Skip to content

CMS: private drafts and published blog posts

CMS: private drafts and published blog posts

Section titled “CMS: private drafts and published blog posts”

The complete case lives at examples/cms/: a real project/ checkout, preparation SQL, synthetic fixtures, bilingual README, offline validate.py and separately opted-in acceptance.py. YAML files use JSON syntax, a valid YAML subset. Verification: the offline validator passed; separate isolated real CLI/API/authz/PostgreSQL/worker acceptance passed twice, including after review fixes. acceptance.py itself was not invoked against a deployed customer server. See verification status for evidence and remaining release boundaries. Stable server and CLI target: 1.1.0.

This is one isolated newsroom, not a multi-tenant CMS, rich-text editor or arbitrary website generator. All business roles are non-admin. cross_tenant:true is intentional for this shared newsroom; it is not tenant isolation. The API is a small POST RPC rather than pretending arbitrary SQL is automatically protected by CRUD row scopes.

File / assetResponsibility
project/viat.yamlComplete intentional authorizers, roles and real authorization AST
project/scripts/post_logic.pyShared pure validation, checked mutations and draft/public projections
project/scripts/posts.pyTrusted principal, ctx.authz.check, parameterized SQL and transactions
project/resources/db.yamlDedicated business PostgreSQL connection; .invalid hostname and labeled fixture credentials
project/endpoints/default/posts.post.yamlClosed body contract, cms-keys admission, target ref: posts
prepare.sqlTable, grants and one synthetic unpublished seed-post
fixtures/Registration bodies and input/expected results
validate.py / acceptance.pyOffline pure-unit layer / explicit live HTTP assertions

Draft fields and published fields are separate columns. An editor’s publish copies the current draft into the public columns in one transaction. Subsequent draft edits do not alter the published copy. read and list never return draft columns; preview is separately authorized. Ownership comes only from the authenticated principal at create and the persisted database row thereafter. Mutations lock that row until commit; read/preview use ordinary reads. There are no schedules or automatic sends in this case.

The post model permits:

RolePublic read/listCreate own draftPreview/edit own draftPreview/edit another author’s draftPublish
readeryesnononono
authoryesyesyesnono
editoryesyesyesyesyes

Authors may edit their own draft even after publication; only an editor changes the public copy. The owner attribute compares to the verified identity subject. No matching permit means deny. This is script-side point enforcement: raw db.query/execute are not row-scoped automatically. Do not bind the resource to a second unreviewed script/CRUD endpoint. A SQL resource credential is still visible in the 1.1.0 worker projection; trust the script/dependency tree and never log it.

Operator prerequisites and safety boundary

Section titled “Operator prerequisites and safety boundary”

Use an activated isolated Viat 1.1.0 instance, matching CLI, an authenticated Owner/Admin (publish and metadata settings), server Python/uv, local Python 3, Bash, curl, jq, and a separately provisioned PostgreSQL service reachable from API nodes. No tools/services are installed by this case. Do not use Viat’s platform database. SQL bootstrap below assumes an existing approved development container with PostgreSQL superuser postgres; adapt the connection transport, not the table/grant contract.

Require a new project and a new business database. Stop if cms-example, cms_example, db or these identities already belong to another exercise. To choose another project slug, change project.slug, the create/select commands and every /p/ and /projects/ URL together. Keep a private record of project slug, DB/container, endpoint UUID, authorizer/key UUIDs and user identities. API tokens and .viat/ never go into source control.

BASE means the instance management API root, without a trailing slash. PLATFORM_ACCESS_TOKEN is an already obtained platform login token/PAT with appropriate scope, not an endpoint key. Authenticate the CLI separately using CLI authentication. Select and verify the exact server before each write.

From the documentation repository root, copy the case to a private working directory. Commands below remain operator instructions, not a claim that this sequence ran against your server.

终端窗口
set -euo pipefail
umask 077
CASE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/viat-cms.XXXXXX")
cp -R examples/cms/. "$CASE_DIR/"
cd "$CASE_DIR"
mkdir records
python3 -I validate.py

This validation imports the actual deployed pure module; decision callbacks are stubs, not a fake claim that Viat’s policy engine was exercised. It checks publication-copy isolation, denial handling, trusted ownership, malformed input and fixture/manifest/model structure. It does not connect anywhere.

  1. Prepare only the new database. Set PG_CONTAINER to your approved business database container. These fixture passwords are for isolated local development only; for nonlocal use provision a real secret outside this checkout and enforce transport security independently. The resource ssl boolean does not enforce peer verification in 1.1.0.
终端窗口
export PG_CONTAINER=your-owned-business-postgres
# Stop on any name conflict. Do not add IF NOT EXISTS or reuse another database.
docker exec "$PG_CONTAINER" psql -U postgres -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE ROLE cms_example LOGIN PASSWORD 'local-fixture-only-not-for-production';"
docker exec "$PG_CONTAINER" psql -U postgres -d postgres -v ON_ERROR_STOP=1 \
-c 'CREATE DATABASE cms_example OWNER postgres;'
docker exec -i "$PG_CONTAINER" psql -U postgres -d cms_example -v ON_ERROR_STOP=1 < prepare.sql
  1. In the copied project/resources/db.yaml, replace plain.host/port with the reachable approved database address. The fixture role/password must match the bootstrap above. Keep the credential in secret, never plain. For a real secret, provision resource db securely first and replace both file secret values with string $secret before filtered push. A placeholder cannot create a new resource.
终端窗口
viat project create cms-example "CMS isolated example"
viat project select cms-example
viat version
cd project
viat push --only meta,resources,scripts,endpoints
viat resource refresh db
cd ..

The metadata is complete and intentional for this new project, including the native password authorizer and cms-keys; other server-only authorizers can be pruned. Never perform this push against an existing project or use an unrestricted all-kind push. push neither runs prepare.sql nor publishes. Resource refresh rebuilds introspection after DDL; script SQL itself is not protected by introspection policies.

  1. Register the four local project end users, not platform members. Creation bodies are the exact objects in fixtures/users.json; no passwords are needed for these API-key-only users. No registration switch is enabled.
终端窗口
# Export BASE and PLATFORM_ACCESS_TOKEN through your private shell/session first.
set -o pipefail
jq -c '.[]' fixtures/users.json | while IFS= read -r user; do
curl --fail-with-body -sS "$BASE/projects/cms-example/users" \
-H "Authorization: Bearer $PLATFORM_ACCESS_TOKEN" \
-H 'Content-Type: application/json' -d "$user" -w '\n'
done > records/users.jsonl
viat apikey create cms-keys --user cms-reader --name cms-reader-test > records/reader.key
viat apikey create cms-keys --user cms-author-a --name cms-author-test > records/author.key
viat apikey create cms-keys --user cms-author-b --name cms-other-test > records/other.key
viat apikey create cms-keys --user cms-editor --name cms-editor-test > records/editor.key
viat apikey list cms-keys > records/key-rows.txt

Keys are one-time secrets on stdout; the list records row UUIDs for revoke, not the token’s short key ID. Inspect each user response’s assigned role and stop on any failed registration; do not continue a partially seeded setup.

  1. Locate only this case’s endpoint and record its actual UUID. Publish explicitly.
终端窗口
curl --fail-with-body -sS "$BASE/projects/cms-example/endpoints" \
-H "Authorization: Bearer $PLATFORM_ACCESS_TOKEN" > records/endpoints.json
POSTS_ID=$(jq -er '[.[] | select(.path=="/posts" and .method=="POST")] | if length==1 then .[0].id else error("expected one posts leaf") end' records/endpoints.json)
printf '%s\n' "$POSTS_ID" > records/posts-id.txt
viat endpoint publish "$POSTS_ID"

Inspect the publish plan/response and authorizer list, then call the published URL, not a draft editor test. Ordinary script dependencies are frozen with this endpoint unless a dependency is marked (live); this case uses no live marker. Authorization model/resource/user settings remain live independently of snapshots.

POST /p/cms-example/posts, bearer key from cms-keys. Required op; optional post_id, title, body default to empty strings. Unknown fields (including forged owner, role, is_admin) are 400. post_id is a lowercase slug, 1–64 characters; title is nonblank, at most 160 characters; body nonblank, at most 20,000. list ignores unused declared fields and returns the first 100 published posts ordered by ID. There is no pagination beyond that bounded example. Each listed row keeps an object-aware authorization check: a later owner-dependent rule must not be bypassed by reusing one subject-only allow decision. This costs up to 100 authorization RPCs, and a denied row fails the list rather than silently filtering it. For larger datasets, design and verify a paginated scoped query; do not replace these checks with a blanket cached allow.

  • create: requires ID/title/body; returns {post_id,revision}; duplicate ID → 409.
  • edit: requires existing ID/title/body; changes only draft; returns {post_id,revision}.
  • publish: existing ID; copies draft to public; returns {post_id,revision}.
  • read: public {post_id,title,body} or 404 if absent/unpublished.
  • preview: draft projection, permission checked first; unauthorized → 403.
  • list: array of public projections; initially [] even though SQL seeded a draft.

All mutations use short database transactions and row locks. There is no optimistic revision precondition; two authorized editors serialize and the later edit wins. IDs are business uniqueness keys; after timeout inspect by ID/preview before retrying rather than assuming rollback. SQL/network failures still need operator investigation.

After explicitly approving remote writes to the new project/database, run the prepared live assertion program from CASE_DIR:

终端窗口
python3 -I acceptance.py --url "$BASE/p/cms-example/posts" \
--reader-key-file records/reader.key --author-key-file records/author.key \
--other-key-file records/other.key --editor-key-file records/editor.key \
--allow-remote-writes

It creates only fixture-post, verifies unpublished reads 404, reader mutations 403, other-author preview/edit 403, author publish 403, anonymous access 401, strict forged fields 400, duplicate create 409, editor publish, and a later private draft that does not alter the public response. The expected bodies are in fixtures/expected.json. It leaves data for inspection; it is not safely rerunnable after success without deliberately cleaning the two recorded fixture IDs. The coordinator’s separate isolated real-stack acceptance passed; this acceptance.py program itself was not invoked against a deployed customer server. Neither result establishes production recovery, load or customer-environment acceptance.

Inspect refusal and execution logs without printing tokens. A 403 is not fixed by turning the endpoint anonymous; a 404 may mean no public version; 409 duplicate requires examining the existing ID. Endpoint rollback does not roll back data, roles or model edits. This example omits deletion, media uploads, approvals, audit-retention policy and production pagination deliberately.

Stop callers, then unpublish and delete only the recorded leaf; do not mirror an empty directory or blanket-delete a tree.

终端窗口
viat endpoint unpublish "$POSTS_ID"
viat endpoint delete "$POSTS_ID"
# For EACH of the four UUIDs in records/key-rows.txt, after checking its name:
viat apikey revoke cms-keys KEY_ROW_UUID
# Delete exactly these identities with platform auth; repeat for the other three.
curl --fail-with-body -sS -X DELETE "$BASE/projects/cms-example/users/cms-reader" \
-H "Authorization: Bearer $PLATFORM_ACCESS_TOKEN"

The other three identities are cms-author-a, cms-author-b, cms-editor. With keys revoked and users removed, delete cms-keys and password only if this recorded new project still has no other dependents, using DELETE /projects/cms-example/authorizers/{name} with the platform bearer. Remove the recorded model rule IDs first (GET /projects/cms-example/authz → rules[].id, then DELETE .../authz/rules/{id}), then DELETE .../authz/types/post, then .../authz/subject-attrs/identity and .../authz/subject-attrs/role, then DELETE .../roles/reader, .../roles/author, .../roles/editor. Stop on dependency conflicts; never force-delete an unexpected dependent.

终端窗口
viat script delete posts
viat script delete post_logic
viat resource delete db
# Inspect these two owned rows before deleting; leave all other rows alone.
docker exec "$PG_CONTAINER" psql -U postgres -d cms_example -v ON_ERROR_STOP=1 \
-c "SELECT post_id, owner FROM cms_posts WHERE post_id IN ('seed-post','fixture-post');"
docker exec "$PG_CONTAINER" psql -U postgres -d cms_example -v ON_ERROR_STOP=1 \
-c "DELETE FROM cms_posts WHERE post_id IN ('seed-post','fixture-post');"

Retain the schema/database for inspection by default. Only after proving exclusive ownership and no additional rows/dependents may the database owner separately drop this case’s table/database/role; that is not implied by resource deletion. Keep necessary evidence, then remove only the exact private CASE_DIR you recorded. There is no registered project DELETE route or project-delete CLI in 1.1.0; leave the empty isolated project. Token revocation, DB cleanup and local key-file removal are separate operations.

Repo sync, authorization, HTTP, CRUD alternative, PostgreSQL. The explicit script checks are needed because this design does not use CRUD. Targeted implementation evidence: crates/viat-cli/src/repo/{viat_yaml.rs,endpoints.rs}, crates/viat-api/src/{lib.rs,handlers/endpoints.rs}, crates/viat-runtime/src/{python.rs,annotations.rs}. Source review is not integration acceptance.