Skip to content

feat(authz): authorization foundation (RBAC slice 1) — CRUD enforced, events pending - #71

Open
phoebus-84 wants to merge 26 commits into
masterfrom
feat/authz-foundation
Open

phoebus-84 wants to merge 26 commits into
masterfrom
feat/authz-foundation

Conversation

@phoebus-84

@phoebus-84 phoebus-84 commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

First authorization slice for Zenflows: CRUD enforced; events pending. It fixes the architecture review's F01 finding (a valid signature did not imply the right to the object) on resource, organization, relationship, role and person CRUD.

  • ExecutionContext built from the verified signer (req_user), the admin key, or fixed-scope service accounts, and passed into domain functions.
  • Pure, deny-by-default Zenflows.Authz.Policy, evaluated inside the same Ecto.Multi transaction as the write, on facts loaded with row locks.
  • zf_resource_control: administrative control of a resource, kept separate from ValueFlows primaryAccountable/custodian. Existing resources are backfilled from primaryAccountable with source: backfill.
  • Organization roles as ValueFlows AgentRelationships with seeded system roles (org.admin, org.member, scoped by in_scope_of):
    • grant and revoke under a per-organization advisory lock;
    • the last admin cannot be revoked;
    • revoked relationships are kept (revokedAt), never deleted.
  • Append-only zf_permission_log (a DB trigger rejects UPDATE and DELETE), written in the same transaction as every permission change.
  • GraphQL:
    • FORBIDDEN/UNAUTHENTICATED error codes;
    • new resourceControl query;
    • admin-key assignOrganizationAdmin mutation;
    • new fields AgentRelationship.inScopeOf/revokedAt and AgentRelationshipRole.systemKey.
  • Guard tests: every mutation must either declare an authz action or sit on an explicit pending allowlist, and privileged helpers may only be called from allowlisted places.

Design and rationale: docs/authz-foundation-design.md (see §14 for how it differs from the original draft). Release notes, including behaviour changes, operations steps and known limitations: docs/authz-release-notes.md.

Before deploying

  • Existing organizations have no admin after the upgrade. Assign admins with assignOrganizationAdmin before users need to edit them.
  • Hard delete is no longer possible for organizations created after this change, or for persons who created organizations or resources. An archive path is still to be decided.
  • Event authorization (F02) is out of scope. createEconomicEvent can still affect another agent's resource.
  • New env vars: AUTHZ_ENFORCE and AUTHZ_ALLOW_UNCHECKED. The server refuses to start in production with either one set to its permissive value.

Test plan

  • Full suite: 684 tests, 7 failures, none caused by this branch:

    • 4 fail on master too: restroom.test.exs:25,35,41 and economic_resource/domain.test.exs:81.
    • 3 are admin-key GraphQL tests failing with "you are not an admin" against the local zenflows-crypto image: person/type.test.exs:94,155 (already failing on master) and the new authz/organization.test.exs:79.
  • Alice/Bob negative tests at domain and GraphQL level, including aliases and several root fields in one document.

  • Concurrency tests:

    • a revoke racing an update;
    • two admins revoking each other;
    • a grant racing a revoke;
    • two-organization mutual revokes.

    The race trials hit Postgres deadlocks (40P01) on the earlier code and pass now.

  • Migrate, roll back and re-migrate on a fresh database; every seeded resource gets a backfilled controller.

  • Run test/authz/organization.test.exs and test/vf/person/type.test.exs against a working Restroom/zenflows-crypto (admin-key path).

🤖 Generated with Claude Code

Cross-repository integration

Workstream: #74
Shared label: workstream:authz-integration. This is a non-closing backlink; merging one slice must not close the overall integration.

phoebus-84 and others added 26 commits October 1, 2026 11:31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
controller_for/3's catch-all let an :anonymous principal become the resource
controller via the declared primary_accountable_id, contradicting the module
doc ("never the declared receiver alone") and the deny-by-default policy for
anonymous callers. multi_on_create/3 now authorizes resource.create for
:anonymous contexts before assigning control, aborting the whole multi
(event + resource + control all roll back) on deny.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eadlock

Concurrent revokes/updates of two different system-role relationships in
the same organization could deadlock (Postgres 40P01): each transaction
locked its own target row FOR UPDATE before reaching the organization's
advisory lock, taken only once Membership.multi_revoke's authorize step
ran (which also locks the actor's own admin row FOR SHARE). Two such
transactions could each hold one row lock while waiting on the other's.

Fix: AgentRelationship.Domain.multi_load/3 now peeks at the target row
without locking it, and if it is a system-role relationship, takes
Membership.lock_org/2 (now public) before locking the row FOR UPDATE,
so every transaction touching an org's system relationships acquires
locks in the same order.

Added test/authz/concurrency_revoke.test.exs: a deterministic
handshake test plus a genuinely unsynchronized concurrent-race test
(verified to reproduce the pre-fix deadlock reliably, and to never
raise against the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ganizationAdmin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ution

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The line-based regex missed a multi-line %ExecutionContext{...request_id: ...} literal split across lines. Scan each source file's full content instead, via an extracted context_literal?/1 helper covered by its own unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nator ruling

Assert errors != [], each error matches FORBIDDEN, and the resource is
unchanged, instead of splitting into three requests. Absinthe still
collapses the three sibling non-null field errors into fewer entries
in the response, but the unchanged resource state plus the all-FORBIDDEN
check together prove every resolver was independently denied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- api.test.exs: assert Alice's organization name and Alice's own name are
  unchanged after Bob's denied updateOrganization/updatePerson calls, not
  just the FORBIDDEN error shape.
- concurrency.test.exs: wrap the post-assert_receive assertions in
  try/after so the revoker task always gets :commit, even if an assertion
  fails; otherwise it stays parked in its receive holding row locks and
  on_exit cleanup can hang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Facts.admin_of/3 now takes the organization ids a decision needs and
locks FOR SHARE only the actor's admin rows of those organizations,
instead of every org.admin row of the actor.  Membership.multi_grant
takes the organization's advisory lock before loading admin_of, the
same order as multi_revoke.  Together this removes the same-org
grant-vs-revoke deadlock and the cross-org mutual-revoke deadlock.

Adds deterministic and repeated-trial race tests for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…content

- guard: ExecutionContext.admin_key/1 is only called from the three
  admin-key resolvers.
- GraphQL: an unsigned updateEconomicResource with allow_unchecked off
  is UNAUTHENTICATED and leaves the resource unchanged.
- Permission log rows of grant/revoke/resource create+delete/person
  create+delete carry only the allowed before/after keys and no public
  keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AgentRelationship.system_changeset/2 requires in_scope_of == object.
- Backfill test checks the logged controller_id.
- Spec: §10 has no partial unique index, §3.4 names Log.multi_insert/4
  and insert/3, §11 describes the pending-mutation allowlist, §14 delta
  13 describes scoped admin_of locking and the grant lock order.
- Release notes: hard-delete consequences of revoke-only grants,
  backfilled organization-controlled resources, rollback deleting grants,
  TRUNCATE not blocked by the trigger; drop the fixed cross-org deadlock
  limitation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch had an error being deployed

1 failed deployment
test_trace — db80d263 Deployed Oct 1, 2026 by phoebus-84 via run_tests #54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

workstream:authz-integration Cross-repository Zenflows, SDK, GUI and service authorization integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant