Skip to content

feat(server): add access control - #1398

Open
Teingi wants to merge 36 commits into
oceanbase:masterfrom
Teingi:codex/handoff-access-control-implementation
Open

feat(server): add access control#1398
Teingi wants to merge 36 commits into
oceanbase:masterfrom
Teingi:codex/handoff-access-control-implementation

Conversation

@Teingi

@Teingi Teingi commented Aug 30, 2026

Copy link
Copy Markdown
Member

Which issue or RFC does this PR close?

Implements #1395.

Design reference: #1396.

Rationale for this change

The current optional static Bearer token authenticates one deployment-local caller but cannot express per-user, per-scope, or exact-Handoff visibility. Handoff Receipts also record receiver observations; they are not authorization grants.

This change adds an independent Server Access Control boundary so user A can grant user B least-privilege access to one committed Handoff Revision without exposing the surrounding scope. Runtime domain APIs remain free of Principal, role, and permission parameters, and deployments can replace the built-in decision provider and relationship store with an external authorization system.

What changes are included in this PR?

  • Add typed Principal, action, structured resource, fixed role, binding, decision, and data-minimized audit models.
  • Add a built-in hierarchical RBAC provider and Server-owned relational binding/audit schema for SQLite, OceanBase, and seekDB.
  • Add replaceable AuthorizationProvider, RelationshipWriter, and AccessAuditStore boundaries for integrations such as OpenFGA, Casbin, Oso, or an AuthZEN-compatible PDP.
  • Add /v1/access/me, check, batch-check, safe resource listing, role listing, binding management, and audit APIs plus typed Python SDK methods.
  • Annotate protected OpenAPI operations with generated x-powercontext-access requirements and enforce them at one Server PEP before Runtime dependencies or business metrics.
  • Preserve the authenticated Principal across the MCP internal ASGI bridge while still executing authorization for logical MCP operations.
  • Add disabled, legacy-static-admin, and enforced rollout modes; the default legacy mode preserves the existing static-token administrator behavior.
  • Add English and Chinese configuration and HTTP API documentation.
  • Fix OceanBase Handoff trigger cursor creation to avoid an incompatible nested SAVEPOINT while retaining SQLite's concurrent-insert behavior.
  • Add focused Access Control, HTTP, MCP, SDK, OpenAPI, persistence, and Server tests.

Are there any user-facing changes?

Yes. Authenticated deployments gain public Access APIs, stable 403 behavior, exact-Handoff receiver grants, Access audit records, and two Access configuration settings. Existing static-token deployments remain compatible through the default legacy-static-admin mode.

The change adds Server-owned Access tables but does not add identity or ACL fields to Handoff, Source, Memory, Work, or other Runtime domain records. scope_id remains a business partition rather than an authorization credential.

How was this change tested?

  • PRE_COMMIT_HOME=/tmp/powercontext-prek-cache make check
  • .venv/bin/python -m pytest -q -p no:cacheprovider (974 passed, 9 skipped)
  • make contract-test (30 passed)
  • make docs-test
  • Real configured acceptance journey using the local .env OceanBase, generation LLM, and Embedding provider:
    • persisted and vector-searched Memory;
    • generated, finalized, and committed a real Handoff;
    • granted one exact Revision from A to B;
    • verified HTTP allow/deny boundaries, MCP Principal propagation, audit redaction, restart persistence, revocation, and post-revocation denial;
    • revoked the test binding and removed the failed-run Runtime fixture while preserving append-only low-sensitivity audit evidence.

AI usage statement

OpenAI Codex (GPT-5) was used to inspect the repository contracts, implement the Access Control layer and generated API changes, diagnose the real OceanBase SAVEPOINT failure, and run the validation described above. The author directed the design and publication scope.

@Teingi
Teingi marked this pull request as ready for review September 1, 2026 14:17
@Teingi Teingi changed the title feat(server): add Handoff access control feat(server): add access control Sep 3, 2026
…s-control-implementation

# Conflicts:
#	tests/test_transport.py
…s-control-implementation

# Conflicts:
#	src/powercontext/builtin/runtime/application.py
#	src/powercontext/builtin/runtime/composition.py
#	src/powercontext/client/__init__.py
#	src/powercontext/client/client.py
#	src/powercontext/server/app.py
…s-control-implementation

# Conflicts:
#	docs/en/docs/reference/configuration.md
#	docs/zh/docs/reference/configuration.md
#	integrations/dsh/plugins/powercontext/lib/index.js
#	integrations/dsh/plugins/powercontext/openapi/powercontext.yaml
#	integrations/dsh/plugins/powercontext/src/operations.generated.ts
#	integrations/opencode/plugins/powercontext/lib/index.js
#	integrations/opencode/plugins/powercontext/src/operations.generated.ts
#	integrations/pi/plugins/powercontext/src/operations.generated.ts
#	openapi/powercontext.yaml
#	src/powercontext/builtin/persistence/cursors.py
#	src/powercontext/client/__init__.py
#	src/powercontext/client/client.py
#	src/powercontext/http/__init__.py
#	src/powercontext/http/_generated/models.py
#	src/powercontext/http/_generated/operations.py
#	src/powercontext/http/_generated/schema.py
#	src/powercontext/server/app.py
#	src/powercontext/server/factory.py
#	src/powercontext/server/static/skills.js
#	src/powercontext/server/templates/pages/skills.html
#	src/powercontext/server/web.py
#	tests/e2e/real_experience_skill/harness.py
#	tests/test_api_contract.py
#	tests/test_dashboard.py
@Teingi

Teingi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author
方法 接口 用途 成功返回
GET /v1/access/me 获取当前 Principal 及 Access 能力 AccessMeResponse
POST /v1/access/check 检查一组 all/any 组合权限要求 AccessCheckResponse
POST /v1/access/resources/list 列出当前 Principal 已经可见的资源 AccessResourcePage
POST /v1/access/roles/list 列出系统内置角色定义 AccessRolePage
POST /v1/access/bindings/list 查询管理边界内的授权关系 AccessBindingPage
POST /v1/access/bindings/create 幂等创建授权关系 AccessBinding,HTTP 201
POST /v1/access/bindings/revoke 通过版本号 CAS 撤销授权关系 更新后的 AccessBinding
POST /v1/access/bindings/replace 原子替换授权关系 AccessBindingReplacement
POST /v1/access/audit/list 查询最小化的 Access 审计记录 AccessAuditPage

Comment thread src/powercontext/server/authz/models.py
Comment thread src/powercontext/server/app.py Outdated
Comment thread src/powercontext/server/settings.py
@thunguo
thunguo self-requested a review September 3, 2026 10:24
Comment thread src/powercontext/http/_generated/models.py
Comment thread src/powercontext/server/app.py
Comment thread openapi/powercontext.yaml Outdated
Comment thread src/powercontext/server/authz/service.py
Comment thread src/powercontext/server/authz/authzen.py
Comment thread src/powercontext/server/authz/models.py
…s-control-implementation

# Conflicts:
#	integrations/dsh/plugins/powercontext/lib/index.js
#	integrations/dsh/plugins/powercontext/openapi/powercontext.yaml
#	integrations/dsh/plugins/powercontext/src/operations.generated.ts
#	integrations/opencode/plugins/powercontext/lib/index.js
#	integrations/opencode/plugins/powercontext/src/operations.generated.ts
#	integrations/pi/plugins/powercontext/src/operations.generated.ts
#	openapi/powercontext.yaml
#	scripts/generate_api.py
#	src/powercontext/builtin/artifacts/handoff/service.py
#	src/powercontext/client/client.py
#	src/powercontext/http/__init__.py
#	src/powercontext/http/_generated/models.py
#	src/powercontext/http/_generated/operations.py
#	src/powercontext/http/_generated/schema.py
#	src/powercontext/server/app.py
#	src/powercontext/server/settings.py
#	src/powercontext/server/web.py
#	tests/test_api_contract.py
#	tests/test_client.py
#	tests/test_dashboard.py
@tlyyxjz

tlyyxjz commented Sep 4, 2026

Copy link
Copy Markdown

Following up from #1395 — congrats on landing this so quickly, the boundary design looks clean (single PEP, fail-closed modes, data-minimized audit are all the right calls).

Since the AuthorizationProvider boundary explicitly names Casbin as an integration target, a note from the Casbin ecosystem side on what a first integration would look like:

Fit check against the built-in provider's model. The PR's hierarchical RBAC (subject / action / resource / context + role bindings) maps 1:1 onto casbin's rbac_with_domains pattern — p, role, action, resource, dom policy rows + g, user, role, scope bindings, with the same semantics as your built-in hierarchical resolution. Two things casbin would add beyond the built-in provider:

  1. Wildcard/ABAC matchers for cases like "readonly can GET everything under a scope" without enumerating rows, and attribute conditions (e.g. revision-time windows) in the matcher instead of pre-filtering.
  2. Policy storage via existing adapters — for PowerContext the natural one is the SQLAlchemy/Python adapter (pycasbin), reusing your OceanBase/seekDB connection config so policies live beside the binding tables you already created.

Concrete offer: I'm happy to prototype a casbin_provider.py implementing your AuthorizationProvider protocol (check + batch-check backed by pycasbin, with your existing binding schema imported into policy on first load) as a reference integration — it would double as a validation that the boundary is truly engine-agnostic. We also have casbin-config-doctor (offline model/policy diagnostics with near-miss attribution) which would help your users debug "why was this 403" once custom providers land — 403-without-explanation is the #1 support burden of embedded authz engines.

One question: for the handoff.receiver exact-revision grant — do you intend those to stay in the built-in relationship store only, or should external providers be able to write grants via RelationshipWriter too? That determines whether a casbin integration should treat casbin as the source of truth for bindings or as a read-only decision mirror.

@Teingi

Teingi commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

@tlyyxjz Thanks for the thoughtful offer. This PR already includes an embedded CasbinAuthorizationProvider and open_casbin_access_control. In the first version, the canonical relational Access repository remains the source of truth for bindings and ownership; the adapter materializes active relationships into a fresh Casbin enforcer for decisions instead of maintaining a second persisted Casbin policy store.

RelationshipWriter is intentionally separate from AuthorizationProvider. An external decision provider may supply a paired writer and declare relationship_management=true; without one, binding mutations fail with relationship_management_unavailable and relationships are administered in the external system. Receiver and other bindings are therefore not restricted to the built-in store. Also, handoff.receiver targets the logical Handoff identity across revisions, rather than an exact-revision grant.

I clarified this composition and source-of-truth boundary in f6d2600. A native Casbin-backed writer could be a useful follow-up if it preserves the same idempotency, versioning, ownership, audit, and safe-filtering contracts.

…s-control-implementation

# Conflicts:
#	docs/en/docs/reference/configuration.md
#	docs/zh/docs/reference/configuration.md
#	integrations/dsh/plugins/powercontext/lib/index.js
#	integrations/dsh/plugins/powercontext/src/operations.generated.ts
#	integrations/opencode/plugins/powercontext/src/operations.generated.ts
#	integrations/pi/plugins/powercontext/src/operations.generated.ts
#	openapi/powercontext.yaml
#	scripts/generate_api.py
#	src/powercontext/client/client.py
#	src/powercontext/http/__init__.py
#	src/powercontext/http/_generated/models.py
#	src/powercontext/http/_generated/operations.py
#	src/powercontext/http/_generated/schema.py
#	src/powercontext/server/app.py
#	src/powercontext/server/factory.py
#	tests/test_client.py
#	uv.lock
@tlyyxjz

tlyyxjz commented Sep 5, 2026

Copy link
Copy Markdown

Thanks for the detailed breakdown — that composition makes sense: enforcer materialized fresh from the relational source of truth on each decision cycle avoids the dual-write consistency problem entirely.

Two follow-ups on the native Casbin-backed RelationshipWriter you mentioned:

  1. We'd like to take a shot at it. A writer backed by pycasbin (the Python port, same org, actively maintained) can preserve those contracts:

    • Idempotency: add_grouping_policy is a no-op on duplicates, and we'd key mutations on the binding's (principal, role, resource) triple so retries converge.
    • Versioning/ownership: writes flow through your existing binding mutation path with the writer acting as the policy store behind it — audit events emitted at the same boundary as the built-in writer.
    • Safe-filtering: Casbin's filtered adapter (load_filtered_policy) maps cleanly to your per-principal view materialization, so the enforcer only ever loads the subject's reachable subtree.
  2. One design question first: for handoff.receiver, the writer would need to express "logical Handoff identity across revisions" as a stable p/g subject — is there a doc or test fixture for the RelationshipWriter contract (the relationship_management=true declaration path) we should read first? We'd like to sanity-check the scope against those contracts before promising a shape.

If the spec looks like a fit, we're glad to prototype a writer + filtered-adapter sync and open a draft PR for review.

@Teingi

Teingi commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

@tlyyxjz Thanks for offering to work on this. I'd be happy to review a separate draft PR for a native Casbin writer. Starting with the contract and a small working example makes sense.

The most useful references are:

For the Handoff identity, this is the resource we use:

ResourceRef.artifact(
    "scope-a",
    family="handoff",
    artifact_id="handoff-a",
)

Its canonical key includes the scope, family, and artifact ID. It has no revision field. A handoff.receiver binding therefore applies to that logical Handoff across revisions. If revision 2 is created, the receiver does not need another grant. The binding's own version is separate and tracks changes to the access relationship. The Casbin model can choose how to encode this resource, as long as it preserves that identity and keeps different scopes separate.

On idempotency, add_grouping_policy handles duplicate rules, but the public API also needs to remember the request. A retry with the same caller, idempotency key, and payload must return the same binding. Reusing that key with a different payload must return 409, even if the principal/role/resource triple is unchanged. Changing the expiry is one example. We also need to distinguish a retry of an old grant from a new grant after revocation. The triple alone doesn't carry enough information for those cases; the writer will need durable binding and request metadata somewhere.

Your suggestion to keep writes behind the existing service boundary works for authorization checks and audit emission. The storage side still has to implement the version checks and atomic replacement. For handoff.receiver, there can be at most one active direct receiver binding per logical Handoff. Replacing Bob with Alice must check expected_version and revoke the old binding and create the new one atomically. A stale competing update must fail, and retrying a successful replacement must return the same result. These guarantees currently live in the relational repository, so passing through the service alone won't supply them to a new backend. Ownership also has its own operations and is system-managed; the public Binding APIs cannot assign or transfer it.

For filtering, load_filtered_policy looks useful for limiting what an enforcer loads. We also need the provider to implement resolve_resource_filter: given the caller and action, return the authorized logical resource keys and parent constraints, with a policy revision. The repository applies that filter before counts, sorting, and pagination. Building it needs to account for active bindings, expiry, ownership, trusted groups where supported, and the fixed parent-role implications. Loading a subject's policy rows is only part of that work. If the adapter cannot produce a complete, safe filter for a list operation, that operation must fail closed and the capability declaration must reflect the limitation.

For the proposed sync, please describe where the binding IDs, request metadata, versions, and ownership records would live, and how writes become visible to decisions. I want to avoid ending up with two independently writable copies of the same relationship. A short explanation of the storage layout and failure behavior would help us review that before the implementation gets large.

I'd start the prototype with one Handoff: establish its owner, grant Bob receiver access, publish another revision, replace Bob with Alice, retry that replacement, then revoke it. Run the permission checks and resource listing throughout, including a caller with no grant and a Handoff in another scope. That would give us something concrete to review early. The relationship_management=true declaration can follow once the complete relationship contract is covered.

@PsiACE PsiACE left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed two behaviors in resources/list:

  • An Experience or Skill created through the base Artifact API can be shared and read by the grantee, but it does not appear in /v1/access/resources/list because discovery only scans approved candidates.
  • A server.admin calling /v1/access/resources/list for scope.admin Scopes receives a 503 because the server-level parent constraint is rejected, even though this action/resource combination is accepted by validation.

Could you confirm whether these are intended limitations for this version? Other than these, LGTM.

@Zxf-xufeng Zxf-xufeng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Teingi

Teingi commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

@PsiACE Thanks for checking these paths. I've fixed the discovery gaps in c71d370c.

For Experience and Skill, I reproduced the missing entries when the caller inherited read access through a Scope role. Direct artifact.viewer grants and owner access already contributed exact resource keys, so those entries appeared correctly. Scope discovery now queries committed Artifact heads within the authorized Scopes. This covers resources created through the base Artifact API as well as those committed through review approval.

The server.admin case was also an implementation gap. Server parent constraints now expand to Scopes in the current deployment, so listing scope.admin resources returns the manageable Scope identities instead of 503. The same expansion supports artifact.share discovery. Administration still does not grant scope.read or artifact.read.

I added HTTP regressions for both the built-in and Casbin compositions. They cover both Artifact families, inherited and direct grants, pagination and totals, duplicate removal when grants overlap, isolation from other Scopes, and management without content access. All six new cases failed before the fix and pass now.

Validation:

  • make check passed.
  • The focused Access, HTTP, MCP, and base Artifact tests passed: 46 tests.
  • The full suite finished with 1,392 passed, 17 skipped, and one failure in the unchanged OpenCode probe timeout test. Its 0.2-second timeout expired before the helper process wrote its PID file. I reran the entire OpenCode CLI test file separately; all 14 tests passed.

@tlyyxjz

tlyyxjz commented Sep 5, 2026

Copy link
Copy Markdown

Thanks — this is exactly the context we needed. Before writing the writer, here is the storage layout and failure behavior, per your question. The short version: the canonical relational Access schema stays the only writable copy of relationships; the Casbin side is derived decision state that is materialized inside the same transaction as the mutation. There is never a second independently writable relationship copy.

Where each artifact lives

Artifact Storage Who writes it
binding_id, subject, resource, role, state, expires_at, version (CAS), policy_revision canonical access_bindings table — the RFC's Binding model fields (RFC §Access Binding model, L851+) the writer, via the canonical repository's existing CAS logic
Request metadata (idempotency_key, payload hash) a dedicated idempotency ledger keyed (grantor, idempotency_key)(payload_hash, binding_id, request_kind) the writer, same transaction as the mutation
Ownership the existing one-per-resource owner relation (RFC: ownership is not an AccessBinding, has no expiry, and is not assignable through the Binding APIs) only establish_artifact_owner / system-managed paths
Casbin policy rows not persisted — materialized fresh from the committed canonical state at evaluation time, exactly as the current adapter does (RFC §Casbin adapter: "does not maintain a second persistent Casbin policy store") nobody writes policy rows directly

The idempotency ledger answers the three cases you called out:

  • same grantor + key + same payload → return the original binding (the ledger row points at it);
  • same key + different payload (e.g. changed expiry) → 409, even if the subject/role/resource triple is unchanged — the payload hash is what conflicts, not the triple;
  • retry of an old grant vs. a new grant after revocation: the ledger row is bound to the binding lifecycle. A revoked binding's ledger row still resolves to that revoked binding (retry returns it as-is), while a post-revocation re-grant uses a new key and creates a new binding — mirroring test_singleton_binding_replacement_is_atomic (tests/test_access_control.py L179-244), where the re-grant of Bob uses receiver-bob-again and yields a fresh binding.

Write path and visibility

  1. Mutation runs against the canonical repository in one transaction: CAS version check → revoke/create/replace rows → ledger row upsert → bump a monotonic policy_revision → stamp the new revision on the touched bindings.
  2. Decisions materialize the enforcer from committed canonical state only. So a decision either sees both the binding and its policy effect (transaction committed) or neither — there is no window where the API reports a grant whose decision state is missing, and no window where decision state exists without a committed binding. This is the RFC's own rule ("The Server must not report a successful grant and then write only a local shadow record", §Relationship administration) applied to the writer side.
  3. policy_revision on decisions comes from the same counter, which is also what makes our adapter's revision semantics line up with the built-in one (test_builtin_and_casbin_adapters_share_terminal_semantics, tests/test_access_adapters.py L55-93, already asserts equal policy_revision across adapters).

Failure behavior

Failure Behavior
Version conflict (stale competing replace/revoke) CAS check fails → 409-class conflict, nothing mutated
Duplicate / conflicting idempotency key ledger lookup: same payload → original result replayed; different payload → 409
Materialization or validation error mid-transaction transaction rolls back → API returns an error; no binding, no ledger row, no decision-state change
Expired binding never deleted (RFC: "the decision treats it as denied") — the canonical row keeps state=active with a past expires_at; materialization and resolve_resource_filter both exclude it
Incomplete filter inputs (trusted groups, parent-role implications, ownership) resolve_resource_filter refuses to return a partial filter → the list operation fails closed, and AccessProviderCapabilities.safe_resource_filtering stays false for that composition (the declaration mechanism is already in open_casbin_access_control, composition.py L76-103)

One deliberate consequence: because we keep the RFC's "materialize fresh per evaluation" model, load_filtered_policy is not the load-bearing mechanism for correctness — filtering is a performance tool, and the correctness of both decisions and listing always derives from the same committed canonical rows. If you'd rather see a persistent Casbin policy table for large-policy-set performance, it can still work under the same rule (single writer: only the mutation transaction may write it, plus a revision equality check at decision time), but our default proposal avoids it.

Prototype conformance suite (the Handoff scenario you outlined)

  1. establish owner (Alice) → assert ownership is visible to decisions and to resolve_resource_filter;
  2. grant Bob handoff.receiver → check + list show Bob; retry the same create → same binding back;
  3. publish revision 2 → no new grant needed (binding spans revisions; ResourceRef.artifact has no revision field);
  4. replace Bob with Alice at expected_version=1 → old binding revoked, new active, single active direct receiver;
  5. replay the same replace request → identical BindingReplacement back;
  6. stale expected_version → conflict; second concurrent receiver create → conflict ("maximum active Bindings");
  7. revoke Alice → Bob's original grant stays revoked; re-grant Bob with a fresh key → new binding;
  8. throughout: a caller with no grant and a Handoff in another scope must be denied on both check and list, and ownership must be untouched by every Binding API.

Open questions before we cut the draft PR

  1. Does the idempotency ledger belong in the canonical repository's own schema (RFC: the Binding Repository "owns an independent schema, migrations, and API"), or do you want it as a sibling table in the same access schema?
  2. For resolve_resource_filter, the current built-in path derives parent constraints from fixed role tables. We plan to reuse that derivation — is there an existing helper you'd want us to call rather than re-derive?
  3. policy_revision parity: the comparison test asserts builtin and Casbin adapters report the same revision. Should the native writer keep that invariant (we propose yes), or is a distinct revision namespace acceptable for the new composition?

Happy to adjust the layout based on your review — the draft PR follows once the storage shape is agreed.

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.

6 participants