Skip to content

fix(cedar): scope the @owner forbid to mutations so reads follow @access - #156

Merged
rrrodzilla merged 4 commits into
mainfrom
fix/151-owner-restrict-reads
Sep 10, 2026
Merged

fix(cedar): scope the @owner forbid to mutations so reads follow @access#156
rrrodzilla merged 4 commits into
mainfrom
fix/151-owner-restrict-reads

Conversation

@rrrodzilla

Copy link
Copy Markdown
Contributor

Fixes #151.

The defect

@owner generates a forge.<schema>.owner_restrict forbid whose action list covered Read and List as well as Update and Delete. In Cedar a forbid overrides every permit, so the annotation silently revoked the schema's own @access(read: [...]) grant for every record the caller had not personally created. Only platform_admin was exempt. Any shared workspace built on an @owner schema became per-user silos, and the schema text gave no hint that it had happened.

A required @owner field made it worse. build_resource_placeholder (authz/adapters.rs:292) populates required fields with type defaults, so the schema-level placeholder used for List and Create carried owner = "". That satisfied the forbid's resource has "<owner>" conjunct, the collection-level check was denied, and the endpoint answered 403. An optional owner field on an otherwise identical schema answered 200 with zero rows. Same annotation, two different failures, neither of them what the schema says.

Found in the FEMA CMDS demo workspace, where the set of dead screens turned out to be exactly the set of schemas carrying @owner (NewsRelease, Campaign, DistributionList, Distribution) and every schema without it worked. Bastion (schemas/project.schema: owner_id: text required @owner alongside @access(read: ["member","pm","finance","admin"], ...)) is broken the same way — a member can read zero projects.

The fix

Scope owner_restrict to Update and Delete:

@id("forge.task.owner_restrict")
forbid (
    principal is Forge::Principal,
    action in [Action::"UpdateTask", Action::"DeleteTask"],
    resource is Task
) when {
    resource has "created_by"
    && (!(principal has id) || resource["created_by"] != principal.id)
    && !(principal in Forge::Group::"platform_admin")
};

Who may see a record is now decided by @access(read:) alone. Everything @owner is actually relied on for is unchanged:

  • inject_owner_on_create (access.rs:446) still stamps the creator.
  • strip_owner_on_update (access.rs:475) plus routes/entities.rs:3183 still make the value immutable after create.
  • The owner_write permit still lets a creator update their own record.
  • tenant_guard still forbids cross-tenant reads — its action list intentionally keeps Read and List.

Cedar remains the sole visibility gate; there is no query-layer ownership filter to keep in step.

Because the placeholder resource no longer participates in an owner decision, required and optional @owner fields now behave identically. The broader hazard — that a hand-written policy reading resource has "<field>" against a placeholder is comparing against a fabricated default — is filed separately as #155.

Behavior change

This widens read access relative to previous releases, so it is written up under ### Security / Breaking with a migration note. A deployment that was relying on @owner for privacy, deliberately or not, will see those records become readable by the roles its own schema already granted. The migration note gives the custom Cedar policy that restores owner-only reads explicitly.

Tests

Two generator regressions in policy_gen.rs:

  • owner_restrict_covers_mutations_only_and_never_readsUpdateTask/DeleteTask present, ReadTask/ListTask absent.
  • owner_schema_keeps_its_read_permit_intact — a required @owner schema with @access(read: ["viewer","platform_user"], ...) still emits its read_viewer permit, and no owner-derived forbid covers a read.

Three integration tests asserted the old behavior and now assert the corrected one, each keeping its original point:

  • auth_demo::public_reads_enforce_record_forbids_and_ownership — a custom record forbid still hides a row; the owned notice is now readable, and its permissions block reports update: false, delete: false.
  • auth_demo::demo_all_auth_layers_combined — the non-owner manager now reads the document and is refused on PUT and DELETE.
  • conditional_entities::conditional_delete_preserves_owner_denial_without_hiding_the_record (renamed) — the owner denial on DELETE still precedes conditional-header parsing, and the GET the schema grants succeeds.

Gates

  • cargo nextest run --workspace --exclude schema-forge-mssql2481 passed, 8 skipped, including full_policy_set_validates_against_generated_schema (strict-mode Cedar bundle validation).
  • cargo clippy --workspace --exclude schema-forge-mssql --all-targets -- -D warnings — clean.
  • cargo fmt --all -- --check fails on main today at 329 pre-existing sites (local rustfmt applies 2024 style edition to committed 2021-era formatting). Verified per-file that this branch introduces no new drift: the touched files have the same drift sites before and after (policy_gen 4, auth_demo 3, conditional_entities 7), at shifted line numbers only. Reformatting 79 files does not belong in this PR.

--workspace without --exclude schema-forge-mssql cannot build on any branch: schema-forge-mssql and schema-forge-surrealdb unify into compile_error!("Features mssql and surrealdb are mutually exclusive.") in acton-service 0.43.0. Confirmed pre-existing by stashing this change and re-running.

Docs

docs/query-api-reference.md §12 bullet 3 said record-level visibility is what @owner does. Rewritten to say that every returned row is authorized individually after the query runs, that @owner establishes who created a record and stops anyone else changing it, and that owner-only reads need a custom Cedar policy.



The generated `forge.<schema>.owner_restrict` policy listed Read and List
alongside Update and Delete. A Cedar forbid overrides every permit, so the
annotation silently revoked the schema's own `@access(read: [...])` grant for
every record the caller had not personally created, with only platform_admin
exempt. Any shared workspace built on an @owner schema became per-user silos.

A required @owner field made it worse: `build_resource_placeholder` fills
required fields with type defaults, so the schema-level placeholder carried
`owner = ""`, satisfying the forbid's `resource has "<owner>"` conjunct and
denying the whole collection with a 403. An optional owner field on an
otherwise identical schema answered 200 with zero rows instead.

Scope the policy to Update and Delete. Who may see a record is decided by
`@access(read:)` alone; a schema wanting owner-only reads says so with a
custom Cedar policy, and the migration note shows how. Everything @owner is
relied on for is unchanged: inject_owner_on_create still stamps the creator,
strip_owner_on_update still makes the value immutable, and the owner_write
permit still lets a creator update their own record.

Two generator regressions cover the shape directly, and the three integration
tests that asserted the old behavior now assert reads succeed while the
non-owner write and delete stay refused.

Fixes #151
Refs #155
…hange

The ignored PostgreSQL HTTP case asserted 403 on GET for a record owned by
someone else, which is the behavior this branch corrects. Keep PUT, PATCH and
DELETE refused before the conditional header is honored, and assert the read
succeeds while its permissions block reports update and delete as false.
…ntext

#155 landed `context.resource_is_placeholder`, so the migration recipe for
restoring owner-only reads can now scope its forbid to concrete records. Guard
the example with it and scope it to Read, because a collection request
preflights the Read action at schema scope before checking each row. Without
the guard the hand-written policy reintroduces exactly the 403 this release
fixes.
@rrrodzilla

Copy link
Copy Markdown
Contributor Author

Merged main (PRs #154 and #157) into this branch — clean, no conflicts.

Two follow-ups from #155 landing, since context.resource_is_placeholder now exists:

  • The migration recipe for restoring owner-only reads is guarded with !context.resource_is_placeholder and scoped to Read rather than Read + List, per docs/custom-policy-context.md (a collection request preflights the Read<Schema> action at schema scope before checking each row). Without that guard the hand-written policy reintroduces exactly the 403 this PR fixes.
  • The changelog no longer describes the placeholder default-value hazard as open; it points at the new context flag instead.

docs/query-api-reference.md §12 now links to the context doc from the same bullet.

@rrrodzilla
rrrodzilla merged commit fa080a2 into main Sep 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@owner generates a Read/List forbid that silently overrides the schema's @access(read:) grant

1 participant