From 770aa2693ed02087607788d7a5f9977ab5476735 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sun, 30 Aug 2026 19:05:21 +0800 Subject: [PATCH 01/22] feat(server): add Handoff access control --- docs/en/docs/reference/configuration.md | 16 + docs/en/docs/reference/http-api.md | 38 + docs/zh/docs/reference/configuration.md | 13 + docs/zh/docs/reference/http-api.md | 35 + .../powercontext/src/operations.generated.ts | 9 + .../powercontext/src/operations.generated.ts | 9 + .../powercontext/src/operations.generated.ts | 9 + openapi/powercontext.yaml | 697 +++++++++++++++++- scripts/generate_api.py | 58 +- .../builtin/persistence/cursors.py | 29 +- src/powercontext/client/client.py | 70 ++ src/powercontext/http/__init__.py | 46 ++ src/powercontext/http/_generated/models.py | 222 ++++++ .../http/_generated/operations.py | 346 +++++++++ src/powercontext/http/_generated/schema.py | 687 ++++++++++++++++- src/powercontext/server/app.py | 432 ++++++++++- src/powercontext/server/authz/__init__.py | 69 ++ src/powercontext/server/authz/composition.py | 57 ++ src/powercontext/server/authz/errors.py | 79 ++ src/powercontext/server/authz/models.py | 279 +++++++ src/powercontext/server/authz/repository.py | 492 +++++++++++++ src/powercontext/server/authz/service.py | 483 ++++++++++++ src/powercontext/server/context.py | 18 + src/powercontext/server/factory.py | 56 +- src/powercontext/server/middleware.py | 29 +- src/powercontext/server/settings.py | 9 + tests/builtin/persistence/test_cursors.py | 46 ++ tests/test_access_control.py | 210 ++++++ tests/test_access_http.py | 148 ++++ tests/test_access_mcp.py | 118 +++ tests/test_api_contract.py | 13 +- tests/test_client.py | 37 + tests/test_server.py | 20 + 33 files changed, 4837 insertions(+), 42 deletions(-) create mode 100644 src/powercontext/server/authz/__init__.py create mode 100644 src/powercontext/server/authz/composition.py create mode 100644 src/powercontext/server/authz/errors.py create mode 100644 src/powercontext/server/authz/models.py create mode 100644 src/powercontext/server/authz/repository.py create mode 100644 src/powercontext/server/authz/service.py create mode 100644 tests/test_access_control.py create mode 100644 tests/test_access_http.py create mode 100644 tests/test_access_mcp.py diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index 726c18a41..4ed7be439 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -55,6 +55,8 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_MCP_PATH` | `/mcp` | MCP path | | `POWERCONTEXT_SERVER_AUTH_ENABLED` | `false` | Require one static bearer token for HTTP and MCP | | `POWERCONTEXT_SERVER_AUTH_TOKEN` | unset | Static bearer token; required when authentication is enabled | +| `POWERCONTEXT_SERVER_ACCESS_MODE` | `legacy-static-admin` | Authorization rollout: `disabled`, `legacy-static-admin`, or `enforced` | +| `POWERCONTEXT_SERVER_ACCESS_BOOTSTRAP_STATIC_PRINCIPAL` | `true` | Treat the deployment-local static-token Principal as a bootstrap Server administrator | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | Opt in to a non-loopback bind while authentication is disabled | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | Enable the Dashboard at the Server root path `/` | | `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | JSON array of selectable Dashboard scopes | @@ -93,6 +95,20 @@ when TLS is terminated upstream or the network is otherwise controlled, set `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` to opt in explicitly. Use TLS before exposing an authenticated Server over a network. +Authentication establishes a Principal; Access Control decides what that Principal may do. The built-in static token +always represents one deployment-local service Principal, so it cannot distinguish user A from user B. The default +`legacy-static-admin` mode maps that Principal to a bootstrap Server administrator and preserves the single-user local +deployment. `enforced` enables the same policy enforcement point and persistent Binding/audit store for an injected +multi-user authentication and Authorization Provider. Set `bootstrap_static_principal=false` after another +administrator relationship is available. `disabled` bypasses authorization decisions and is intended only for an +explicit compatibility rollback inside an already trusted network boundary. + +The built-in Access schema uses the configured SQLite, seekDB, or OceanBase backend, but remains Server-owned rather +than becoming a Runtime domain. A custom deployment can inject an `AccessControlService` into `create_server_app` and +implement the `AuthorizationProvider` and `RelationshipWriter` protocols with OpenFGA, Casbin, Oso, or another policy +system. Its authentication middleware must bind an opaque `PrincipalRef`; `scope_id` is only a resource partition and +never establishes identity. + The Python Client and CLI apply the matching rule for outbound requests: a configured unencrypted `http://` Server URL is accepted only for loopback hosts. The Client refuses to send any request, authenticated or not, over unencrypted non-loopback HTTP. Code whose `http://` base URL is only a routing label for a transport that is secure in diff --git a/docs/en/docs/reference/http-api.md b/docs/en/docs/reference/http-api.md index 3d723dfc1..ebb8dd6b7 100644 --- a/docs/en/docs/reference/http-api.md +++ b/docs/en/docs/reference/http-api.md @@ -90,11 +90,48 @@ curl --fail \ "$POWERCONTEXT_URL/v1/memory/search" ``` +## Grant one exact Handoff to a receiver + +`scope_id` never grants access by itself. An administrator delegates one exact committed Handoff by creating a +Binding for the receiver's authenticated Principal: + +```bash +curl --fail \ + --request POST \ + --header 'Content-Type: application/json' \ + --header "$POWERCONTEXT_AUTH_HEADER" \ + --data '{ + "subject": {"type": "user", "issuer": "https://id.example", "id": "user-b"}, + "resource": { + "type": "handoff", + "scope_id": "project:example", + "family": "handoff", + "artifact_id": "handoff-42", + "revision": 3 + }, + "role": "handoff.receiver", + "idempotency_key": "handoff-42-r3-to-user-b" + }' \ + "$POWERCONTEXT_URL/v1/access/bindings/create" +``` + +The receiver can read evidence and acknowledge only that Revision. It cannot use latest-Handoff discovery, read +another Handoff, or access Memory in the parent scope unless a separate scope role allows it. Use `/v1/access/me` to +verify which Principal the deployment established, `/v1/access/check` for one decision, and +`/v1/access/resources/list` for a non-discovering list of already visible resources. Creation is idempotent per +grantor and key; revocation uses `binding_id` plus `expected_version`. Relationship and decision events are available +to Server administrators through `/v1/access/audit/list`. + +The built-in static token represents one local administrator and cannot model different A/B users. A real multi-user +deployment must authenticate each caller to a different Principal and inject an Authorization Provider. HTTP and MCP +use the same policy enforcement point; MCP tool visibility is not permission. + ## Find an operation | Area | Main paths | Purpose | | --- | --- | --- | | Health and capabilities | `/health/*`, `/v1/capabilities` | Probe the deployment and discover enabled runtime behavior | +| Access Control | `/v1/access/*` | Inspect identity, check decisions, and administer roles, Bindings, and audit events | | Source and context | `/v1/sources/content`, `/v1/context/prepare` | Capture evidence and prepare bounded context | | Work continuity | `/v1/work/*` | Create work contracts, prepare or acknowledge Handoffs, and record outcomes | | Low-level Handoff | `/v1/handoff/*` | Activate, prepare, finalize, commit, or continue a Handoff | @@ -127,6 +164,7 @@ Common statuses are: | Status | Meaning | | --- | --- | | `401` | The Server requires a valid bearer token | +| `403` | The authenticated Principal is not authorized for the requested action and resource | | `404` | The requested immutable value does not exist | | `409` | The request conflicts with current immutable state or an expected version | | `413` | A selected Handoff Report exceeds its output limit | diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index 0cd6b416e..76d78bf60 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -52,6 +52,8 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_MCP_PATH` | `/mcp` | MCP 路径 | | `POWERCONTEXT_SERVER_AUTH_ENABLED` | `false` | HTTP 和 MCP 是否要求一个静态 Bearer token | | `POWERCONTEXT_SERVER_AUTH_TOKEN` | 未设置 | 静态 Bearer token;启用鉴权时必须设置 | +| `POWERCONTEXT_SERVER_ACCESS_MODE` | `legacy-static-admin` | 权限启用模式:`disabled`、`legacy-static-admin` 或 `enforced` | +| `POWERCONTEXT_SERVER_ACCESS_BOOTSTRAP_STATIC_PRINCIPAL` | `true` | 是否把部署本地静态 token 的 Principal 作为初始 Server 管理员 | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | 在鉴权关闭时显式允许绑定非 loopback 地址 | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | 在 Server 根路径 `/` 启用 Dashboard | | `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | Dashboard 可选择的 scope JSON 数组 | @@ -89,6 +91,17 @@ TLS 由上游终止或网络本身受控的场景下, 显式设置 `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` 主动选择接受。通过网络暴露启用鉴权的 Server 前必须配置 TLS。 +Authentication 负责建立 Principal,Access Control 负责判断该 Principal 能做什么。内置静态 token 始终只代表一个 +部署本地 service Principal,因此不能区分用户 A 和用户 B。默认 `legacy-static-admin` 会把该 Principal 映射为初始 +Server 管理员,以保持单用户本地部署的兼容行为。`enforced` 使用同一个策略执行点和持久化 Binding/审计存储,供注入的 +多用户 authentication 与 Authorization Provider 使用。在已有其他管理员关系后,可设置 +`bootstrap_static_principal=false`。`disabled` 会跳过授权决策,只应作为可信网络边界内的显式兼容回退。 + +内置 Access schema 使用配置好的 SQLite、seekDB 或 OceanBase,但由 Server 独立持有,不进入 Runtime 领域。自定义部署 +可以向 `create_server_app` 注入 `AccessControlService`,并用 OpenFGA、Casbin、Oso 或其他策略系统实现 +`AuthorizationProvider` 与 `RelationshipWriter` protocol。authentication middleware 必须绑定不透明的 +`PrincipalRef`;`scope_id` 只用于资源分区,不能建立身份。 + Python Client 和 CLI 对出站请求应用相同规则:配置的明文 `http://` Server URL 仅接受 loopback 主机,并且 Client 拒绝 通过明文的非 loopback HTTP 发送任何请求,无论是否携带 Bearer token。当代码的 `http://` base URL 只是路由标签、 实际传输是安全的,例如进程内 ASGI 应用、Unix domain socket 或由代理终止 TLS 时,必须自行传入 `http_client` 并 diff --git a/docs/zh/docs/reference/http-api.md b/docs/zh/docs/reference/http-api.md index c289ca691..ac37ba574 100644 --- a/docs/zh/docs/reference/http-api.md +++ b/docs/zh/docs/reference/http-api.md @@ -84,11 +84,45 @@ curl --fail \ "$POWERCONTEXT_URL/v1/memory/search" ``` +## 把一个精确 Handoff 授予接收者 + +`scope_id` 本身从不授予权限。管理员通过创建 Binding,把一个精确的 committed Handoff 授予接收者已经认证的 +Principal: + +```bash +curl --fail \ + --request POST \ + --header 'Content-Type: application/json' \ + --header "$POWERCONTEXT_AUTH_HEADER" \ + --data '{ + "subject": {"type": "user", "issuer": "https://id.example", "id": "user-b"}, + "resource": { + "type": "handoff", + "scope_id": "project:example", + "family": "handoff", + "artifact_id": "handoff-42", + "revision": 3 + }, + "role": "handoff.receiver", + "idempotency_key": "handoff-42-r3-to-user-b" + }' \ + "$POWERCONTEXT_URL/v1/access/bindings/create" +``` + +接收者只能读取证据并确认这个 Revision;除非另有 scope role,否则不能发现 latest Handoff、读取其他 Handoff, +也不能访问父 scope 的 Memory。用 `/v1/access/me` 确认部署建立的 Principal,用 `/v1/access/check` 检查一个决策, +用 `/v1/access/resources/list` 非发现式地列出已经可见的资源。创建操作按授权者与幂等键保证幂等;撤销时必须提交 +`binding_id` 和 `expected_version`。Server 管理员可通过 `/v1/access/audit/list` 查看关系变更与决策事件。 + +内置静态 token 只代表一个本地管理员,无法表达不同的 A/B 用户。真正的多用户部署必须把每个调用者认证为不同的 +Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策略执行点;MCP tool 可见不等于有权限。 + ## 查找操作 | 领域 | 主要路径 | 用途 | | --- | --- | --- | | 健康与能力 | `/health/*`、`/v1/capabilities` | 探测部署状态并查看已启用的 Runtime 行为 | +| Access Control | `/v1/access/*` | 查看身份、检查决策,并管理 role、Binding 和审计事件 | | Source 与 Context | `/v1/sources/content`、`/v1/context/prepare` | 采集证据并准备有界 Context | | 工作连续性 | `/v1/work/*` | 创建 Work Contract、准备或确认 Handoff、记录 Outcome | | 底层 Handoff | `/v1/handoff/*` | activate、prepare、finalize、commit 或 continue Handoff | @@ -120,6 +154,7 @@ curl --fail \ | 状态码 | 含义 | | --- | --- | | `401` | Server 要求有效的 Bearer token | +| `403` | 已认证 Principal 无权对目标资源执行请求的 action | | `404` | 请求的不可变值不存在 | | `409` | 请求与当前不可变状态或 expected version 冲突 | | `413` | 选中的 Handoff Report 超过输出限制 | diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 092c7108a..49d191ee1 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -70,6 +70,15 @@ export const OPERATIONS = { get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scope: false }, + check_access: { method: 'POST', path: '/v1/access/check', location: "body", scope: false }, + check_access_batch: { method: 'POST', path: '/v1/access/check-batch', location: "body", scope: false }, + list_access_resources: { method: 'POST', path: '/v1/access/resources/list', location: "body", scope: false }, + list_access_roles: { method: 'POST', path: '/v1/access/roles/list', location: "body", scope: false }, + list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, + create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, + revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 092c7108a..49d191ee1 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -70,6 +70,15 @@ export const OPERATIONS = { get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scope: false }, + check_access: { method: 'POST', path: '/v1/access/check', location: "body", scope: false }, + check_access_batch: { method: 'POST', path: '/v1/access/check-batch', location: "body", scope: false }, + list_access_resources: { method: 'POST', path: '/v1/access/resources/list', location: "body", scope: false }, + list_access_roles: { method: 'POST', path: '/v1/access/roles/list', location: "body", scope: false }, + list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, + create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, + revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 092c7108a..49d191ee1 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -70,6 +70,15 @@ export const OPERATIONS = { get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scope: false }, + check_access: { method: 'POST', path: '/v1/access/check', location: "body", scope: false }, + check_access_batch: { method: 'POST', path: '/v1/access/check-batch', location: "body", scope: false }, + list_access_resources: { method: 'POST', path: '/v1/access/resources/list', location: "body", scope: false }, + list_access_roles: { method: 'POST', path: '/v1/access/roles/list', location: "body", scope: false }, + list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, + create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, + revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 2c8681f99..d2e232300 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -67,6 +67,7 @@ paths: tags: [capabilities] summary: Get runtime capabilities operationId: get_capabilities + x-powercontext-access: {action: server.observe, resource: server} responses: "200": description: Behavior enabled by the assembled runtime. @@ -79,12 +80,15 @@ paths: $ref: "#/components/schemas/Capabilities" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /v1/sources/content: post: tags: [sources] summary: Capture durable ContentSource evidence description: Accept raw content as an idempotent Source without synchronously deriving Artifacts. operationId: capture_content_source + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -105,6 +109,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -117,6 +123,7 @@ paths: summary: Prepare bounded context for an Agent turn description: Prepare final, ephemeral context from Runtime-owned sources without persisting or injecting it. operationId: prepare_context + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -135,6 +142,8 @@ paths: $ref: "#/components/schemas/PreparedContext" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -147,6 +156,7 @@ paths: summary: Create a grounded Work Contract description: Persist an inspectable delegation baseline without granting execution authority. operationId: create_work_contract + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -169,6 +179,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -181,6 +193,7 @@ paths: summary: Hand off current work in one high-level operation description: Capture an inspected boundary and prepare a temporary evidence-bearing Handoff without committing it. operationId: handoff_current_work + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -203,6 +216,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -215,6 +230,10 @@ paths: summary: Resolve and acknowledge a Handoff description: Re-resolve one prepared or exact Handoff, check evidence, and capture the receiver's explicit live-state, capability, and authorization checks. operationId: acknowledge_handoff + x-powercontext-access: + action: scope.contribute + resource: scope + resolver: acknowledge_handoff requestBody: required: true content: @@ -237,6 +256,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -249,6 +270,7 @@ paths: summary: Record a completion-aware Task Outcome description: Preserve one attempt's status and checks, optionally linked to the exact accepted Handoff Receipt that the result covers. operationId: record_task_outcome + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -271,6 +293,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -283,6 +307,7 @@ paths: summary: Activate Handoff generation at a Source boundary description: Evaluate the standard Handoff Trigger and synchronously execute any emitted PrepareHandoff Action. operationId: activate_handoff + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -303,6 +328,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -314,6 +341,7 @@ paths: tags: [handoff] summary: Generate an inspectable Handoff Draft operationId: prepare_handoff + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -334,6 +362,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -345,6 +375,7 @@ paths: tags: [handoff] summary: Finalize an inspected Handoff Draft operationId: finalize_handoff + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -365,6 +396,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -376,6 +409,7 @@ paths: tags: [handoff] summary: Commit an explicit Handoff milestone operationId: commit_handoff + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -398,6 +432,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -409,6 +445,10 @@ paths: tags: [handoff] summary: Resolve a Handoff as untrusted historical input operationId: continue_handoff + x-powercontext-access: + action: scope.read + resource: scope + resolver: continue_handoff requestBody: required: true content: @@ -429,6 +469,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -441,6 +483,7 @@ paths: summary: Process the pending Source window into Memory description: Run one bounded Source-to-Memory activation for operational control and testing. operationId: flush_memory + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -459,6 +502,8 @@ paths: $ref: "#/components/schemas/FlushMemoryResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -471,6 +516,7 @@ paths: summary: Remember explicit Memory content description: Save one already-curated Memory entry without creating a Source or invoking extraction. operationId: remember_memory + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -491,6 +537,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -503,6 +551,7 @@ paths: summary: Search active Memory entries description: Retrieve relevant active Memory entries within one explicit application scope. operationId: search_memory + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -523,6 +572,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -537,6 +588,7 @@ paths: Read active entries from the current Memory head. Inactive entries are available only when explicitly requested for audit. operationId: list_memory_entries + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -557,6 +609,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -569,6 +623,7 @@ paths: summary: Get an exact Memory entry version description: Resolve an immutable entry citation within one Memory Revision. operationId: get_memory_entry + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -589,6 +644,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -601,6 +658,7 @@ paths: summary: Revise an exact Memory entry description: Replace active entry content against an explicit current Memory Revision. operationId: revise_memory_entry + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -623,6 +681,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -635,6 +695,7 @@ paths: summary: Retire an exact Memory entry description: Deactivate an entry against an explicit current Memory Revision without deleting history. operationId: retire_memory_entry + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -657,6 +718,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -669,6 +732,7 @@ paths: summary: List Memory Revision changes description: Read compact entry changes without expanding entry bodies. operationId: list_memory_changes + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -689,6 +753,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -701,6 +767,7 @@ paths: summary: Propose Experience content description: Persist a pending Experience Candidate without creating an Artifact Revision. operationId: propose_experience + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -721,6 +788,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -733,6 +802,7 @@ paths: summary: Generate an Experience Candidate description: Use the configured model and caller-selected exact evidence; persist only a schema-valid pending Candidate. operationId: generate_experience + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -753,6 +823,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -765,6 +837,7 @@ paths: summary: Get an exact Experience Revision description: Read approved Experience content and its exact direct evidence. operationId: get_experience + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -785,6 +858,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -797,6 +872,7 @@ paths: summary: Propose managed Skill content description: Persist a pending managed Skill Candidate without creating an Artifact Revision. operationId: propose_skill + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -817,6 +893,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -829,6 +907,7 @@ paths: summary: Generate a managed Skill Candidate description: Use the configured model with an explicit provenance shape; persist only a schema-valid pending Candidate. operationId: generate_skill + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -849,6 +928,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -861,6 +942,7 @@ paths: summary: Get an exact managed Skill Revision description: Read approved managed Skill content and its exact direct evidence. operationId: get_skill + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -881,6 +963,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -893,6 +977,7 @@ paths: summary: Scan configured external Skill roots description: Replace the current host-local Registry projection without copying or rewriting package content. operationId: scan_external_skills + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -911,6 +996,8 @@ paths: $ref: "#/components/schemas/ScanExternalSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -923,6 +1010,7 @@ paths: summary: List external Skills visible on this host description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. operationId: list_external_skills + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -941,6 +1029,8 @@ paths: $ref: "#/components/schemas/ListExternalSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -953,6 +1043,7 @@ paths: summary: Resolve an exact external Skill fingerprint description: Resolve only the registered local package version requested by the caller; never install or fall back. operationId: resolve_external_skill + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -973,6 +1064,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -985,6 +1078,7 @@ paths: summary: Import or fork an external Skill into Review description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. operationId: import_external_skill + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1007,6 +1101,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1019,6 +1115,7 @@ paths: summary: List Artifact Candidates description: Page current Candidate heads; pending is the default Review Inbox view. operationId: list_artifact_candidates + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1037,6 +1134,8 @@ paths: $ref: "#/components/schemas/ArtifactCandidatePage" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1049,6 +1148,7 @@ paths: summary: Get an Artifact Candidate description: Read the current head and exact immutable proposal version. operationId: get_artifact_candidate + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1069,6 +1169,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1081,6 +1183,7 @@ paths: summary: Approve an Artifact Candidate description: Commit the reviewed proposal and mark the Candidate approved in one transaction. operationId: approve_artifact_candidate + x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1103,6 +1206,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1115,6 +1220,7 @@ paths: summary: Reject an Artifact Candidate description: Move the exact pending version to its rejected terminal state without writing an Artifact. operationId: reject_artifact_candidate + x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1137,6 +1243,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1149,6 +1257,7 @@ paths: summary: Revise an Artifact Candidate description: Append a complete replacement proposal as the next immutable pending version. operationId: revise_artifact_candidate + x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1171,6 +1280,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1182,6 +1293,7 @@ paths: tags: [stats] summary: Get scoped product statistics operationId: get_stats + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} parameters: - name: scope_id in: query @@ -1213,6 +1325,8 @@ paths: $ref: "#/components/schemas/ScopedStats" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1224,6 +1338,7 @@ paths: tags: [handoff-reports] summary: Create a Handoff Report Project operationId: create_handoff_report_project + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1244,6 +1359,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1253,6 +1370,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Projects operationId: list_handoff_report_projects + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1271,6 +1389,8 @@ paths: $ref: "#/components/schemas/ProjectPage" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1280,6 +1400,7 @@ paths: tags: [handoff-reports] summary: List scopes that contain a committed Handoff operationId: list_handoff_report_known_scopes + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1298,6 +1419,8 @@ paths: $ref: "#/components/schemas/KnownHandoffScopePage" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1307,6 +1430,7 @@ paths: tags: [handoff-reports] summary: Get a Handoff Report Project operationId: get_handoff_report_project + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1327,6 +1451,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1336,6 +1462,7 @@ paths: tags: [handoff-reports] summary: Update a Handoff Report Project operationId: update_handoff_report_project + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1358,6 +1485,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1367,6 +1496,7 @@ paths: tags: [handoff-reports] summary: Register a Handoff Report Workstream operationId: register_handoff_report_workstream + x-powercontext-access: {action: scope.admin, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1389,6 +1519,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1398,6 +1530,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Workstreams operationId: list_handoff_report_workstreams + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1418,6 +1551,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1427,6 +1562,7 @@ paths: tags: [handoff-reports] summary: Update a Handoff Report Workstream operationId: update_handoff_report_workstream + x-powercontext-access: {action: scope.admin, resource: scope, scope_id_field: workstream.scope_id} requestBody: required: true content: @@ -1449,6 +1585,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1458,6 +1596,7 @@ paths: tags: [handoff-reports] summary: Generate a Handoff Report operationId: get_handoff_report + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1500,6 +1639,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "413": @@ -1513,6 +1654,7 @@ paths: tags: [handoff-reports] summary: Record a Handoff Report Activity operationId: record_handoff_report_activity + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1535,6 +1677,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1544,6 +1688,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Activities operationId: list_handoff_report_activities + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1564,6 +1709,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1573,6 +1720,7 @@ paths: tags: [handoff-reports] summary: Purge Handoff Report Activities operationId: purge_handoff_report_activities + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1593,6 +1741,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1602,6 +1752,7 @@ paths: tags: [handoff-reports] summary: Get a Handoff Report Workspace Binding operationId: get_handoff_report_workspace + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1622,6 +1773,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1631,6 +1784,7 @@ paths: tags: [handoff-reports] summary: Attach a Handoff Report Workspace Binding operationId: attach_handoff_report_workspace + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1653,6 +1807,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1662,6 +1818,7 @@ paths: tags: [handoff-reports] summary: Detach a Handoff Report Workspace Binding operationId: detach_handoff_report_workspace + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1684,16 +1841,255 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" + /v1/access/me: + get: + tags: [access] + summary: Get the authenticated Principal + operationId: get_access_principal + x-powercontext-access: {action: access.self, resource: server} + responses: + "200": + description: The opaque Principal established by the authentication adapter. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessPrincipal" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/check: + post: + tags: [access] + summary: Check one authorization decision + operationId: check_access + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccessCheckRequest" + responses: + "200": + description: A low-sensitivity allow or deny decision. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessDecision" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/check-batch: + post: + tags: [access] + summary: Check a bounded batch of authorization decisions + operationId: check_access_batch + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccessCheckBatchRequest" + responses: + "200": + description: Ordered low-sensitivity decisions matching the submitted checks. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessCheckBatchResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/resources/list: + post: + tags: [access] + summary: List only resources already visible to the Principal + operationId: list_access_resources + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListAccessResourcesRequest" + responses: + "200": + description: A non-discovering page derived from authorized relationships. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessResourcePage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/roles/list: + post: + tags: [access] + summary: List stable built-in role definitions + operationId: list_access_roles + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListAccessRolesRequest" + responses: + "200": + description: Stable role names and the resource type accepted by each role. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessRolePage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/access/bindings/list: + post: + tags: [access] + summary: List Access Bindings under an administrative boundary + operationId: list_access_bindings + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListAccessBindingsRequest" + responses: + "200": + description: Matching immutable Access Bindings. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessBindingPage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/bindings/create: + post: + tags: [access] + summary: Create an idempotent Access Binding + operationId: create_access_binding + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateAccessBindingRequest" + responses: + "201": + description: The Access Binding was created or an identical idempotent result was returned. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessBinding" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/bindings/revoke: + post: + tags: [access] + summary: Revoke an Access Binding using compare-and-swap + operationId: revoke_access_binding + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RevokeAccessBindingRequest" + responses: + "200": + description: The revoked Access Binding with its incremented version. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessBinding" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/audit/list: + post: + tags: [access] + summary: List data-minimized Access audit events + operationId: list_access_audit + x-powercontext-access: {action: server.admin, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListAccessAuditRequest" + responses: + "200": + description: Ordered authorization and relationship audit events. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessAuditPage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" components: securitySchemes: BearerAuth: type: http scheme: bearer - description: Static bearer token used when local Server authentication is enabled. + description: Bearer credential resolved to an opaque authenticated Principal by the Server deployment. headers: BearerChallenge: description: Authentication scheme required by the Server. @@ -1706,7 +2102,7 @@ components: type: string responses: Unauthorized: - description: A valid bearer token is required by this Server deployment. + description: The Server could not establish an authenticated Principal. headers: WWW-Authenticate: $ref: "#/components/headers/BearerChallenge" @@ -1716,6 +2112,15 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: The authenticated Principal is not authorized for the requested action and resource. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" Conflict: description: The command conflicts with current immutable state. headers: @@ -1771,6 +2176,294 @@ components: schema: $ref: "#/components/schemas/ErrorResponse" schemas: + AccessPrincipal: + type: object + additionalProperties: false + required: [type, issuer, id] + properties: + type: {type: string, minLength: 1, maxLength: 64} + issuer: {type: string, minLength: 1, maxLength: 255} + id: {type: string, minLength: 1, maxLength: 255} + AccessAction: + type: string + enum: + - access.self + - server.observe + - server.admin + - scope.read + - scope.contribute + - scope.review + - scope.delegate + - scope.admin + - handoff.read + - handoff.evidence.read + - handoff.acknowledge + AccessResourceType: + type: string + enum: [server, scope, handoff] + AccessResource: + type: object + additionalProperties: false + required: [type] + properties: + type: + $ref: "#/components/schemas/AccessResourceType" + scope_id: {type: string, minLength: 1, maxLength: 256, nullable: true} + family: {type: string, minLength: 1, maxLength: 64, nullable: true} + artifact_id: {type: string, minLength: 1, maxLength: 256, nullable: true} + revision: {type: integer, minimum: 1, nullable: true} + AccessDecision: + type: object + additionalProperties: false + required: [allowed, reason_code, policy_revision] + properties: + allowed: {type: boolean} + reason_code: {type: string, minLength: 1, maxLength: 64} + policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} + AccessCheckRequest: + type: object + additionalProperties: false + required: [action, resource] + properties: + action: + $ref: "#/components/schemas/AccessAction" + resource: + $ref: "#/components/schemas/AccessResource" + AccessCheckBatchRequest: + type: object + additionalProperties: false + required: [checks] + properties: + checks: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/AccessCheckRequest" + AccessCheckBatchResponse: + type: object + additionalProperties: false + required: [decisions] + properties: + decisions: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/AccessDecision" + ListAccessResourcesRequest: + type: object + additionalProperties: false + required: [action, resource_type] + properties: + action: + $ref: "#/components/schemas/AccessAction" + resource_type: + $ref: "#/components/schemas/AccessResourceType" + cursor: {type: string, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessResourcePage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessResource" + next_cursor: {type: string, nullable: true} + AccessRole: + type: string + enum: + - handoff.viewer + - handoff.receiver + - scope.viewer + - scope.contributor + - scope.reviewer + - scope.delegator + - scope.admin + - server.observer + - server.admin + ListAccessRolesRequest: + type: object + additionalProperties: false + properties: + resource_type: + allOf: + - $ref: "#/components/schemas/AccessResourceType" + nullable: true + AccessRoleDescriptor: + type: object + additionalProperties: false + required: [role, resource_type, actions] + properties: + role: + $ref: "#/components/schemas/AccessRole" + resource_type: + $ref: "#/components/schemas/AccessResourceType" + actions: + type: array + items: + $ref: "#/components/schemas/AccessAction" + AccessRolePage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + maxItems: 16 + items: + $ref: "#/components/schemas/AccessRoleDescriptor" + AccessBindingState: + type: string + enum: [active, revoked] + AccessBinding: + type: object + additionalProperties: false + required: + - binding_id + - subject + - resource + - role + - granted_by + - reason + - created_at + - expires_at + - state + - version + - policy_revision + - idempotency_key + - revoked_at + - revoked_by + properties: + binding_id: {type: string, minLength: 1, maxLength: 64} + subject: + $ref: "#/components/schemas/AccessPrincipal" + resource: + $ref: "#/components/schemas/AccessResource" + role: + $ref: "#/components/schemas/AccessRole" + granted_by: + $ref: "#/components/schemas/AccessPrincipal" + reason: {type: string, maxLength: 1024, nullable: true} + created_at: {type: string, format: date-time} + expires_at: {type: string, format: date-time, nullable: true} + state: + $ref: "#/components/schemas/AccessBindingState" + version: {type: integer, minimum: 1} + policy_revision: {type: string, minLength: 1, maxLength: 64} + idempotency_key: {type: string, minLength: 1, maxLength: 255} + revoked_at: {type: string, format: date-time, nullable: true} + revoked_by: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + ListAccessBindingsRequest: + type: object + additionalProperties: false + properties: + subject: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + resource: + allOf: + - $ref: "#/components/schemas/AccessResource" + nullable: true + include_revoked: {type: boolean, default: false} + AccessBindingPage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessBinding" + CreateAccessBindingRequest: + type: object + additionalProperties: false + required: [subject, resource, role, idempotency_key] + properties: + subject: + $ref: "#/components/schemas/AccessPrincipal" + resource: + $ref: "#/components/schemas/AccessResource" + role: + $ref: "#/components/schemas/AccessRole" + idempotency_key: {type: string, minLength: 1, maxLength: 255} + reason: {type: string, maxLength: 1024, nullable: true} + expires_at: {type: string, format: date-time, nullable: true} + RevokeAccessBindingRequest: + type: object + additionalProperties: false + required: [binding_id, expected_version] + properties: + binding_id: {type: string, minLength: 1, maxLength: 64} + expected_version: {type: integer, minimum: 1} + ListAccessAuditRequest: + type: object + additionalProperties: false + properties: + after: {type: integer, minimum: 0, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessAuditEvent: + type: object + additionalProperties: false + required: + - cursor + - event_id + - occurred_at + - request_id + - transport + - operation + - principal + - action + - resource + - allowed + - reason_code + - policy_revision + - binding_id + - target + - role + properties: + cursor: {type: integer, minimum: 1} + event_id: {type: string, minLength: 1, maxLength: 64} + occurred_at: {type: string, format: date-time} + request_id: {type: string, maxLength: 128, nullable: true} + transport: {type: string, minLength: 1, maxLength: 16} + operation: {type: string, minLength: 1, maxLength: 128} + principal: + $ref: "#/components/schemas/AccessPrincipal" + action: + $ref: "#/components/schemas/AccessAction" + resource: + $ref: "#/components/schemas/AccessResource" + allowed: {type: boolean} + reason_code: {type: string, minLength: 1, maxLength: 64} + policy_revision: {type: string, maxLength: 64, nullable: true} + binding_id: {type: string, maxLength: 64, nullable: true} + target: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + role: + allOf: + - $ref: "#/components/schemas/AccessRole" + nullable: true + AccessAuditPage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessAuditEvent" + next_cursor: {type: integer, minimum: 1, nullable: true} ActivateHandoffRequest: type: object additionalProperties: false diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 364eb1617..0c5f05df4 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -19,7 +19,7 @@ import argparse from pathlib import Path from pprint import pformat -from typing import Literal +from typing import Literal, TypedDict import yaml from datamodel_code_generator import GenerateConfig, InputFileType, generate @@ -58,6 +58,13 @@ def __init__(self, subject: str, value: object) -> None: super().__init__(f"cannot generate PowerContext API: invalid {subject}: {value!r}") +class _AccessRequirement(TypedDict): + action: str + resource: Literal["server", "scope", "handoff"] + scope_id_field: str | None + resolver: Literal["static", "request", "continue_handoff", "acknowledge_handoff"] + + def generate_sources() -> dict[Path, str]: """Build every generated source without modifying the worktree.""" @@ -145,6 +152,7 @@ def _generate_operations( if operation.operationId is None or operation.summary is None: raise ContractGenerationError("operation metadata", path) # noqa: TRY003 operation_id = operation.operationId + access = _access_requirement(operation, operation_id) request_model = _request_model(operation, schemas) if request_model is not None: imports.add(request_model[:2]) @@ -168,6 +176,7 @@ def _generate_operations( int(code) if code.isdecimal() else code: _response_metadata(response) for code, response in operation.responses.items() }, + access=access, ) ) @@ -203,6 +212,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): summary: str tags: tuple[str, ...] responses: dict[int | str, dict[str, JsonValue]] + access: AccessRequirement | None + + +class AccessRequirement(BaseModel): + action: str + resource: Literal["server", "scope", "handoff"] + scope_id_field: str | None + resolver: Literal["static", "request", "continue_handoff", "acknowledge_handoff"] {rendered_operations} @@ -353,6 +370,34 @@ def _response_metadata(response: Response | object) -> dict[str, JsonValue]: ) +def _access_requirement(operation: OpenAPIOperation, operation_id: str) -> _AccessRequirement | None: + value = (operation.model_extra or {}).get("x-powercontext-access") + if value is None: + return None + if not isinstance(value, dict): + raise ContractGenerationError(f"{operation_id} x-powercontext-access", value) # noqa: TRY003 + action = value.get("action") + resource = value.get("resource") + scope_id_field = value.get("scope_id_field") + resolver = value.get("resolver", "static" if resource == "server" else "request") + if not isinstance(action, str) or not action: + raise ContractGenerationError(f"{operation_id} access action", action) # noqa: TRY003 + if resource not in {"server", "scope", "handoff"}: + raise ContractGenerationError(f"{operation_id} access resource", resource) # noqa: TRY003 + if scope_id_field is not None and not isinstance(scope_id_field, str): + raise ContractGenerationError(f"{operation_id} access scope_id_field", scope_id_field) # noqa: TRY003 + if resolver not in {"static", "request", "continue_handoff", "acknowledge_handoff"}: + raise ContractGenerationError(f"{operation_id} access resolver", resolver) # noqa: TRY003 + if resource != "server" and resolver == "request" and not scope_id_field: + raise ContractGenerationError(f"{operation_id} access scope_id_field", scope_id_field) # noqa: TRY003 + return { + "action": action, + "resource": resource, + "scope_id_field": scope_id_field, + "resolver": resolver, + } + + def _render_operation( *, constant_name: str, @@ -366,8 +411,18 @@ def _render_operation( summary: str, tags: tuple[str, ...], responses: dict[int | str, dict[str, JsonValue]], + access: _AccessRequirement | None, ) -> str: request_type = "None" if request_model is None else request_model + rendered_access = ( + "None" + if access is None + else "AccessRequirement(" + f"action={access['action']!r}, " + f"resource={access['resource']!r}, " + f"scope_id_field={access['scope_id_field']!r}, " + f"resolver={access['resolver']!r})" + ) return f"""{constant_name} = Operation[{request_type}, {response_model}]( method={method!r}, path={path!r}, @@ -379,6 +434,7 @@ def _render_operation( summary={summary!r}, tags={tags!r}, responses={pformat(responses, width=100, sort_dicts=False)}, + access={rendered_access}, )""" diff --git a/src/powercontext/builtin/persistence/cursors.py b/src/powercontext/builtin/persistence/cursors.py index 9f9eac7e7..e3524a976 100644 --- a/src/powercontext/builtin/persistence/cursors.py +++ b/src/powercontext/builtin/persistence/cursors.py @@ -86,20 +86,31 @@ async def save( if existing is not None: raise GenerationConflictError(binding_name, None, existing.generation) generation = 1 + statement = insert(SOURCE_CURSORS_TABLE).values( + scope_id=scope_id, + binding_name=binding_name, + cursor=payload, + generation=generation, + ) try: - async with connection.begin_nested(): - await connection.execute( - insert(SOURCE_CURSORS_TABLE).values( - scope_id=scope_id, - binding_name=binding_name, - cursor=payload, - generation=generation, - ) + if connection.dialect.name == "sqlite": + async with connection.begin_nested(): + await connection.execute(statement) + elif connection.dialect.name == "mysql": + await connection.execute(statement) + else: + raise InvalidRepositoryArgumentError( + "dialect", + f"unsupported database dialect: {connection.dialect.name}", ) except IntegrityError: # Another runtime may have inserted the same cursor after our # initial read. Normalize that database race to the same CAS - # conflict used for concurrent updates. + # conflict used for concurrent updates. SQLite needs the nested + # transaction to release its read lock before the competing writer + # commits. The supported MySQL-compatible profiles keep the outer + # transaction usable after a uniqueness error, while OceanBase does + # not consistently preserve SAVEPOINTs for this write path. existing = await self.load( connection, scope_id, diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 2c1618f2f..df1f235bb 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -26,6 +26,16 @@ from powercontext.client.errors import InvalidResponseError, ServerResponseError, TransportError from powercontext.client.tracing import ClientSpan from powercontext.http import ( + AccessAuditPage, + AccessBinding, + AccessBindingPage, + AccessCheckBatchRequest, + AccessCheckBatchResponse, + AccessCheckRequest, + AccessDecision, + AccessPrincipal, + AccessResourcePage, + AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, @@ -38,6 +48,7 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateAccessBindingRequest, CreateHandoffReportProjectRequest, CreateWorkContractRequest, DetachHandoffReportWorkspaceRequest, @@ -69,6 +80,10 @@ HealthResponse, ImportExternalSkillRequest, KnownHandoffScopePage, + ListAccessAuditRequest, + ListAccessBindingsRequest, + ListAccessResourcesRequest, + ListAccessRolesRequest, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -103,6 +118,7 @@ RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, + RevokeAccessBindingRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, @@ -122,8 +138,11 @@ APPROVE_ARTIFACT_CANDIDATE, ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, + CHECK_ACCESS, + CHECK_ACCESS_BATCH, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_ACCESS_BINDING, CREATE_HANDOFF_REPORT_PROJECT, CREATE_WORK_CONTRACT, DETACH_HANDOFF_REPORT_WORKSPACE, @@ -131,6 +150,7 @@ FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, + GET_ACCESS_PRINCIPAL, GET_ARTIFACT_CANDIDATE, GET_CAPABILITIES, GET_EXPERIENCE, @@ -144,6 +164,10 @@ GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, + LIST_ACCESS_AUDIT, + LIST_ACCESS_BINDINGS, + LIST_ACCESS_RESOURCES, + LIST_ACCESS_ROLES, LIST_ARTIFACT_CANDIDATES, LIST_EXTERNAL_SKILLS, LIST_HANDOFF_REPORT_ACTIVITIES, @@ -166,6 +190,7 @@ RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, + REVOKE_ACCESS_BINDING, SCAN_EXTERNAL_SKILLS, SEARCH_MEMORY, UPDATE_HANDOFF_REPORT_PROJECT, @@ -423,6 +448,51 @@ async def capture_content_source(self, request: CaptureContentSourceRequest) -> return await self._request(CAPTURE_CONTENT_SOURCE, request) + async def get_access_principal(self) -> AccessPrincipal: + """Return the opaque Principal established by Server authentication.""" + + return await self._request(GET_ACCESS_PRINCIPAL) + + async def check_access(self, request: AccessCheckRequest) -> AccessDecision: + """Evaluate one action and resource for the current Principal.""" + + return await self._request(CHECK_ACCESS, request) + + async def check_access_batch(self, request: AccessCheckBatchRequest) -> AccessCheckBatchResponse: + """Evaluate a bounded ordered batch for the current Principal.""" + + return await self._request(CHECK_ACCESS_BATCH, request) + + async def list_access_resources(self, request: ListAccessResourcesRequest) -> AccessResourcePage: + """List only relationships already visible to the current Principal.""" + + return await self._request(LIST_ACCESS_RESOURCES, request) + + async def list_access_roles(self, request: ListAccessRolesRequest) -> AccessRolePage: + """List stable built-in role definitions.""" + + return await self._request(LIST_ACCESS_ROLES, request) + + async def list_access_bindings(self, request: ListAccessBindingsRequest) -> AccessBindingPage: + """List bindings within an authorized administrative boundary.""" + + return await self._request(LIST_ACCESS_BINDINGS, request) + + async def create_access_binding(self, request: CreateAccessBindingRequest) -> AccessBinding: + """Create or idempotently return one Access Binding.""" + + return await self._request(CREATE_ACCESS_BINDING, request) + + async def revoke_access_binding(self, request: RevokeAccessBindingRequest) -> AccessBinding: + """Revoke one Access Binding using compare-and-swap.""" + + return await self._request(REVOKE_ACCESS_BINDING, request) + + async def list_access_audit(self, request: ListAccessAuditRequest) -> AccessAuditPage: + """List data-minimized authorization and relationship audit events.""" + + return await self._request(LIST_ACCESS_AUDIT, request) + async def create_work_contract(self, request: CreateWorkContractRequest) -> WorkSourceReceipt: """Create one grounded delegation baseline as durable Source evidence.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 56c05bf89..e749b1367 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -15,6 +15,23 @@ """Public HTTP models shared by the Server and Client SDK.""" from powercontext.http._generated.models import ( + AccessAction, + AccessAuditEvent, + AccessAuditPage, + AccessBinding, + AccessBindingPage, + AccessBindingState, + AccessCheckBatchRequest, + AccessCheckBatchResponse, + AccessCheckRequest, + AccessDecision, + AccessPrincipal, + AccessResource, + AccessResourcePage, + AccessResourceType, + AccessRole, + AccessRoleDescriptor, + AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, @@ -34,6 +51,7 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateAccessBindingRequest, CreateHandoffReportProjectRequest, CreateWorkContractRequest, CurrentWorkHandoff, @@ -101,6 +119,10 @@ InventoryStatistics, KnownHandoffScope, KnownHandoffScopePage, + ListAccessAuditRequest, + ListAccessBindingsRequest, + ListAccessResourcesRequest, + ListAccessRolesRequest, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -161,6 +183,7 @@ RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, + RevokeAccessBindingRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, @@ -194,6 +217,23 @@ ) __all__ = [ + "AccessAction", + "AccessAuditEvent", + "AccessAuditPage", + "AccessBinding", + "AccessBindingPage", + "AccessBindingState", + "AccessCheckBatchRequest", + "AccessCheckBatchResponse", + "AccessCheckRequest", + "AccessDecision", + "AccessPrincipal", + "AccessResource", + "AccessResourcePage", + "AccessResourceType", + "AccessRole", + "AccessRoleDescriptor", + "AccessRolePage", "AcknowledgeHandoffRequest", "ActivateHandoffRequest", "ApproveArtifactCandidateRequest", @@ -213,6 +253,7 @@ "CommitHandoffRequest", "CommittedHandoff", "ContinueHandoffRequest", + "CreateAccessBindingRequest", "CreateHandoffReportProjectRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", @@ -280,6 +321,10 @@ "InventoryStatistics", "KnownHandoffScope", "KnownHandoffScopePage", + "ListAccessAuditRequest", + "ListAccessBindingsRequest", + "ListAccessResourcesRequest", + "ListAccessRolesRequest", "ListArtifactCandidatesRequest", "ListExternalSkillsRequest", "ListExternalSkillsResponse", @@ -340,6 +385,7 @@ "RetireMemoryEntryRequest", "ReviseArtifactCandidateRequest", "ReviseMemoryEntryRequest", + "RevokeAccessBindingRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", "ScopedStats", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index ad2a598c1..b22e1f244 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -21,6 +21,228 @@ ) +class AccessPrincipal(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + type: Annotated[StrictStr, Field(max_length=64, min_length=1)] + issuer: Annotated[StrictStr, Field(max_length=255, min_length=1)] + id: Annotated[StrictStr, Field(max_length=255, min_length=1)] + + +class AccessAction(StrEnum): + ACCESS_SELF = "access.self" + SERVER_OBSERVE = "server.observe" + SERVER_ADMIN = "server.admin" + SCOPE_READ = "scope.read" + SCOPE_CONTRIBUTE = "scope.contribute" + SCOPE_REVIEW = "scope.review" + SCOPE_DELEGATE = "scope.delegate" + SCOPE_ADMIN = "scope.admin" + HANDOFF_READ = "handoff.read" + HANDOFF_EVIDENCE_READ = "handoff.evidence.read" + HANDOFF_ACKNOWLEDGE = "handoff.acknowledge" + + +class AccessResourceType(StrEnum): + SERVER = "server" + SCOPE = "scope" + HANDOFF = "handoff" + + +class AccessResource(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + type: AccessResourceType + scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + family: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] = None + artifact_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + revision: Annotated[StrictInt | None, Field(ge=1)] = None + + +class AccessDecision(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + allowed: StrictBool + reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] + policy_revision: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] + + +class AccessCheckRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + action: AccessAction + resource: AccessResource + + +class AccessCheckBatchRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + checks: Annotated[list[AccessCheckRequest], Field(max_length=100, min_length=1)] + + +class AccessCheckBatchResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + decisions: Annotated[list[AccessDecision], Field(max_length=100)] + + +class ListAccessResourcesRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + action: AccessAction + resource_type: AccessResourceType + cursor: StrictStr | None = None + limit: Annotated[StrictInt, Field(ge=1, le=500)] = 100 + + +class AccessResourcePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessResource], Field(max_length=500)] + next_cursor: Annotated[StrictStr | None, Field(...)] + + +class AccessRole(StrEnum): + HANDOFF_VIEWER = "handoff.viewer" + HANDOFF_RECEIVER = "handoff.receiver" + SCOPE_VIEWER = "scope.viewer" + SCOPE_CONTRIBUTOR = "scope.contributor" + SCOPE_REVIEWER = "scope.reviewer" + SCOPE_DELEGATOR = "scope.delegator" + SCOPE_ADMIN = "scope.admin" + SERVER_OBSERVER = "server.observer" + SERVER_ADMIN = "server.admin" + + +class ListAccessRolesRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + resource_type: AccessResourceType | None = None + + +class AccessRoleDescriptor(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + role: AccessRole + resource_type: AccessResourceType + actions: list[AccessAction] + + +class AccessRolePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessRoleDescriptor], Field(max_length=16)] + + +class AccessBindingState(StrEnum): + ACTIVE = "active" + REVOKED = "revoked" + + +class AccessBinding(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + subject: AccessPrincipal + resource: AccessResource + role: AccessRole + granted_by: AccessPrincipal + reason: Annotated[StrictStr | None, Field(max_length=1024)] + created_at: AwareDatetime + expires_at: Annotated[AwareDatetime | None, Field(...)] + state: AccessBindingState + version: Annotated[StrictInt, Field(ge=1)] + policy_revision: Annotated[StrictStr, Field(max_length=64, min_length=1)] + idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] + revoked_at: Annotated[AwareDatetime | None, Field(...)] + revoked_by: Annotated[AccessPrincipal | None, Field(...)] + + +class ListAccessBindingsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + subject: AccessPrincipal | None = None + resource: AccessResource | None = None + include_revoked: StrictBool = False + + +class AccessBindingPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessBinding], Field(max_length=500)] + + +class CreateAccessBindingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + subject: AccessPrincipal + resource: AccessResource + role: AccessRole + idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] + reason: Annotated[StrictStr | None, Field(max_length=1024)] = None + expires_at: AwareDatetime | None = None + + +class RevokeAccessBindingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + expected_version: Annotated[StrictInt, Field(ge=1)] + + +class ListAccessAuditRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + after: Annotated[StrictInt | None, Field(ge=0)] = None + limit: Annotated[StrictInt, Field(ge=1, le=500)] = 100 + + +class AccessAuditEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + cursor: Annotated[StrictInt, Field(ge=1)] + event_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + occurred_at: AwareDatetime + request_id: Annotated[StrictStr | None, Field(max_length=128)] + transport: Annotated[StrictStr, Field(max_length=16, min_length=1)] + operation: Annotated[StrictStr, Field(max_length=128, min_length=1)] + principal: AccessPrincipal + action: AccessAction + resource: AccessResource + allowed: StrictBool + reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] + policy_revision: Annotated[StrictStr | None, Field(max_length=64)] + binding_id: Annotated[StrictStr | None, Field(max_length=64)] + target: Annotated[AccessPrincipal | None, Field(...)] + role: Annotated[AccessRole | None, Field(...)] + + +class AccessAuditPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessAuditEvent], Field(max_length=500)] + next_cursor: Annotated[StrictInt | None, Field(ge=1)] + + class ArtifactReference(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index d344d87bd..ec28937a1 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -7,6 +7,16 @@ from pydantic import BaseModel, JsonValue from powercontext.http._generated.models import ( + AccessAuditPage, + AccessBinding, + AccessBindingPage, + AccessCheckBatchRequest, + AccessCheckBatchResponse, + AccessCheckRequest, + AccessDecision, + AccessPrincipal, + AccessResourcePage, + AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, @@ -19,6 +29,7 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateAccessBindingRequest, CreateHandoffReportProjectRequest, CreateWorkContractRequest, DetachHandoffReportWorkspaceRequest, @@ -49,6 +60,10 @@ HealthResponse, ImportExternalSkillRequest, KnownHandoffScopePage, + ListAccessAuditRequest, + ListAccessBindingsRequest, + ListAccessResourcesRequest, + ListAccessRolesRequest, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -83,6 +98,7 @@ RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, + RevokeAccessBindingRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, @@ -117,6 +133,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): summary: str tags: tuple[str, ...] responses: dict[int | str, dict[str, JsonValue]] + access: AccessRequirement | None + + +class AccessRequirement(BaseModel): + action: str + resource: Literal["server", "scope", "handoff"] + scope_id_field: str | None + resolver: Literal["static", "request", "continue_handoff", "acknowledge_handoff"] GET_LIVENESS = Operation[None, HealthResponse]( @@ -135,6 +159,7 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, } }, + access=None, ) GET_READINESS = Operation[None, ReadinessResponse]( @@ -157,6 +182,7 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, }, + access=None, ) GET_CAPABILITIES = Operation[None, Capabilities]( @@ -175,7 +201,9 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) CAPTURE_CONTENT_SOURCE = Operation[CaptureContentSourceRequest, CaptureContentSourceResponse]( @@ -195,10 +223,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) PREPARE_CONTEXT = Operation[PrepareContextRequest, PreparedContext]( @@ -217,10 +249,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) CREATE_WORK_CONTRACT = Operation[CreateWorkContractRequest, WorkSourceReceipt]( @@ -241,10 +275,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) HANDOFF_CURRENT_WORK = Operation[HandoffCurrentWorkRequest, PreparedWorkHandoff]( @@ -265,10 +303,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) ACKNOWLEDGE_HANDOFF = Operation[AcknowledgeHandoffRequest, HandoffAcknowledgement]( @@ -289,10 +331,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field=None, resolver="acknowledge_handoff" + ), ) RECORD_TASK_OUTCOME = Operation[RecordTaskOutcomeRequest, WorkSourceReceipt]( @@ -314,10 +360,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) ACTIVATE_HANDOFF = Operation[ActivateHandoffRequest, HandoffActivation]( @@ -337,10 +387,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) PREPARE_HANDOFF = Operation[PrepareHandoffRequest, HandoffDraft]( @@ -360,10 +414,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) FINALIZE_HANDOFF = Operation[FinalizeHandoffRequest, PreparedHandoff]( @@ -383,10 +441,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) COMMIT_HANDOFF = Operation[CommitHandoffRequest, CommittedHandoff]( @@ -407,10 +469,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) CONTINUE_HANDOFF = Operation[ContinueHandoffRequest, HandoffResolution]( @@ -430,10 +496,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field=None, resolver="continue_handoff"), ) FLUSH_MEMORY = Operation[FlushMemoryRequest, FlushMemoryResponse]( @@ -452,10 +520,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) REMEMBER_MEMORY = Operation[RememberMemoryRequest, MemoryMutationResponse]( @@ -475,10 +547,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) SEARCH_MEMORY = Operation[SearchMemoryRequest, SearchMemoryResponse]( @@ -498,10 +574,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) LIST_MEMORY_ENTRIES = Operation[ListMemoryEntriesRequest, ListMemoryEntriesResponse]( @@ -521,10 +599,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) GET_MEMORY_ENTRY = Operation[GetMemoryEntryRequest, MemoryEntry]( @@ -544,10 +624,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) REVISE_MEMORY_ENTRY = Operation[ReviseMemoryEntryRequest, MemoryMutationResponse]( @@ -568,10 +650,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) RETIRE_MEMORY_ENTRY = Operation[RetireMemoryEntryRequest, MemoryMutationResponse]( @@ -592,10 +678,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) LIST_MEMORY_CHANGES = Operation[ListMemoryChangesRequest, ListMemoryChangesResponse]( @@ -615,10 +705,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) PROPOSE_EXPERIENCE = Operation[ProposeExperienceRequest, ArtifactCandidate]( @@ -638,10 +730,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) GENERATE_EXPERIENCE = Operation[GenerateExperienceRequest, GeneratedCandidateResponse]( @@ -661,10 +757,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) GET_EXPERIENCE = Operation[GetExperienceRequest, ExperienceArtifact]( @@ -684,10 +784,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) PROPOSE_SKILL = Operation[ProposeSkillRequest, ArtifactCandidate]( @@ -707,10 +809,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) GENERATE_SKILL = Operation[GenerateSkillRequest, GeneratedCandidateResponse]( @@ -730,10 +836,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) GET_SKILL = Operation[GetSkillRequest, SkillArtifact]( @@ -753,10 +863,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) SCAN_EXTERNAL_SKILLS = Operation[ScanExternalSkillsRequest, ScanExternalSkillsResponse]( @@ -775,10 +887,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) LIST_EXTERNAL_SKILLS = Operation[ListExternalSkillsRequest, ListExternalSkillsResponse]( @@ -797,10 +911,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) RESOLVE_EXTERNAL_SKILL = Operation[ResolveExternalSkillRequest, ExternalSkillResolution]( @@ -820,10 +936,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) IMPORT_EXTERNAL_SKILL = Operation[ImportExternalSkillRequest, GeneratedCandidateResponse]( @@ -844,10 +962,14 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), ) LIST_ARTIFACT_CANDIDATES = Operation[ListArtifactCandidatesRequest, ArtifactCandidatePage]( @@ -866,10 +988,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) GET_ARTIFACT_CANDIDATE = Operation[GetArtifactCandidateRequest, ArtifactCandidate]( @@ -889,10 +1013,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) APPROVE_ARTIFACT_CANDIDATE = Operation[ApproveArtifactCandidateRequest, ArtifactCandidate]( @@ -913,10 +1039,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.review", resource="scope", scope_id_field="scope_id", resolver="request"), ) REJECT_ARTIFACT_CANDIDATE = Operation[RejectArtifactCandidateRequest, ArtifactCandidate]( @@ -937,10 +1065,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.review", resource="scope", scope_id_field="scope_id", resolver="request"), ) REVISE_ARTIFACT_CANDIDATE = Operation[ReviseArtifactCandidateRequest, ArtifactCandidate]( @@ -961,10 +1091,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.review", resource="scope", scope_id_field="scope_id", resolver="request"), ) GET_STATS = Operation[GetStatsRequest, ScopedStats]( @@ -989,10 +1121,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) CREATE_HANDOFF_REPORT_PROJECT = Operation[CreateHandoffReportProjectRequest, ProjectDescriptor]( @@ -1012,9 +1146,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) LIST_HANDOFF_REPORT_PROJECTS = Operation[ListHandoffReportProjectsRequest, ProjectPage]( @@ -1033,9 +1169,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) LIST_HANDOFF_REPORT_KNOWN_SCOPES = Operation[ListHandoffReportKnownScopesRequest, KnownHandoffScopePage]( @@ -1054,9 +1192,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) GET_HANDOFF_REPORT_PROJECT = Operation[GetHandoffReportProjectRequest, ProjectDescriptor]( @@ -1076,9 +1216,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) UPDATE_HANDOFF_REPORT_PROJECT = Operation[UpdateHandoffReportProjectRequest, ProjectDescriptor]( @@ -1099,9 +1241,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) REGISTER_HANDOFF_REPORT_WORKSTREAM = Operation[RegisterHandoffReportWorkstreamRequest, WorkstreamDescriptor]( @@ -1122,9 +1266,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.admin", resource="scope", scope_id_field="scope_id", resolver="request"), ) LIST_HANDOFF_REPORT_WORKSTREAMS = Operation[ListHandoffReportWorkstreamsRequest, WorkstreamPage]( @@ -1144,9 +1290,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) UPDATE_HANDOFF_REPORT_WORKSTREAM = Operation[UpdateHandoffReportWorkstreamRequest, WorkstreamDescriptor]( @@ -1167,9 +1315,13 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement( + action="scope.admin", resource="scope", scope_id_field="workstream.scope_id", resolver="request" + ), ) GET_HANDOFF_REPORT = Operation[GetHandoffReportRequest, HandoffReportResponse]( @@ -1207,11 +1359,13 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 413: {"$ref": "#/components/responses/ReportTooLarge"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) RECORD_HANDOFF_REPORT_ACTIVITY = Operation[RecordHandoffReportActivityRequest, StoredHandoffReportActivity]( @@ -1232,9 +1386,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) LIST_HANDOFF_REPORT_ACTIVITIES = Operation[ListHandoffReportActivitiesRequest, HandoffReportActivityPage]( @@ -1254,9 +1410,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) PURGE_HANDOFF_REPORT_ACTIVITIES = Operation[PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse]( @@ -1276,9 +1434,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) GET_HANDOFF_REPORT_WORKSPACE = Operation[GetHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( @@ -1298,9 +1458,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 404: {"$ref": "#/components/responses/NotFound"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) ATTACH_HANDOFF_REPORT_WORKSPACE = Operation[AttachHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( @@ -1321,9 +1483,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) DETACH_HANDOFF_REPORT_WORKSPACE = Operation[DetachHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( @@ -1344,7 +1508,189 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 404: {"$ref": "#/components/responses/NotFound"}, 409: {"$ref": "#/components/responses/Conflict"}, 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 500: {"$ref": "#/components/responses/InternalError"}, }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), +) + +GET_ACCESS_PRINCIPAL = Operation[None, AccessPrincipal]( + method="GET", + path="/v1/access/me", + operation_id="get_access_principal", + request_type=None, + request_location=None, + response_type=AccessPrincipal, + success_status=200, + summary="Get the authenticated Principal", + tags=("access",), + responses={ + 200: {"description": "The opaque Principal established by the authentication adapter."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +CHECK_ACCESS = Operation[AccessCheckRequest, AccessDecision]( + method="POST", + path="/v1/access/check", + operation_id="check_access", + request_type=AccessCheckRequest, + request_location="body", + response_type=AccessDecision, + success_status=200, + summary="Check one authorization decision", + tags=("access",), + responses={ + 200: {"description": "A low-sensitivity allow or deny decision."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +CHECK_ACCESS_BATCH = Operation[AccessCheckBatchRequest, AccessCheckBatchResponse]( + method="POST", + path="/v1/access/check-batch", + operation_id="check_access_batch", + request_type=AccessCheckBatchRequest, + request_location="body", + response_type=AccessCheckBatchResponse, + success_status=200, + summary="Check a bounded batch of authorization decisions", + tags=("access",), + responses={ + 200: {"description": "Ordered low-sensitivity decisions matching the submitted checks."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +LIST_ACCESS_RESOURCES = Operation[ListAccessResourcesRequest, AccessResourcePage]( + method="POST", + path="/v1/access/resources/list", + operation_id="list_access_resources", + request_type=ListAccessResourcesRequest, + request_location="body", + response_type=AccessResourcePage, + success_status=200, + summary="List only resources already visible to the Principal", + tags=("access",), + responses={ + 200: {"description": "A non-discovering page derived from authorized relationships."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +LIST_ACCESS_ROLES = Operation[ListAccessRolesRequest, AccessRolePage]( + method="POST", + path="/v1/access/roles/list", + operation_id="list_access_roles", + request_type=ListAccessRolesRequest, + request_location="body", + response_type=AccessRolePage, + success_status=200, + summary="List stable built-in role definitions", + tags=("access",), + responses={ + 200: {"description": "Stable role names and the resource type accepted by each role."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +LIST_ACCESS_BINDINGS = Operation[ListAccessBindingsRequest, AccessBindingPage]( + method="POST", + path="/v1/access/bindings/list", + operation_id="list_access_bindings", + request_type=ListAccessBindingsRequest, + request_location="body", + response_type=AccessBindingPage, + success_status=200, + summary="List Access Bindings under an administrative boundary", + tags=("access",), + responses={ + 200: {"description": "Matching immutable Access Bindings."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +CREATE_ACCESS_BINDING = Operation[CreateAccessBindingRequest, AccessBinding]( + method="POST", + path="/v1/access/bindings/create", + operation_id="create_access_binding", + request_type=CreateAccessBindingRequest, + request_location="body", + response_type=AccessBinding, + success_status=201, + summary="Create an idempotent Access Binding", + tags=("access",), + responses={ + 201: {"description": "The Access Binding was created or an identical idempotent result was returned."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +REVOKE_ACCESS_BINDING = Operation[RevokeAccessBindingRequest, AccessBinding]( + method="POST", + path="/v1/access/bindings/revoke", + operation_id="revoke_access_binding", + request_type=RevokeAccessBindingRequest, + request_location="body", + response_type=AccessBinding, + success_status=200, + summary="Revoke an Access Binding using compare-and-swap", + tags=("access",), + responses={ + 200: {"description": "The revoked Access Binding with its incremented version."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +LIST_ACCESS_AUDIT = Operation[ListAccessAuditRequest, AccessAuditPage]( + method="POST", + path="/v1/access/audit/list", + operation_id="list_access_audit", + request_type=ListAccessAuditRequest, + request_location="body", + response_type=AccessAuditPage, + success_status=200, + summary="List data-minimized Access audit events", + tags=("access",), + responses={ + 200: {"description": "Ordered authorization and relationship audit events."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 6be425400..487be9ee2 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -57,7 +57,9 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Capabilities"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/sources/content": { @@ -84,10 +86,16 @@ }, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/context/prepare": { @@ -109,10 +117,12 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PreparedContext"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/work/contracts/create": { @@ -136,10 +146,16 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/work/handoffs/prepare-current": { @@ -169,10 +185,16 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/work/handoffs/acknowledge": { @@ -203,10 +225,16 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "resolver": "acknowledge_handoff", + }, } }, "/v1/work/outcomes/record": { @@ -242,10 +270,16 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/handoff/activate": { @@ -276,10 +310,16 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/handoff/prepare": { @@ -299,10 +339,16 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/handoff/finalize": { @@ -324,10 +370,16 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/handoff/commit": { @@ -348,10 +400,16 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/handoff/continue": { @@ -373,10 +431,12 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "resolver": "continue_handoff"}, } }, "/v1/memory/flush": { @@ -398,10 +458,16 @@ }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/memory/remember": { @@ -426,10 +492,16 @@ }, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/memory/search": { @@ -452,10 +524,12 @@ }, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/memory/entries/list": { @@ -483,10 +557,12 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/memory/entries/get": { @@ -507,10 +583,12 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/memory/entries/revise": { @@ -536,10 +614,16 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/memory/entries/retire": { @@ -567,10 +651,16 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/memory/changes": { @@ -595,10 +685,12 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/experience/propose": { @@ -621,10 +713,16 @@ }, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/experience/generate": { @@ -652,10 +750,16 @@ }, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/experience/get": { @@ -678,10 +782,12 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/skill/propose": { @@ -702,10 +808,16 @@ }, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/skill/generate": { @@ -730,10 +842,16 @@ }, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/skill/get": { @@ -754,10 +872,12 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/external-skills/scan": { @@ -784,10 +904,12 @@ }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.admin", "resource": "server"}, } }, "/v1/external-skills/list": { @@ -822,10 +944,12 @@ }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/external-skills/resolve": { @@ -853,10 +977,12 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/external-skills/import": { @@ -885,10 +1011,16 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": "scope", + "scope_id_field": "scope_id", + }, } }, "/v1/artifact-candidates/list": { @@ -912,10 +1044,12 @@ }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/artifact-candidates/get": { @@ -938,10 +1072,12 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/artifact-candidates/approve": { @@ -965,10 +1101,12 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.review", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/artifact-candidates/reject": { @@ -995,10 +1133,12 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.review", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/artifact-candidates/revise": { @@ -1022,10 +1162,12 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.review", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/stats": { @@ -1033,6 +1175,7 @@ "tags": ["stats"], "summary": "Get scoped product statistics", "operationId": "get_stats", + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, "parameters": [ { "name": "scope_id", @@ -1060,6 +1203,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopedStats"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, @@ -1087,9 +1231,11 @@ }, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.admin", "resource": "server"}, } }, "/v1/handoff-reports/projects/list": { @@ -1112,9 +1258,11 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectPage"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/handoff-reports/scopes/list-known": { @@ -1139,9 +1287,11 @@ }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/handoff-reports/projects/get": { @@ -1163,9 +1313,11 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/handoff-reports/projects/update": { @@ -1190,9 +1342,11 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.admin", "resource": "server"}, } }, "/v1/handoff-reports/workstreams/register": { @@ -1219,9 +1373,11 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.admin", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/handoff-reports/workstreams/list": { @@ -1245,9 +1401,11 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/handoff-reports/workstreams/update": { @@ -1274,9 +1432,15 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": { + "action": "scope.admin", + "resource": "scope", + "scope_id_field": "workstream.scope_id", + }, } }, "/v1/handoff-reports/get": { @@ -1319,11 +1483,13 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "413": {"$ref": "#/components/responses/ReportTooLarge"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, } }, "/v1/handoff-reports/activities/record": { @@ -1350,9 +1516,11 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.admin", "resource": "server"}, } }, "/v1/handoff-reports/activities/list": { @@ -1378,9 +1546,11 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/handoff-reports/activities/purge": { @@ -1408,9 +1578,11 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.admin", "resource": "server"}, } }, "/v1/handoff-reports/workspace-bindings/get": { @@ -1438,9 +1610,11 @@ }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.observe", "resource": "server"}, } }, "/v1/handoff-reports/workspace-bindings/attach": { @@ -1469,9 +1643,11 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.admin", "resource": "server"}, } }, "/v1/handoff-reports/workspace-bindings/detach": { @@ -1500,14 +1676,513 @@ "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + } + }, + "/v1/access/me": { + "get": { + "tags": ["access"], + "summary": "Get the authenticated Principal", + "operationId": "get_access_principal", + "responses": { + "200": { + "description": "The opaque Principal established by the authentication adapter.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessPrincipal"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": "server"}, + } + }, + "/v1/access/check": { + "post": { + "tags": ["access"], + "summary": "Check one authorization decision", + "operationId": "check_access", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessCheckRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "A low-sensitivity allow or deny decision.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDecision"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": "server"}, + } + }, + "/v1/access/check-batch": { + "post": { + "tags": ["access"], + "summary": "Check a bounded batch of authorization decisions", + "operationId": "check_access_batch", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AccessCheckBatchRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Ordered low-sensitivity decisions matching the submitted checks.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AccessCheckBatchResponse"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": "server"}, + } + }, + "/v1/access/resources/list": { + "post": { + "tags": ["access"], + "summary": "List only resources already visible to the Principal", + "operationId": "list_access_resources", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ListAccessResourcesRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "A non-discovering page derived from authorized relationships.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AccessResourcePage"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": "server"}, + } + }, + "/v1/access/roles/list": { + "post": { + "tags": ["access"], + "summary": "List stable built-in role definitions", + "operationId": "list_access_roles", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ListAccessRolesRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Stable role names and the resource type accepted by each role.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessRolePage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": "server"}, + } + }, + "/v1/access/bindings/list": { + "post": { + "tags": ["access"], + "summary": "List Access Bindings under an administrative boundary", + "operationId": "list_access_bindings", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ListAccessBindingsRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Matching immutable Access Bindings.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessBindingPage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": "server"}, + } + }, + "/v1/access/bindings/create": { + "post": { + "tags": ["access"], + "summary": "Create an idempotent Access Binding", + "operationId": "create_access_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/CreateAccessBindingRequest"}} + }, + "required": True, + }, + "responses": { + "201": { + "description": "The Access Binding was created or an identical idempotent result was returned.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessBinding"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": "server"}, + } + }, + "/v1/access/bindings/revoke": { + "post": { + "tags": ["access"], + "summary": "Revoke an Access Binding using compare-and-swap", + "operationId": "revoke_access_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RevokeAccessBindingRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The revoked Access Binding with its incremented version.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessBinding"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": "server"}, + } + }, + "/v1/access/audit/list": { + "post": { + "tags": ["access"], + "summary": "List data-minimized Access audit events", + "operationId": "list_access_audit", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ListAccessAuditRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Ordered authorization and relationship audit events.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessAuditPage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "server.admin", "resource": "server"}, } }, }, "components": { "schemas": { + "AccessPrincipal": { + "properties": { + "type": {"type": "string", "maxLength": 64, "minLength": 1}, + "issuer": {"type": "string", "maxLength": 255, "minLength": 1}, + "id": {"type": "string", "maxLength": 255, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["type", "issuer", "id"], + }, + "AccessAction": { + "type": "string", + "enum": [ + "access.self", + "server.observe", + "server.admin", + "scope.read", + "scope.contribute", + "scope.review", + "scope.delegate", + "scope.admin", + "handoff.read", + "handoff.evidence.read", + "handoff.acknowledge", + ], + }, + "AccessResourceType": {"type": "string", "enum": ["server", "scope", "handoff"]}, + "AccessResource": { + "properties": { + "type": {"$ref": "#/components/schemas/AccessResourceType"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, + "family": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, + "artifact_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, + "revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["type"], + }, + "AccessDecision": { + "properties": { + "allowed": {"type": "boolean"}, + "reason_code": {"type": "string", "maxLength": 64, "minLength": 1}, + "policy_revision": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["allowed", "reason_code", "policy_revision"], + }, + "AccessCheckRequest": { + "properties": { + "action": {"$ref": "#/components/schemas/AccessAction"}, + "resource": {"$ref": "#/components/schemas/AccessResource"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["action", "resource"], + }, + "AccessCheckBatchRequest": { + "properties": { + "checks": { + "items": {"$ref": "#/components/schemas/AccessCheckRequest"}, + "type": "array", + "maxItems": 100, + "minItems": 1, + } + }, + "additionalProperties": False, + "type": "object", + "required": ["checks"], + }, + "AccessCheckBatchResponse": { + "properties": { + "decisions": { + "items": {"$ref": "#/components/schemas/AccessDecision"}, + "type": "array", + "maxItems": 100, + } + }, + "additionalProperties": False, + "type": "object", + "required": ["decisions"], + }, + "ListAccessResourcesRequest": { + "properties": { + "action": {"$ref": "#/components/schemas/AccessAction"}, + "resource_type": {"$ref": "#/components/schemas/AccessResourceType"}, + "cursor": {"type": "string", "nullable": True}, + "limit": {"type": "integer", "maximum": 500.0, "minimum": 1.0, "default": 100}, + }, + "additionalProperties": False, + "type": "object", + "required": ["action", "resource_type"], + }, + "AccessResourcePage": { + "properties": { + "items": { + "items": {"$ref": "#/components/schemas/AccessResource"}, + "type": "array", + "maxItems": 500, + }, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["items", "next_cursor"], + }, + "AccessRole": { + "type": "string", + "enum": [ + "handoff.viewer", + "handoff.receiver", + "scope.viewer", + "scope.contributor", + "scope.reviewer", + "scope.delegator", + "scope.admin", + "server.observer", + "server.admin", + ], + }, + "ListAccessRolesRequest": { + "properties": { + "resource_type": {"allOf": [{"$ref": "#/components/schemas/AccessResourceType"}], "nullable": True} + }, + "additionalProperties": False, + "type": "object", + }, + "AccessRoleDescriptor": { + "properties": { + "role": {"$ref": "#/components/schemas/AccessRole"}, + "resource_type": {"$ref": "#/components/schemas/AccessResourceType"}, + "actions": {"items": {"$ref": "#/components/schemas/AccessAction"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["role", "resource_type", "actions"], + }, + "AccessRolePage": { + "properties": { + "items": { + "items": {"$ref": "#/components/schemas/AccessRoleDescriptor"}, + "type": "array", + "maxItems": 16, + } + }, + "additionalProperties": False, + "type": "object", + "required": ["items"], + }, + "AccessBindingState": {"type": "string", "enum": ["active", "revoked"]}, + "AccessBinding": { + "properties": { + "binding_id": {"type": "string", "maxLength": 64, "minLength": 1}, + "subject": {"$ref": "#/components/schemas/AccessPrincipal"}, + "resource": {"$ref": "#/components/schemas/AccessResource"}, + "role": {"$ref": "#/components/schemas/AccessRole"}, + "granted_by": {"$ref": "#/components/schemas/AccessPrincipal"}, + "reason": {"type": "string", "maxLength": 1024, "nullable": True}, + "created_at": {"type": "string", "format": "date-time"}, + "expires_at": {"type": "string", "format": "date-time", "nullable": True}, + "state": {"$ref": "#/components/schemas/AccessBindingState"}, + "version": {"type": "integer", "minimum": 1.0}, + "policy_revision": {"type": "string", "maxLength": 64, "minLength": 1}, + "idempotency_key": {"type": "string", "maxLength": 255, "minLength": 1}, + "revoked_at": {"type": "string", "format": "date-time", "nullable": True}, + "revoked_by": {"allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "binding_id", + "subject", + "resource", + "role", + "granted_by", + "reason", + "created_at", + "expires_at", + "state", + "version", + "policy_revision", + "idempotency_key", + "revoked_at", + "revoked_by", + ], + }, + "ListAccessBindingsRequest": { + "properties": { + "subject": {"allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], "nullable": True}, + "resource": {"allOf": [{"$ref": "#/components/schemas/AccessResource"}], "nullable": True}, + "include_revoked": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + "type": "object", + }, + "AccessBindingPage": { + "properties": { + "items": {"items": {"$ref": "#/components/schemas/AccessBinding"}, "type": "array", "maxItems": 500} + }, + "additionalProperties": False, + "type": "object", + "required": ["items"], + }, + "CreateAccessBindingRequest": { + "properties": { + "subject": {"$ref": "#/components/schemas/AccessPrincipal"}, + "resource": {"$ref": "#/components/schemas/AccessResource"}, + "role": {"$ref": "#/components/schemas/AccessRole"}, + "idempotency_key": {"type": "string", "maxLength": 255, "minLength": 1}, + "reason": {"type": "string", "maxLength": 1024, "nullable": True}, + "expires_at": {"type": "string", "format": "date-time", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["subject", "resource", "role", "idempotency_key"], + }, + "RevokeAccessBindingRequest": { + "properties": { + "binding_id": {"type": "string", "maxLength": 64, "minLength": 1}, + "expected_version": {"type": "integer", "minimum": 1.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["binding_id", "expected_version"], + }, + "ListAccessAuditRequest": { + "properties": { + "after": {"type": "integer", "minimum": 0.0, "nullable": True}, + "limit": {"type": "integer", "maximum": 500.0, "minimum": 1.0, "default": 100}, + }, + "additionalProperties": False, + "type": "object", + }, + "AccessAuditEvent": { + "properties": { + "cursor": {"type": "integer", "minimum": 1.0}, + "event_id": {"type": "string", "maxLength": 64, "minLength": 1}, + "occurred_at": {"type": "string", "format": "date-time"}, + "request_id": {"type": "string", "maxLength": 128, "nullable": True}, + "transport": {"type": "string", "maxLength": 16, "minLength": 1}, + "operation": {"type": "string", "maxLength": 128, "minLength": 1}, + "principal": {"$ref": "#/components/schemas/AccessPrincipal"}, + "action": {"$ref": "#/components/schemas/AccessAction"}, + "resource": {"$ref": "#/components/schemas/AccessResource"}, + "allowed": {"type": "boolean"}, + "reason_code": {"type": "string", "maxLength": 64, "minLength": 1}, + "policy_revision": {"type": "string", "maxLength": 64, "nullable": True}, + "binding_id": {"type": "string", "maxLength": 64, "nullable": True}, + "target": {"allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], "nullable": True}, + "role": {"allOf": [{"$ref": "#/components/schemas/AccessRole"}], "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "cursor", + "event_id", + "occurred_at", + "request_id", + "transport", + "operation", + "principal", + "action", + "resource", + "allowed", + "reason_code", + "policy_revision", + "binding_id", + "target", + "role", + ], + }, + "AccessAuditPage": { + "properties": { + "items": { + "items": {"$ref": "#/components/schemas/AccessAuditEvent"}, + "type": "array", + "maxItems": 500, + }, + "next_cursor": {"type": "integer", "minimum": 1.0, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["items", "next_cursor"], + }, "ActivateHandoffRequest": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, @@ -3724,13 +4399,18 @@ }, "responses": { "Unauthorized": { - "description": "A valid bearer token is required by this Server deployment.", + "description": "The Server could not establish an authenticated Principal.", "headers": { "WWW-Authenticate": {"$ref": "#/components/headers/BearerChallenge"}, "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, }, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, }, + "Forbidden": { + "description": "The authenticated Principal is not authorized for the requested action and resource.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, "Conflict": { "description": "The command conflicts with current immutable state.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -3775,7 +4455,10 @@ "securitySchemes": { "BearerAuth": { "type": "http", - "description": "Static bearer token used when local Server authentication is enabled.", + "description": "Bearer credential resolved to " + "an opaque authenticated " + "Principal by the Server " + "deployment.", "scheme": "bearer", } }, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 8cfd96edd..317c2ebeb 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -19,7 +19,7 @@ import asyncio import json import logging -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from contextlib import suppress from copy import deepcopy from datetime import UTC, datetime @@ -214,6 +214,19 @@ SourceConflictError, ) from powercontext.http import ( + AccessAction as TransportAccessAction, +) +from powercontext.http import ( + AccessAuditEvent as TransportAccessAuditEvent, +) +from powercontext.http import ( + AccessAuditPage, + AccessBindingPage, + AccessCheckBatchRequest, + AccessCheckBatchResponse, + AccessCheckRequest, + AccessResourcePage, + AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, @@ -226,6 +239,7 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateAccessBindingRequest, CreateHandoffReportProjectRequest, CreateWorkContractRequest, DetachHandoffReportWorkspaceRequest, @@ -258,6 +272,10 @@ ImportExternalSkillRequest, KnownHandoffScope, KnownHandoffScopePage, + ListAccessAuditRequest, + ListAccessBindingsRequest, + ListAccessResourcesRequest, + ListAccessRolesRequest, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -292,6 +310,7 @@ RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, + RevokeAccessBindingRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, @@ -305,6 +324,30 @@ WorkstreamDescriptor, WorkstreamPage, ) +from powercontext.http import ( + AccessBinding as TransportAccessBinding, +) +from powercontext.http import ( + AccessBindingState as TransportAccessBindingState, +) +from powercontext.http import ( + AccessDecision as TransportAccessDecision, +) +from powercontext.http import ( + AccessPrincipal as TransportAccessPrincipal, +) +from powercontext.http import ( + AccessResource as TransportAccessResource, +) +from powercontext.http import ( + AccessResourceType as TransportAccessResourceType, +) +from powercontext.http import ( + AccessRole as TransportAccessRole, +) +from powercontext.http import ( + AccessRoleDescriptor as TransportAccessRoleDescriptor, +) from powercontext.http import ( HandoffActivation as TransportHandoffActivation, ) @@ -326,8 +369,11 @@ APPROVE_ARTIFACT_CANDIDATE, ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, + CHECK_ACCESS, + CHECK_ACCESS_BATCH, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_ACCESS_BINDING, CREATE_HANDOFF_REPORT_PROJECT, CREATE_WORK_CONTRACT, DETACH_HANDOFF_REPORT_WORKSPACE, @@ -335,6 +381,7 @@ FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, + GET_ACCESS_PRINCIPAL, GET_ARTIFACT_CANDIDATE, GET_CAPABILITIES, GET_EXPERIENCE, @@ -348,6 +395,10 @@ GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, + LIST_ACCESS_AUDIT, + LIST_ACCESS_BINDINGS, + LIST_ACCESS_RESOURCES, + LIST_ACCESS_ROLES, LIST_ARTIFACT_CANDIDATES, LIST_EXTERNAL_SKILLS, LIST_HANDOFF_REPORT_ACTIVITIES, @@ -371,17 +422,40 @@ RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, + REVOKE_ACCESS_BINDING, SCAN_EXTERNAL_SKILLS, SEARCH_MEMORY, UPDATE_HANDOFF_REPORT_PROJECT, UPDATE_HANDOFF_REPORT_WORKSTREAM, + AccessRequirement, Operation, ) from powercontext.http._generated.schema import OPENAPI_SCHEMA from powercontext.server import mapping +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessAuditEvent, + AccessBinding, + AccessConflictError, + AccessControlService, + AccessDecision, + AccessDeniedError, + AccessIdentityRequiredError, + AccessInvalidRequestError, + AccessResourceType, + AccessRole, + AccessUnavailableError, + CreateBinding, + PrincipalRef, + ResourceRef, +) +from powercontext.server.authz.models import ROLE_ACTIONS, ROLE_RESOURCE_TYPES from powercontext.server.context import ( bind_request_id, + current_principal, current_request_id, + is_internal_bridge, reset_request_id, ) from powercontext.server.tracing import request_id_from_span @@ -564,6 +638,7 @@ def create_app( metrics: ServerMetrics | None = None, tracing: ServerTracing | None = None, handoff_report_enabled: bool = False, + access_control: AccessControlService | None = None, ) -> FastAPI: """Build the HTTP adapter around an optional Runtime application binding.""" @@ -578,6 +653,7 @@ def create_app( app.state.application = application app.state.capability_provider = capability_provider app.state.readiness_probe = readiness_probe + app.state.access_control = access_control app.state.metrics = metrics app.state.tracing = tracing app.state.capabilities = Capabilities( @@ -636,6 +712,15 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_READINESS, get_readiness) _add_route(app, GET_CAPABILITIES, get_capabilities) _add_route(app, GET_STATS, get_stats) + _add_route(app, GET_ACCESS_PRINCIPAL, get_access_principal) + _add_route(app, CHECK_ACCESS, check_access) + _add_route(app, CHECK_ACCESS_BATCH, check_access_batch) + _add_route(app, LIST_ACCESS_RESOURCES, list_access_resources) + _add_route(app, LIST_ACCESS_ROLES, list_access_roles) + _add_route(app, LIST_ACCESS_BINDINGS, list_access_bindings) + _add_route(app, CREATE_ACCESS_BINDING, create_access_binding) + _add_route(app, REVOKE_ACCESS_BINDING, revoke_access_binding) + _add_route(app, LIST_ACCESS_AUDIT, list_access_audit) if handoff_report_enabled: _add_route(app, CREATE_HANDOFF_REPORT_PROJECT, create_handoff_report_project) _add_route(app, GET_HANDOFF_REPORT_PROJECT, get_handoff_report_project) @@ -723,6 +808,122 @@ async def get_capabilities(request: Request) -> Capabilities: return request.app.state.capabilities +async def get_access_principal(request: Request) -> TransportAccessPrincipal: + _require_access_control(request) + return _access_principal_response(_require_principal()) + + +async def check_access(payload: AccessCheckRequest, request: Request) -> TransportAccessDecision: + access = _require_access_control(request) + decision = await access.check( + _require_principal(), + AccessAction(payload.action.value), + _access_resource(payload.resource), + context=_access_audit_context(CHECK_ACCESS.operation_id), + ) + return _access_decision_response(decision) + + +async def check_access_batch(payload: AccessCheckBatchRequest, request: Request) -> AccessCheckBatchResponse: + access = _require_access_control(request) + checks = tuple((AccessAction(check.action.value), _access_resource(check.resource)) for check in payload.checks) + decisions = await access.check_batch( + _require_principal(), + checks, + context=_access_audit_context(CHECK_ACCESS_BATCH.operation_id), + ) + return AccessCheckBatchResponse(decisions=[_access_decision_response(decision) for decision in decisions]) + + +async def list_access_resources(payload: ListAccessResourcesRequest, request: Request) -> AccessResourcePage: + access = _require_access_control(request) + page = await access.list_resources( + _require_principal(), + action=AccessAction(payload.action.value), + resource_type=AccessResourceType(payload.resource_type.value), + cursor=payload.cursor, + limit=payload.limit, + ) + return AccessResourcePage( + items=[_access_resource_response(resource) for resource in page.items], + next_cursor=page.next_cursor, + ) + + +async def list_access_roles(payload: ListAccessRolesRequest, request: Request) -> AccessRolePage: + _require_access_control(request) + resource_type = None if payload.resource_type is None else AccessResourceType(payload.resource_type.value) + roles = [role for role in AccessRole if resource_type is None or ROLE_RESOURCE_TYPES[role] is resource_type] + return AccessRolePage( + items=[ + TransportAccessRoleDescriptor( + role=TransportAccessRole(role.value), + resource_type=TransportAccessResourceType(ROLE_RESOURCE_TYPES[role].value), + actions=[TransportAccessAction(action.value) for action in sorted(ROLE_ACTIONS[role], key=str)], + ) + for role in roles + ] + ) + + +async def list_access_bindings(payload: ListAccessBindingsRequest, request: Request) -> AccessBindingPage: + access = _require_access_control(request) + principal = _require_principal() + resource = None if payload.resource is None else _access_resource(payload.resource) + action, boundary = _binding_administrative_check(resource) + await access.require( + principal, + action, + boundary, + context=_access_audit_context(LIST_ACCESS_BINDINGS.operation_id), + ) + subject = None if payload.subject is None else _access_principal(payload.subject) + bindings = await access.list_bindings( + subject=subject, + resource=resource, + include_revoked=payload.include_revoked, + ) + return AccessBindingPage(items=[_access_binding_response(binding) for binding in bindings]) + + +async def create_access_binding(payload: CreateAccessBindingRequest, request: Request) -> TransportAccessBinding: + access = _require_access_control(request) + binding = await access.create_binding( + _require_principal(), + CreateBinding( + subject=_access_principal(payload.subject), + resource=_access_resource(payload.resource), + role=AccessRole(payload.role.value), + idempotency_key=payload.idempotency_key, + reason=payload.reason, + expires_at=payload.expires_at, + ), + context=_access_audit_context(CREATE_ACCESS_BINDING.operation_id), + ) + return _access_binding_response(binding) + + +async def revoke_access_binding(payload: RevokeAccessBindingRequest, request: Request) -> TransportAccessBinding: + access = _require_access_control(request) + binding = await access.revoke_binding( + _require_principal(), + payload.binding_id, + expected_version=payload.expected_version, + context=_access_audit_context(REVOKE_ACCESS_BINDING.operation_id), + ) + return _access_binding_response(binding) + + +async def list_access_audit(payload: ListAccessAuditRequest, request: Request) -> AccessAuditPage: + access = _require_access_control(request) + events = await access.list_audit(after=payload.after, limit=payload.limit) + next_cursor = events[-1].cursor if len(events) == payload.limit else None + return AccessAuditPage( + items=[_access_audit_response(event) for event in events], + next_cursor=next_cursor, + ) + + async def get_stats( request: Annotated[GetStatsRequest, Query()], response: Response, @@ -1334,6 +1535,121 @@ def _require_handoff_report_application(request: Request) -> HandoffReportApplic return application.handoff_report +def _require_access_control(request: Request) -> AccessControlService: + access: AccessControlService | None = request.app.state.access_control + if access is None: + raise _RuntimeNotReadyError + return access + + +def _require_principal() -> PrincipalRef: + principal = current_principal() + if principal is None: + raise AccessIdentityRequiredError + return principal + + +def _access_audit_context(operation: str) -> AccessAuditContext: + return AccessAuditContext( + transport="mcp" if is_internal_bridge() else "http", + operation=operation, + request_id=current_request_id(), + ) + + +def _access_principal(value: TransportAccessPrincipal) -> PrincipalRef: + return PrincipalRef(type=value.type, issuer=value.issuer, id=value.id) + + +def _access_principal_response(value: PrincipalRef) -> TransportAccessPrincipal: + return TransportAccessPrincipal(type=value.type, issuer=value.issuer, id=value.id) + + +def _access_resource(value: TransportAccessResource) -> ResourceRef: + resource_type = AccessResourceType(value.type.value) + if resource_type is AccessResourceType.SERVER: + return ResourceRef.server() + if resource_type is AccessResourceType.SCOPE: + return ResourceRef.scope(value.scope_id or "") + return ResourceRef( + type=AccessResourceType.HANDOFF, + scope_id=value.scope_id, + family=value.family, + artifact_id=value.artifact_id, + revision=value.revision, + ) + + +def _access_resource_response(value: ResourceRef) -> TransportAccessResource: + return TransportAccessResource( + type=TransportAccessResourceType(value.type.value), + scope_id=value.scope_id, + family=value.family, + artifact_id=value.artifact_id, + revision=value.revision, + ) + + +def _access_decision_response(value: AccessDecision) -> TransportAccessDecision: + return TransportAccessDecision( + allowed=value.allowed, + reason_code=value.reason_code, + policy_revision=value.policy_revision, + ) + + +def _access_binding_response(value: AccessBinding) -> TransportAccessBinding: + return TransportAccessBinding( + binding_id=value.binding_id, + subject=_access_principal_response(value.subject), + resource=_access_resource_response(value.resource), + role=TransportAccessRole(value.role.value), + granted_by=_access_principal_response(value.granted_by), + reason=value.reason, + created_at=value.created_at, + expires_at=value.expires_at, + state=TransportAccessBindingState(value.state.value), + version=value.version, + policy_revision=value.policy_revision, + idempotency_key=value.idempotency_key, + revoked_at=value.revoked_at, + revoked_by=None if value.revoked_by is None else _access_principal_response(value.revoked_by), + ) + + +def _access_audit_response(value: AccessAuditEvent) -> TransportAccessAuditEvent: + if value.cursor is None: + raise AccessUnavailableError + return TransportAccessAuditEvent( + cursor=value.cursor, + event_id=value.event_id, + occurred_at=value.occurred_at, + request_id=value.request_id, + transport=value.transport, + operation=value.operation, + principal=_access_principal_response(value.principal), + action=TransportAccessAction(value.action.value), + resource=_access_resource_response(value.resource), + allowed=value.allowed, + reason_code=value.reason_code, + policy_revision=value.policy_revision, + binding_id=value.binding_id, + target=None if value.target is None else _access_principal_response(value.target), + role=None if value.role is None else TransportAccessRole(value.role.value), + ) + + +def _binding_administrative_check(resource: ResourceRef | None) -> tuple[AccessAction, ResourceRef]: + if resource is None or resource.type is AccessResourceType.SERVER: + return AccessAction.SERVER_ADMIN, ResourceRef.server() + if resource.type is AccessResourceType.SCOPE: + return AccessAction.SCOPE_ADMIN, resource + parent = resource.parent_scope + if parent is None: + raise AccessInvalidRequestError("handoff-reference") + return AccessAction.SCOPE_DELEGATE, parent + + def _project_descriptor_response(value: DomainProjectDescriptor) -> ProjectDescriptor: return ProjectDescriptor.model_validate(value.model_dump(mode="json", by_alias=True)) @@ -1347,9 +1663,10 @@ def _add_route( operation: Operation[_RequestT, _ResponseT], endpoint: Callable[..., Awaitable[_ResponseT | Response]], ) -> None: + observed = _observe_application_operation(app, operation, endpoint) app.add_api_route( operation.path, - _observe_application_operation(app, operation, endpoint), + observed, methods=[operation.method], operation_id=operation.operation_id, response_model=operation.response_type, @@ -1357,7 +1674,101 @@ def _add_route( responses=operation.responses, summary=operation.summary, tags=list(operation.tags), + dependencies=[] if operation.access is None else [Depends(_authorization_dependency(operation))], + ) + + +def _authorization_dependency( + operation: Operation[Any, Any], +) -> Callable[[Request], Awaitable[None]]: + requirement = operation.access + if requirement is None: + raise AccessInvalidRequestError("resource") + + async def authorize(request: Request) -> None: + access: AccessControlService | None = request.app.state.access_control + if access is not None: + payload = await _authorization_payload(request, operation) + action, resource = _resolve_access_requirement(requirement, payload) + await access.require( + current_principal(), + action, + resource, + context=_access_audit_context(operation.operation_id), + ) + + return authorize + + +async def _authorization_payload(request: Request, operation: Operation[Any, Any]) -> Mapping[str, Any]: + if operation.request_type is None: + return {} + if operation.request_location == "query": + return request.query_params + try: + value = await request.json() + except (UnicodeDecodeError, ValueError) as error: + raise AccessInvalidRequestError("resource") from error + if not isinstance(value, dict): + raise AccessInvalidRequestError("resource") + return value + + +def _resolve_access_requirement( + requirement: AccessRequirement, + payload: Mapping[str, Any], +) -> tuple[AccessAction, ResourceRef]: + if requirement.resolver == "static": + return AccessAction(requirement.action), ResourceRef.server() + if requirement.resolver == "request": + scope_id = _nested_request_value(payload, requirement.scope_id_field) + return AccessAction(requirement.action), ResourceRef.scope(scope_id) + scope_id = _nested_request_value(payload, "scope_id") + selection = str(_nested_request_value(payload, "selection")) + if selection != "exact": + return AccessAction(requirement.action), ResourceRef.scope(scope_id) + revision = payload.get("revision") + if not isinstance(revision, Mapping): + raise AccessInvalidRequestError("handoff-reference") + resource = ResourceRef( + type=AccessResourceType.HANDOFF, + scope_id=scope_id, + family=_mapping_text(revision, "family"), + artifact_id=_mapping_text(revision, "artifact_id"), + revision=_mapping_revision(revision), + ) + action = ( + AccessAction.HANDOFF_ACKNOWLEDGE if requirement.resolver == "acknowledge_handoff" else AccessAction.HANDOFF_READ ) + return action, resource + + +def _nested_request_value(payload: Mapping[str, Any], field: str | None) -> str: + if not field: + raise AccessInvalidRequestError("resource") + value = payload + for part in field.split("."): + value = value.get(part) if isinstance(value, Mapping) else None + if value is None: + raise AccessInvalidRequestError("resource") + text = str(value) + if not text: + raise AccessInvalidRequestError("resource") + return text + + +def _mapping_text(value: Mapping[str, Any], field: str) -> str: + item = value.get(field) + if not isinstance(item, str) or not item: + raise AccessInvalidRequestError("handoff-reference") + return item + + +def _mapping_revision(value: Mapping[str, Any]) -> int: + revision = value.get("revision") + if not isinstance(revision, int) or isinstance(revision, bool) or revision < 1: + raise AccessInvalidRequestError("handoff-reference") + return revision def _observe_application_operation( @@ -1492,6 +1903,9 @@ def _validation_error_details(error: RequestValidationError) -> list[Any]: def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: + access_error = _map_access_error(error) + if access_error is not None: + return access_error if isinstance(error, _RuntimeNotReadyError): return status.HTTP_503_SERVICE_UNAVAILABLE, "runtime_not_ready", "The Runtime is not ready.", None if isinstance(error, ExternalSkillRegistryUnavailableError): @@ -1529,6 +1943,20 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: return _map_domain_error(error) +def _map_access_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, AccessIdentityRequiredError): + return status.HTTP_401_UNAUTHORIZED, "unauthorized", "An authenticated Principal is required.", None + if isinstance(error, AccessDeniedError): + return status.HTTP_403_FORBIDDEN, "forbidden", "The Principal is not authorized for this operation.", None + if isinstance(error, AccessConflictError): + return status.HTTP_409_CONFLICT, error.code, "The Access Binding conflicts with current state.", None + if isinstance(error, AccessInvalidRequestError): + return status.HTTP_422_UNPROCESSABLE_CONTENT, "invalid_access_request", "The Access request is invalid.", None + if isinstance(error, AccessUnavailableError): + return status.HTTP_503_SERVICE_UNAVAILABLE, "access_unavailable", "Access Control is unavailable.", None + return None + + def _map_candidate_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: if isinstance(error, CandidateNotFoundError): return status.HTTP_404_NOT_FOUND, "candidate_not_found", "The requested Candidate was not found.", None diff --git a/src/powercontext/server/authz/__init__.py b/src/powercontext/server/authz/__init__.py new file mode 100644 index 000000000..44cc425ee --- /dev/null +++ b/src/powercontext/server/authz/__init__.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Server-owned authentication and authorization building blocks.""" + +from powercontext.server.authz.errors import ( + AccessConflictError, + AccessDeniedError, + AccessIdentityRequiredError, + AccessInvalidRequestError, + AccessUnavailableError, +) +from powercontext.server.authz.models import ( + AccessAction, + AccessAuditEvent, + AccessBinding, + AccessBindingState, + AccessDecision, + AccessResourceType, + AccessRole, + PrincipalRef, + ResourceRef, +) +from powercontext.server.authz.service import ( + AccessAuditContext, + AccessAuditStore, + AccessControlService, + AuthorizationProvider, + AuthorizedResourcePage, + BuiltinAuthorizationProvider, + CreateBinding, + RelationshipWriter, +) + +__all__ = ( + "AccessAction", + "AccessAuditContext", + "AccessAuditEvent", + "AccessAuditStore", + "AccessBinding", + "AccessBindingState", + "AccessConflictError", + "AccessControlService", + "AccessDecision", + "AccessDeniedError", + "AccessIdentityRequiredError", + "AccessInvalidRequestError", + "AccessResourceType", + "AccessRole", + "AccessUnavailableError", + "AuthorizationProvider", + "AuthorizedResourcePage", + "BuiltinAuthorizationProvider", + "CreateBinding", + "PrincipalRef", + "RelationshipWriter", + "ResourceRef", +) diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py new file mode 100644 index 000000000..3f4efc385 --- /dev/null +++ b/src/powercontext/server/authz/composition.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Lifecycle assembly for the built-in relational Authorization Provider.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager + +from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile +from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.runtime.composition import BuiltinConfigurationError +from powercontext.builtin.runtime.config import DatabaseConfig +from powercontext.server.authz.models import PrincipalRef +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.authz.service import AccessControlService, BuiltinAuthorizationProvider + + +@asynccontextmanager +async def open_builtin_access_control( + database: DatabaseConfig, + *, + bootstrap_administrators: Sequence[PrincipalRef] = (), +) -> AsyncIterator[AccessControlService]: + """Open a Server-owned Access schema without coupling it to Runtime domains.""" + + if isinstance(database, SQLiteConfig): + profile_context = SQLiteProfile.open(database, tables=ACCESS_TABLES) + elif isinstance(database, OceanBaseConfig): + profile_context = OceanBaseProfile.open(database, tables=ACCESS_TABLES) + elif isinstance(database, SeekDBConfig): + profile_context = SeekDBProfile.open(database, tables=ACCESS_TABLES) + else: + raise BuiltinConfigurationError("database") + async with profile_context as profile: + repository = RelationalAccessRepository(profile.database) + provider = BuiltinAuthorizationProvider( + repository, + bootstrap_administrators=bootstrap_administrators, + ) + yield AccessControlService(provider, relationships=repository, audit=repository) + + +__all__ = ("open_builtin_access_control",) diff --git a/src/powercontext/server/authz/errors.py b/src/powercontext/server/authz/errors.py new file mode 100644 index 000000000..5f48c2301 --- /dev/null +++ b/src/powercontext/server/authz/errors.py @@ -0,0 +1,79 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Stable failures owned by the Server Access Control boundary.""" + +from powercontext.errors import PowerContextError + + +class AccessControlError(PowerContextError): + """Base failure for authentication and authorization operations.""" + + +class AccessIdentityRequiredError(AccessControlError): + """The request has no authenticated Principal.""" + + def __init__(self) -> None: + super().__init__("an authenticated Principal is required") + + +class AccessDeniedError(AccessControlError, PermissionError): + """The current Principal cannot perform the requested action.""" + + def __init__(self) -> None: + super().__init__("the Principal is not authorized for this operation") + + +class AccessUnavailableError(AccessControlError, RuntimeError): + """A required authorization dependency is unavailable.""" + + def __init__(self) -> None: + super().__init__("the authorization service is unavailable") + + +class AccessConflictError(AccessControlError, RuntimeError): + """A relationship mutation conflicts with current immutable state.""" + + def __init__(self, code: str) -> None: + self.code = code + messages = { + "binding-version": "the Access Binding version is stale", + "idempotency-key": "the Access Binding idempotency key was reused with different input", + } + super().__init__(messages.get(code, "the Access Binding conflicts with current state")) + + +class AccessInvalidRequestError(AccessControlError, ValueError): + """An Access API request violates the authorization contract.""" + + def __init__(self, code: str) -> None: + self.code = code + messages = { + "binding-role": "the role cannot be bound to this resource type", + "binding-expired": "expires_at must be later than the current Server time", + "handoff-reference": "a Handoff resource requires one exact Handoff ArtifactReference", + "principal": "the Access Principal is invalid", + "resource": "the Access resource is invalid", + } + super().__init__(messages.get(code, f"invalid Access request: {code}")) + + +__all__ = ( + "AccessConflictError", + "AccessControlError", + "AccessDeniedError", + "AccessIdentityRequiredError", + "AccessInvalidRequestError", + "AccessUnavailableError", +) diff --git a/src/powercontext/server/authz/models.py b/src/powercontext/server/authz/models.py new file mode 100644 index 000000000..65c8f6250 --- /dev/null +++ b/src/powercontext/server/authz/models.py @@ -0,0 +1,279 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Transport-independent Access Control values.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from powercontext.server.authz.errors import AccessInvalidRequestError + + +class AccessAction(StrEnum): + """Stable actions checked by Server business operations.""" + + ACCESS_SELF = "access.self" + SERVER_OBSERVE = "server.observe" + SERVER_ADMIN = "server.admin" + SCOPE_READ = "scope.read" + SCOPE_CONTRIBUTE = "scope.contribute" + SCOPE_REVIEW = "scope.review" + SCOPE_DELEGATE = "scope.delegate" + SCOPE_ADMIN = "scope.admin" + HANDOFF_READ = "handoff.read" + HANDOFF_EVIDENCE_READ = "handoff.evidence.read" + HANDOFF_ACKNOWLEDGE = "handoff.acknowledge" + + +class AccessResourceType(StrEnum): + """Resource types understood by the first authorization profile.""" + + SERVER = "server" + SCOPE = "scope" + HANDOFF = "handoff" + + +class AccessRole(StrEnum): + """Fixed first-version roles exposed by the Access API.""" + + HANDOFF_VIEWER = "handoff.viewer" + HANDOFF_RECEIVER = "handoff.receiver" + SCOPE_VIEWER = "scope.viewer" + SCOPE_CONTRIBUTOR = "scope.contributor" + SCOPE_REVIEWER = "scope.reviewer" + SCOPE_DELEGATOR = "scope.delegator" + SCOPE_ADMIN = "scope.admin" + SERVER_OBSERVER = "server.observer" + SERVER_ADMIN = "server.admin" + + +class AccessBindingState(StrEnum): + """Lifecycle state of an immutable role assignment.""" + + ACTIVE = "active" + REVOKED = "revoked" + + +@dataclass(frozen=True, slots=True) +class PrincipalRef: + """Stable opaque identity established by authentication.""" + + type: str + issuer: str + id: str + + def __post_init__(self) -> None: + if not all(isinstance(value, str) and value and value.strip() for value in (self.type, self.issuer, self.id)): + raise AccessInvalidRequestError("principal") + + @property + def key(self) -> str: + return "\x1f".join((self.type, self.issuer, self.id)) + + +@dataclass(frozen=True, slots=True) +class ResourceRef: + """Canonical structured target of one authorization decision.""" + + type: AccessResourceType + scope_id: str | None = None + family: str | None = None + artifact_id: str | None = None + revision: int | None = None + + def __post_init__(self) -> None: + if self.type is AccessResourceType.SERVER: + valid = self.scope_id is None and self.family is None and self.artifact_id is None and self.revision is None + elif self.type is AccessResourceType.SCOPE: + valid = bool(self.scope_id) and self.family is None and self.artifact_id is None and self.revision is None + else: + valid = ( + bool(self.scope_id) + and self.family == "handoff" + and bool(self.artifact_id) + and self.revision is not None + and self.revision > 0 + ) + if not valid: + raise AccessInvalidRequestError( + "handoff-reference" if self.type is AccessResourceType.HANDOFF else "resource" + ) + + @classmethod + def server(cls) -> ResourceRef: + return cls(type=AccessResourceType.SERVER) + + @classmethod + def scope(cls, scope_id: str) -> ResourceRef: + return cls(type=AccessResourceType.SCOPE, scope_id=scope_id) + + @classmethod + def handoff( + cls, + scope_id: str, + *, + artifact_id: str, + revision: int, + ) -> ResourceRef: + return cls( + type=AccessResourceType.HANDOFF, + scope_id=scope_id, + family="handoff", + artifact_id=artifact_id, + revision=revision, + ) + + @property + def key(self) -> str: + values = ( + self.type.value, + self.scope_id or "", + self.family or "", + self.artifact_id or "", + "" if self.revision is None else str(self.revision), + ) + return "\x1f".join(values) + + @property + def parent_scope(self) -> ResourceRef | None: + return None if self.scope_id is None else ResourceRef.scope(self.scope_id) + + +@dataclass(frozen=True, slots=True) +class AccessDecision: + """One low-sensitivity authorization result.""" + + allowed: bool + reason_code: str + policy_revision: str | None + + +@dataclass(frozen=True, slots=True) +class AccessBinding: + """One persisted role assignment.""" + + binding_id: str + subject: PrincipalRef + resource: ResourceRef + role: AccessRole + granted_by: PrincipalRef + reason: str | None + created_at: datetime + expires_at: datetime | None + state: AccessBindingState + version: int + policy_revision: str + idempotency_key: str + revoked_at: datetime | None = None + revoked_by: PrincipalRef | None = None + + def active_at(self, now: datetime) -> bool: + return self.state is AccessBindingState.ACTIVE and (self.expires_at is None or self.expires_at > now) + + +@dataclass(frozen=True, slots=True) +class AccessAuditEvent: + """Data-minimized authorization or relationship audit record.""" + + cursor: int | None + event_id: str + occurred_at: datetime + request_id: str | None + transport: str + operation: str + principal: PrincipalRef + action: AccessAction + resource: ResourceRef + allowed: bool + reason_code: str + policy_revision: str | None + binding_id: str | None = None + target: PrincipalRef | None = None + role: AccessRole | None = None + + +ROLE_ACTIONS: dict[AccessRole, frozenset[AccessAction]] = { + AccessRole.HANDOFF_VIEWER: frozenset({AccessAction.HANDOFF_READ, AccessAction.HANDOFF_EVIDENCE_READ}), + AccessRole.HANDOFF_RECEIVER: frozenset({ + AccessAction.HANDOFF_READ, + AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_ACKNOWLEDGE, + }), + AccessRole.SCOPE_VIEWER: frozenset({ + AccessAction.SCOPE_READ, + AccessAction.HANDOFF_READ, + AccessAction.HANDOFF_EVIDENCE_READ, + }), + AccessRole.SCOPE_CONTRIBUTOR: frozenset({ + AccessAction.SCOPE_READ, + AccessAction.SCOPE_CONTRIBUTE, + AccessAction.HANDOFF_READ, + AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_ACKNOWLEDGE, + }), + AccessRole.SCOPE_REVIEWER: frozenset({ + AccessAction.SCOPE_READ, + AccessAction.SCOPE_REVIEW, + AccessAction.HANDOFF_READ, + AccessAction.HANDOFF_EVIDENCE_READ, + }), + AccessRole.SCOPE_DELEGATOR: frozenset({ + AccessAction.SCOPE_READ, + AccessAction.SCOPE_DELEGATE, + AccessAction.HANDOFF_READ, + AccessAction.HANDOFF_EVIDENCE_READ, + }), + AccessRole.SCOPE_ADMIN: frozenset({ + AccessAction.SCOPE_READ, + AccessAction.SCOPE_CONTRIBUTE, + AccessAction.SCOPE_REVIEW, + AccessAction.SCOPE_DELEGATE, + AccessAction.SCOPE_ADMIN, + AccessAction.HANDOFF_READ, + AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_ACKNOWLEDGE, + }), + AccessRole.SERVER_OBSERVER: frozenset({AccessAction.SERVER_OBSERVE}), + AccessRole.SERVER_ADMIN: frozenset(AccessAction), +} + +ROLE_RESOURCE_TYPES: dict[AccessRole, AccessResourceType] = { + AccessRole.HANDOFF_VIEWER: AccessResourceType.HANDOFF, + AccessRole.HANDOFF_RECEIVER: AccessResourceType.HANDOFF, + AccessRole.SCOPE_VIEWER: AccessResourceType.SCOPE, + AccessRole.SCOPE_CONTRIBUTOR: AccessResourceType.SCOPE, + AccessRole.SCOPE_REVIEWER: AccessResourceType.SCOPE, + AccessRole.SCOPE_DELEGATOR: AccessResourceType.SCOPE, + AccessRole.SCOPE_ADMIN: AccessResourceType.SCOPE, + AccessRole.SERVER_OBSERVER: AccessResourceType.SERVER, + AccessRole.SERVER_ADMIN: AccessResourceType.SERVER, +} + + +__all__ = ( + "ROLE_ACTIONS", + "ROLE_RESOURCE_TYPES", + "AccessAction", + "AccessAuditEvent", + "AccessBinding", + "AccessBindingState", + "AccessDecision", + "AccessResourceType", + "AccessRole", + "PrincipalRef", + "ResourceRef", +) diff --git a/src/powercontext/server/authz/repository.py b/src/powercontext/server/authz/repository.py new file mode 100644 index 000000000..944768e09 --- /dev/null +++ b/src/powercontext/server/authz/repository.py @@ -0,0 +1,492 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dialect-neutral persistence for Server-owned Access relationships.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import replace +from datetime import datetime +from hashlib import sha256 +from typing import Any + +from sqlalchemy import ( + Boolean, + CheckConstraint, + Column, + Integer, + MetaData, + Table, + Text, + UniqueConstraint, + insert, + select, + update, +) +from sqlalchemy.exc import IntegrityError + +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.tables import identity_string +from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH +from powercontext.server.authz.errors import AccessConflictError, AccessInvalidRequestError +from powercontext.server.authz.models import ( + AccessAction, + AccessAuditEvent, + AccessBinding, + AccessBindingState, + AccessResourceType, + AccessRole, + PrincipalRef, + ResourceRef, +) + +ACCESS_METADATA = MetaData() + +ACCESS_POLICY_HEADS_TABLE = Table( + "pc_access_policy_heads", + ACCESS_METADATA, + Column("name", identity_string(32), primary_key=True), + Column("revision", Integer, nullable=False), + CheckConstraint("revision >= 0", name="ck_pc_access_policy_heads_revision_nonnegative"), +) + +ACCESS_BINDINGS_TABLE = Table( + "pc_access_bindings", + ACCESS_METADATA, + Column("binding_id", identity_string(64), primary_key=True), + Column("subject_type", identity_string(64), nullable=False), + Column("subject_issuer", identity_string(255), nullable=False), + Column("subject_id", identity_string(255), nullable=False), + Column("resource_type", identity_string(16), nullable=False), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH)), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), + Column("revision", Integer), + Column("role", identity_string(32), nullable=False), + Column("granted_by_type", identity_string(64), nullable=False), + Column("granted_by_issuer", identity_string(255), nullable=False), + Column("granted_by_id", identity_string(255), nullable=False), + Column("grantor_key_hash", identity_string(64), nullable=False), + Column("reason", Text), + Column("created_at", identity_string(32), nullable=False), + Column("expires_at", identity_string(32)), + Column("state", identity_string(16), nullable=False), + Column("version", Integer, nullable=False), + Column("policy_revision", identity_string(32), nullable=False), + Column("idempotency_key", identity_string(255), nullable=False), + Column("idempotency_key_hash", identity_string(64), nullable=False), + Column("revoked_at", identity_string(32)), + Column("revoked_by_type", identity_string(64)), + Column("revoked_by_issuer", identity_string(255)), + Column("revoked_by_id", identity_string(255)), + UniqueConstraint( + "grantor_key_hash", + "idempotency_key_hash", + name="uq_pc_access_bindings_grantor_idempotency", + ), + CheckConstraint("version > 0", name="ck_pc_access_bindings_version_positive"), +) + +ACCESS_AUDIT_EVENTS_TABLE = Table( + "pc_access_audit_events", + ACCESS_METADATA, + Column("cursor", Integer, primary_key=True, autoincrement=True), + Column("event_id", identity_string(64), nullable=False, unique=True), + Column("occurred_at", identity_string(32), nullable=False), + Column("request_id", identity_string(128)), + Column("transport", identity_string(16), nullable=False), + Column("operation", identity_string(128), nullable=False), + Column("principal_type", identity_string(64), nullable=False), + Column("principal_issuer", identity_string(255), nullable=False), + Column("principal_id", identity_string(255), nullable=False), + Column("action", identity_string(64), nullable=False), + Column("resource_type", identity_string(16), nullable=False), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH)), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), + Column("revision", Integer), + Column("allowed", Boolean, nullable=False), + Column("reason_code", identity_string(64), nullable=False), + Column("policy_revision", identity_string(32)), + Column("binding_id", identity_string(64)), + Column("target_type", identity_string(64)), + Column("target_issuer", identity_string(255)), + Column("target_id", identity_string(255)), + Column("role", identity_string(32)), +) + +ACCESS_TABLES = (ACCESS_POLICY_HEADS_TABLE, ACCESS_BINDINGS_TABLE, ACCESS_AUDIT_EVENTS_TABLE) +_POLICY_HEAD = "authorization" + + +class RelationalAccessRepository: + """Persist bindings, policy revisions, and data-minimized audit events.""" + + def __init__(self, database: AsyncDatabase) -> None: + self._database = database + + async def policy_revision(self) -> str: + async with self._database.transaction() as connection: + row = ( + await connection.execute( + select(ACCESS_POLICY_HEADS_TABLE.c.revision).where(ACCESS_POLICY_HEADS_TABLE.c.name == _POLICY_HEAD) + ) + ).scalar_one_or_none() + return str(row or 0) + + async def active_bindings(self, subject: PrincipalRef, *, now: datetime) -> tuple[AccessBinding, ...]: + async with self._database.transaction() as connection: + rows = ( + ( + await connection.execute( + select(ACCESS_BINDINGS_TABLE).where( + ACCESS_BINDINGS_TABLE.c.subject_type == subject.type, + ACCESS_BINDINGS_TABLE.c.subject_issuer == subject.issuer, + ACCESS_BINDINGS_TABLE.c.subject_id == subject.id, + ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value, + ) + ) + ) + .mappings() + .all() + ) + return tuple(binding for row in rows if (binding := _decode_binding(row)).active_at(now)) + + async def get_binding(self, binding_id: str) -> AccessBinding | None: + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select(ACCESS_BINDINGS_TABLE).where(ACCESS_BINDINGS_TABLE.c.binding_id == binding_id) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _decode_binding(row) + + async def list_bindings( + self, + *, + subject: PrincipalRef | None = None, + resource: ResourceRef | None = None, + include_revoked: bool = False, + ) -> tuple[AccessBinding, ...]: + statement = select(ACCESS_BINDINGS_TABLE) + if subject is not None: + statement = statement.where( + ACCESS_BINDINGS_TABLE.c.subject_type == subject.type, + ACCESS_BINDINGS_TABLE.c.subject_issuer == subject.issuer, + ACCESS_BINDINGS_TABLE.c.subject_id == subject.id, + ) + if resource is not None: + statement = statement.where(*_resource_predicates(resource)) + if not include_revoked: + statement = statement.where(ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value) + statement = statement.order_by(ACCESS_BINDINGS_TABLE.c.created_at, ACCESS_BINDINGS_TABLE.c.binding_id) + async with self._database.transaction() as connection: + rows = (await connection.execute(statement)).mappings().all() + return tuple(_decode_binding(row) for row in rows) + + async def create_binding(self, binding: AccessBinding) -> AccessBinding: + async with self._database.transaction() as connection: + existing = ( + ( + await connection.execute( + select(ACCESS_BINDINGS_TABLE).where( + ACCESS_BINDINGS_TABLE.c.grantor_key_hash == _digest(binding.granted_by.key), + ACCESS_BINDINGS_TABLE.c.idempotency_key_hash == _digest(binding.idempotency_key), + ) + ) + ) + .mappings() + .one_or_none() + ) + if existing is not None: + decoded = _decode_binding(existing) + if _same_creation(decoded, binding): + return decoded + raise AccessConflictError("idempotency-key") + revision = await self._increment_policy_revision(connection) + created = replace(binding, policy_revision=str(revision)) + try: + await connection.execute(insert(ACCESS_BINDINGS_TABLE).values(_binding_row(created))) + except IntegrityError as error: + raise AccessConflictError("idempotency-key") from error + return created + + async def revoke_binding( + self, + binding_id: str, + *, + expected_version: int, + revoked_at: datetime, + revoked_by: PrincipalRef, + ) -> AccessBinding: + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select(ACCESS_BINDINGS_TABLE).where(ACCESS_BINDINGS_TABLE.c.binding_id == binding_id) + ) + ) + .mappings() + .one_or_none() + ) + if row is None: + raise AccessConflictError("binding-version") + current = _decode_binding(row) + if current.version != expected_version or current.state is not AccessBindingState.ACTIVE: + raise AccessConflictError("binding-version") + revision = await self._increment_policy_revision(connection) + result = await connection.execute( + update(ACCESS_BINDINGS_TABLE) + .where( + ACCESS_BINDINGS_TABLE.c.binding_id == binding_id, + ACCESS_BINDINGS_TABLE.c.version == expected_version, + ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value, + ) + .values( + state=AccessBindingState.REVOKED.value, + version=expected_version + 1, + policy_revision=str(revision), + revoked_at=_timestamp(revoked_at), + revoked_by_type=revoked_by.type, + revoked_by_issuer=revoked_by.issuer, + revoked_by_id=revoked_by.id, + ) + ) + if result.rowcount != 1: + raise AccessConflictError("binding-version") + return replace( + current, + state=AccessBindingState.REVOKED, + version=expected_version + 1, + policy_revision=str(revision), + revoked_at=revoked_at, + revoked_by=revoked_by, + ) + + async def append_audit(self, event: AccessAuditEvent) -> AccessAuditEvent: + async with self._database.transaction() as connection: + await connection.execute(insert(ACCESS_AUDIT_EVENTS_TABLE).values(_audit_row(event))) + cursor = ( + await connection.execute( + select(ACCESS_AUDIT_EVENTS_TABLE.c.cursor).where( + ACCESS_AUDIT_EVENTS_TABLE.c.event_id == event.event_id + ) + ) + ).scalar_one() + return replace(event, cursor=int(cursor)) + + async def list_audit(self, *, after: int | None = None, limit: int = 100) -> tuple[AccessAuditEvent, ...]: + statement = select(ACCESS_AUDIT_EVENTS_TABLE) + if after is not None: + statement = statement.where(ACCESS_AUDIT_EVENTS_TABLE.c.cursor > after) + statement = statement.order_by(ACCESS_AUDIT_EVENTS_TABLE.c.cursor).limit(limit) + async with self._database.transaction() as connection: + rows = (await connection.execute(statement)).mappings().all() + return tuple(_decode_audit(row) for row in rows) + + @staticmethod + async def _increment_policy_revision(connection: Any) -> int: + current = ( + await connection.execute( + select(ACCESS_POLICY_HEADS_TABLE.c.revision).where(ACCESS_POLICY_HEADS_TABLE.c.name == _POLICY_HEAD) + ) + ).scalar_one_or_none() + if current is None: + try: + await connection.execute(insert(ACCESS_POLICY_HEADS_TABLE).values(name=_POLICY_HEAD, revision=1)) + except IntegrityError as error: + raise AccessConflictError("binding-version") from error + return 1 + result = await connection.execute( + update(ACCESS_POLICY_HEADS_TABLE) + .where( + ACCESS_POLICY_HEADS_TABLE.c.name == _POLICY_HEAD, + ACCESS_POLICY_HEADS_TABLE.c.revision == current, + ) + .values(revision=current + 1) + ) + if result.rowcount != 1: + raise AccessConflictError("binding-version") + return int(current) + 1 + + +def _resource_predicates(resource: ResourceRef) -> Sequence[Any]: + return ( + ACCESS_BINDINGS_TABLE.c.resource_type == resource.type.value, + ACCESS_BINDINGS_TABLE.c.scope_id == resource.scope_id, + ACCESS_BINDINGS_TABLE.c.family == resource.family, + ACCESS_BINDINGS_TABLE.c.artifact_id == resource.artifact_id, + ACCESS_BINDINGS_TABLE.c.revision == resource.revision, + ) + + +def _binding_row(binding: AccessBinding) -> dict[str, object | None]: + revoked_by = binding.revoked_by + return { + "binding_id": binding.binding_id, + "subject_type": binding.subject.type, + "subject_issuer": binding.subject.issuer, + "subject_id": binding.subject.id, + "resource_type": binding.resource.type.value, + "scope_id": binding.resource.scope_id, + "family": binding.resource.family, + "artifact_id": binding.resource.artifact_id, + "revision": binding.resource.revision, + "role": binding.role.value, + "granted_by_type": binding.granted_by.type, + "granted_by_issuer": binding.granted_by.issuer, + "granted_by_id": binding.granted_by.id, + "grantor_key_hash": _digest(binding.granted_by.key), + "reason": binding.reason, + "created_at": _timestamp(binding.created_at), + "expires_at": None if binding.expires_at is None else _timestamp(binding.expires_at), + "state": binding.state.value, + "version": binding.version, + "policy_revision": binding.policy_revision, + "idempotency_key": binding.idempotency_key, + "idempotency_key_hash": _digest(binding.idempotency_key), + "revoked_at": None if binding.revoked_at is None else _timestamp(binding.revoked_at), + "revoked_by_type": None if revoked_by is None else revoked_by.type, + "revoked_by_issuer": None if revoked_by is None else revoked_by.issuer, + "revoked_by_id": None if revoked_by is None else revoked_by.id, + } + + +def _decode_binding(row: Mapping[Any, Any]) -> AccessBinding: + resource = _decode_resource(row) + revoked_by = _optional_principal(row, "revoked_by") + return AccessBinding( + binding_id=str(row["binding_id"]), + subject=_principal(row, "subject"), + resource=resource, + role=AccessRole(str(row["role"])), + granted_by=_principal(row, "granted_by"), + reason=None if row["reason"] is None else str(row["reason"]), + created_at=_parse_timestamp(row["created_at"]), + expires_at=None if row["expires_at"] is None else _parse_timestamp(row["expires_at"]), + state=AccessBindingState(str(row["state"])), + version=int(row["version"]), + policy_revision=str(row["policy_revision"]), + idempotency_key=str(row["idempotency_key"]), + revoked_at=None if row["revoked_at"] is None else _parse_timestamp(row["revoked_at"]), + revoked_by=revoked_by, + ) + + +def _audit_row(event: AccessAuditEvent) -> dict[str, object | None]: + target = event.target + return { + "event_id": event.event_id, + "occurred_at": _timestamp(event.occurred_at), + "request_id": event.request_id, + "transport": event.transport, + "operation": event.operation, + "principal_type": event.principal.type, + "principal_issuer": event.principal.issuer, + "principal_id": event.principal.id, + "action": event.action.value, + "resource_type": event.resource.type.value, + "scope_id": event.resource.scope_id, + "family": event.resource.family, + "artifact_id": event.resource.artifact_id, + "revision": event.resource.revision, + "allowed": event.allowed, + "reason_code": event.reason_code, + "policy_revision": event.policy_revision, + "binding_id": event.binding_id, + "target_type": None if target is None else target.type, + "target_issuer": None if target is None else target.issuer, + "target_id": None if target is None else target.id, + "role": None if event.role is None else event.role.value, + } + + +def _decode_audit(row: Mapping[Any, Any]) -> AccessAuditEvent: + return AccessAuditEvent( + cursor=int(row["cursor"]), + event_id=str(row["event_id"]), + occurred_at=_parse_timestamp(row["occurred_at"]), + request_id=None if row["request_id"] is None else str(row["request_id"]), + transport=str(row["transport"]), + operation=str(row["operation"]), + principal=_principal(row, "principal"), + action=AccessAction(str(row["action"])), + resource=_decode_resource(row), + allowed=bool(row["allowed"]), + reason_code=str(row["reason_code"]), + policy_revision=None if row["policy_revision"] is None else str(row["policy_revision"]), + binding_id=None if row["binding_id"] is None else str(row["binding_id"]), + target=_optional_principal(row, "target"), + role=None if row["role"] is None else AccessRole(str(row["role"])), + ) + + +def _decode_resource(row: Mapping[Any, Any]) -> ResourceRef: + resource_type = AccessResourceType(str(row["resource_type"])) + if resource_type is AccessResourceType.SERVER: + return ResourceRef.server() + if resource_type is AccessResourceType.SCOPE: + return ResourceRef.scope(str(row["scope_id"])) + return ResourceRef.handoff( + str(row["scope_id"]), + artifact_id=str(row["artifact_id"]), + revision=int(row["revision"]), + ) + + +def _principal(row: Mapping[Any, Any], prefix: str) -> PrincipalRef: + return PrincipalRef( + type=str(row[f"{prefix}_type"]), + issuer=str(row[f"{prefix}_issuer"]), + id=str(row[f"{prefix}_id"]), + ) + + +def _optional_principal(row: Mapping[Any, Any], prefix: str) -> PrincipalRef | None: + return None if row[f"{prefix}_type"] is None else _principal(row, prefix) + + +def _timestamp(value: datetime) -> str: + if value.tzinfo is None: + raise AccessInvalidRequestError("timestamp") + return value.isoformat() + + +def _parse_timestamp(value: object) -> datetime: + return datetime.fromisoformat(str(value)) + + +def _same_creation(existing: AccessBinding, requested: AccessBinding) -> bool: + return ( + existing.subject == requested.subject + and existing.resource == requested.resource + and existing.role is requested.role + and existing.reason == requested.reason + and existing.expires_at == requested.expires_at + ) + + +def _digest(value: str) -> str: + return sha256(value.encode("utf-8")).hexdigest() + + +__all__ = ( + "ACCESS_TABLES", + "RelationalAccessRepository", +) diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py new file mode 100644 index 000000000..51a054414 --- /dev/null +++ b/src/powercontext/server/authz/service.py @@ -0,0 +1,483 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Authorization Provider SPI and Server-owned Access use cases.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Protocol, TypeVar +from uuid import uuid4 + +from powercontext.server.authz.errors import ( + AccessControlError, + AccessDeniedError, + AccessIdentityRequiredError, + AccessInvalidRequestError, + AccessUnavailableError, +) +from powercontext.server.authz.models import ( + ROLE_ACTIONS, + ROLE_RESOURCE_TYPES, + AccessAction, + AccessAuditEvent, + AccessBinding, + AccessBindingState, + AccessDecision, + AccessResourceType, + AccessRole, + PrincipalRef, + ResourceRef, +) + +_T = TypeVar("_T") + + +@dataclass(frozen=True, slots=True) +class AuthorizedResourcePage: + """One stable, non-discovering page of resources visible to a Principal.""" + + items: tuple[ResourceRef, ...] + next_cursor: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreateBinding: + """Validated intent to create one immutable Access Binding.""" + + subject: PrincipalRef + resource: ResourceRef + role: AccessRole + idempotency_key: str + reason: str | None = None + expires_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class AccessAuditContext: + """Low-sensitivity request facts attached to a decision audit event.""" + + transport: str + operation: str + request_id: str | None = None + + +class AuthorizationProvider(Protocol): + """Replaceable decision interface suitable for OpenFGA, Casbin, or Oso adapters.""" + + async def check( + self, + principal: PrincipalRef, + action: AccessAction, + resource: ResourceRef, + ) -> AccessDecision: ... + + async def check_batch( + self, + principal: PrincipalRef, + checks: Sequence[tuple[AccessAction, ResourceRef]], + ) -> tuple[AccessDecision, ...]: ... + + async def list_resources( + self, + principal: PrincipalRef, + *, + action: AccessAction, + resource_type: AccessResourceType, + cursor: str | None = None, + limit: int = 100, + ) -> AuthorizedResourcePage: ... + + +class RelationshipWriter(Protocol): + """Replaceable relationship mutation interface paired with a Provider.""" + + async def get_binding(self, binding_id: str) -> AccessBinding | None: ... + + async def list_bindings( + self, + *, + subject: PrincipalRef | None = None, + resource: ResourceRef | None = None, + include_revoked: bool = False, + ) -> tuple[AccessBinding, ...]: ... + + async def create_binding(self, binding: AccessBinding) -> AccessBinding: ... + + async def revoke_binding( + self, + binding_id: str, + *, + expected_version: int, + revoked_at: datetime, + revoked_by: PrincipalRef, + ) -> AccessBinding: ... + + +class AccessAuditStore(Protocol): + """Append-only audit boundary that can use a dedicated compliance backend.""" + + async def append_audit(self, event: AccessAuditEvent) -> AccessAuditEvent: ... + + async def list_audit(self, *, after: int | None = None, limit: int = 100) -> tuple[AccessAuditEvent, ...]: ... + + +class AccessRepository(RelationshipWriter, AccessAuditStore, Protocol): + """Built-in Provider read requirements.""" + + async def policy_revision(self) -> str: ... + + async def active_bindings(self, subject: PrincipalRef, *, now: datetime) -> tuple[AccessBinding, ...]: ... + + +class BuiltinAuthorizationProvider: + """Small hierarchical RBAC profile backed by immutable Access Bindings.""" + + def __init__( + self, + repository: AccessRepository, + *, + bootstrap_administrators: Sequence[PrincipalRef] = (), + clock: Callable[[], datetime] | None = None, + ) -> None: + self._repository = repository + self._bootstrap_administrators = frozenset(bootstrap_administrators) + self._clock = clock or (lambda: datetime.now(UTC)) + + async def check( + self, + principal: PrincipalRef, + action: AccessAction, + resource: ResourceRef, + ) -> AccessDecision: + revision = await self._repository.policy_revision() + if action is AccessAction.ACCESS_SELF: + return AccessDecision(True, "authenticated", revision) + if principal in self._bootstrap_administrators: + return AccessDecision(True, "bootstrap-admin", revision) + bindings = await self._repository.active_bindings(principal, now=self._clock()) + return _binding_decision(bindings, action, resource, policy_revision=revision) + + async def check_batch( + self, + principal: PrincipalRef, + checks: Sequence[tuple[AccessAction, ResourceRef]], + ) -> tuple[AccessDecision, ...]: + revision = await self._repository.policy_revision() + if principal in self._bootstrap_administrators: + return tuple(AccessDecision(True, "bootstrap-admin", revision) for _ in checks) + bindings = await self._repository.active_bindings(principal, now=self._clock()) + return tuple( + AccessDecision(True, "authenticated", revision) + if action is AccessAction.ACCESS_SELF + else _binding_decision(bindings, action, resource, policy_revision=revision) + for action, resource in checks + ) + + async def list_resources( + self, + principal: PrincipalRef, + *, + action: AccessAction, + resource_type: AccessResourceType, + cursor: str | None = None, + limit: int = 100, + ) -> AuthorizedResourcePage: + if limit < 1 or limit > 500: + raise AccessInvalidRequestError("limit") + if cursor not in {None, ""}: + raise AccessInvalidRequestError("cursor") + bindings = await self._repository.active_bindings(principal, now=self._clock()) + resources = { + binding.resource.key: binding.resource + for binding in bindings + if binding.resource.type is resource_type and action in ROLE_ACTIONS[binding.role] + } + ordered = tuple(resources[key] for key in sorted(resources)) + return AuthorizedResourcePage(items=ordered[:limit]) + + +class AccessControlService: + """Fail-closed Access orchestration shared by HTTP and MCP transports.""" + + def __init__( + self, + provider: AuthorizationProvider, + *, + relationships: RelationshipWriter, + audit: AccessAuditStore, + clock: Callable[[], datetime] | None = None, + ) -> None: + self.provider = provider + self.relationships = relationships + self.audit = audit + self._clock = clock or (lambda: datetime.now(UTC)) + + async def check( + self, + principal: PrincipalRef | None, + action: AccessAction, + resource: ResourceRef, + *, + context: AccessAuditContext, + ) -> AccessDecision: + if principal is None: + raise AccessIdentityRequiredError + decision = await _access_call(self.provider.check(principal, action, resource)) + await _access_call(self._record_decision(principal, action, resource, decision, context=context)) + return decision + + async def require( + self, + principal: PrincipalRef | None, + action: AccessAction, + resource: ResourceRef, + *, + context: AccessAuditContext, + ) -> AccessDecision: + decision = await self.check(principal, action, resource, context=context) + if not decision.allowed: + raise AccessDeniedError + return decision + + async def check_batch( + self, + principal: PrincipalRef | None, + checks: Sequence[tuple[AccessAction, ResourceRef]], + *, + context: AccessAuditContext, + ) -> tuple[AccessDecision, ...]: + if principal is None: + raise AccessIdentityRequiredError + decisions = await _access_call(self.provider.check_batch(principal, checks)) + if len(decisions) != len(checks): + raise AccessUnavailableError + for (action, resource), decision in zip(checks, decisions, strict=True): + await _access_call(self._record_decision(principal, action, resource, decision, context=context)) + return decisions + + async def list_resources( + self, + principal: PrincipalRef | None, + *, + action: AccessAction, + resource_type: AccessResourceType, + cursor: str | None = None, + limit: int = 100, + ) -> AuthorizedResourcePage: + actor = _required_principal(principal) + return await _access_call( + self.provider.list_resources( + actor, + action=action, + resource_type=resource_type, + cursor=cursor, + limit=limit, + ) + ) + + async def list_bindings( + self, + *, + subject: PrincipalRef | None = None, + resource: ResourceRef | None = None, + include_revoked: bool = False, + ) -> tuple[AccessBinding, ...]: + return await _access_call( + self.relationships.list_bindings( + subject=subject, + resource=resource, + include_revoked=include_revoked, + ) + ) + + async def list_audit(self, *, after: int | None = None, limit: int = 100) -> tuple[AccessAuditEvent, ...]: + return await _access_call(self.audit.list_audit(after=after, limit=limit)) + + async def create_binding( + self, + principal: PrincipalRef | None, + request: CreateBinding, + *, + context: AccessAuditContext, + ) -> AccessBinding: + if ROLE_RESOURCE_TYPES[request.role] is not request.resource.type: + raise AccessInvalidRequestError("binding-role") + now = self._clock() + if request.expires_at is not None and request.expires_at <= now: + raise AccessInvalidRequestError("binding-expired") + action, administrative_resource = _administrative_check(request.resource) + actor = _required_principal(principal) + await self.require(actor, action, administrative_resource, context=context) + candidate = AccessBinding( + binding_id=str(uuid4()), + subject=request.subject, + resource=request.resource, + role=request.role, + granted_by=actor, + reason=request.reason, + created_at=now, + expires_at=request.expires_at, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key=request.idempotency_key, + ) + created = await _access_call(self.relationships.create_binding(candidate)) + await _access_call(self._record_relationship(created, principal=actor, action=action, context=context)) + return created + + async def revoke_binding( + self, + principal: PrincipalRef | None, + binding_id: str, + *, + expected_version: int, + context: AccessAuditContext, + ) -> AccessBinding: + actor = _required_principal(principal) + binding = await _access_call(self.relationships.get_binding(binding_id)) + if binding is None: + raise AccessDeniedError + action, administrative_resource = _administrative_check(binding.resource) + await self.require(actor, action, administrative_resource, context=context) + revoked = await _access_call( + self.relationships.revoke_binding( + binding_id, + expected_version=expected_version, + revoked_at=self._clock(), + revoked_by=actor, + ) + ) + await _access_call(self._record_relationship(revoked, principal=actor, action=action, context=context)) + return revoked + + async def _record_decision( + self, + principal: PrincipalRef, + action: AccessAction, + resource: ResourceRef, + decision: AccessDecision, + *, + context: AccessAuditContext, + ) -> None: + await self.audit.append_audit( + AccessAuditEvent( + cursor=None, + event_id=str(uuid4()), + occurred_at=self._clock(), + request_id=context.request_id, + transport=context.transport, + operation=context.operation, + principal=principal, + action=action, + resource=resource, + allowed=decision.allowed, + reason_code=decision.reason_code, + policy_revision=decision.policy_revision, + ) + ) + + async def _record_relationship( + self, + binding: AccessBinding, + *, + principal: PrincipalRef, + action: AccessAction, + context: AccessAuditContext, + ) -> None: + await self.audit.append_audit( + AccessAuditEvent( + cursor=None, + event_id=str(uuid4()), + occurred_at=self._clock(), + request_id=context.request_id, + transport=context.transport, + operation=context.operation, + principal=principal, + action=action, + resource=binding.resource, + allowed=True, + reason_code="binding-created" if binding.state is AccessBindingState.ACTIVE else "binding-revoked", + policy_revision=binding.policy_revision, + binding_id=binding.binding_id, + target=binding.subject, + role=binding.role, + ) + ) + + +def _binding_covers(binding: ResourceRef, requested: ResourceRef) -> bool: + if binding == requested: + return True + if binding.type is AccessResourceType.SERVER: + return True + return ( + binding.type is AccessResourceType.SCOPE + and requested.type is AccessResourceType.HANDOFF + and binding.scope_id == requested.scope_id + ) + + +def _binding_decision( + bindings: Sequence[AccessBinding], + action: AccessAction, + resource: ResourceRef, + *, + policy_revision: str, +) -> AccessDecision: + for binding in bindings: + if action in ROLE_ACTIONS[binding.role] and _binding_covers(binding.resource, resource): + return AccessDecision(True, "role-binding", policy_revision) + return AccessDecision(False, "no-matching-binding", policy_revision) + + +def _administrative_check(resource: ResourceRef) -> tuple[AccessAction, ResourceRef]: + if resource.type is AccessResourceType.SERVER: + return AccessAction.SERVER_ADMIN, resource + if resource.type is AccessResourceType.SCOPE: + return AccessAction.SCOPE_ADMIN, resource + parent = resource.parent_scope + if parent is None: + raise AccessInvalidRequestError("handoff-reference") + return AccessAction.SCOPE_DELEGATE, parent + + +def _required_principal(principal: PrincipalRef | None) -> PrincipalRef: + if principal is None: + raise AccessIdentityRequiredError + return principal + + +async def _access_call(awaitable: Awaitable[_T]) -> _T: + try: + return await awaitable + except AccessControlError: + raise + except Exception as error: + raise AccessUnavailableError from error + + +__all__ = ( + "AccessAuditContext", + "AccessAuditStore", + "AccessControlService", + "AuthorizationProvider", + "AuthorizedResourcePage", + "BuiltinAuthorizationProvider", + "CreateBinding", + "RelationshipWriter", +) diff --git a/src/powercontext/server/context.py b/src/powercontext/server/context.py index 27b99fab5..eca18b186 100644 --- a/src/powercontext/server/context.py +++ b/src/powercontext/server/context.py @@ -18,8 +18,11 @@ from contextvars import ContextVar, Token +from powercontext.server.authz import PrincipalRef + _internal_bridge: ContextVar[bool] = ContextVar("powercontext_internal_bridge", default=False) _request_id: ContextVar[str | None] = ContextVar("powercontext_request_id", default=None) +_principal: ContextVar[PrincipalRef | None] = ContextVar("powercontext_principal", default=None) def bind_request_id(request_id: str) -> Token[str | None]: @@ -34,6 +37,18 @@ def current_request_id() -> str | None: return _request_id.get() +def bind_principal(principal: PrincipalRef) -> Token[PrincipalRef | None]: + return _principal.set(principal) + + +def reset_principal(token: Token[PrincipalRef | None]) -> None: + _principal.reset(token) + + +def current_principal() -> PrincipalRef | None: + return _principal.get() + + def bind_internal_bridge() -> Token[bool]: return _internal_bridge.set(True) @@ -48,9 +63,12 @@ def is_internal_bridge() -> bool: __all__ = [ "bind_internal_bridge", + "bind_principal", "bind_request_id", + "current_principal", "current_request_id", "is_internal_bridge", "reset_internal_bridge", + "reset_principal", "reset_request_id", ] diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 563b0ab4c..19bf105cb 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -19,7 +19,7 @@ import asyncio import logging from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from fastapi import FastAPI, Response @@ -41,6 +41,8 @@ from powercontext.paths import default_scheduler_path from powercontext.server.access import HttpAccessLogMiddleware from powercontext.server.app import create_app +from powercontext.server.authz import AccessControlService, PrincipalRef +from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.mcp import mount_mcp from powercontext.server.metrics import CONTENT_TYPE_LATEST, HttpMetricsMiddleware, ServerMetrics from powercontext.server.middleware import StaticBearerMiddleware @@ -64,6 +66,7 @@ def create_server_app( embedding_model: EmbeddingModel | None = None, middleware: Sequence[Middleware] = (), tracing: ServerTracing | None = None, + access_control: AccessControlService | None = None, ) -> FastAPI: """Build the Server process and mount MCP when configured.""" @@ -80,28 +83,43 @@ def create_server_app( if metrics is not None: metrics.set_ready(False) readiness_probe = _ServerReadinessProbe(metrics, tracing=resolved_tracing) + static_principal = PrincipalRef(type="service", issuer="powercontext:static", id="server-token") + configured_access_control = None if resolved.access.mode == "disabled" else access_control @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: _log_lifecycle("server.starting", "PowerContext Server is starting") if isinstance(config.database, SQLiteConfig) and config.database.is_in_memory: _log_in_memory_database_warning() - async with open_builtin_runtime( - config, - scheduler_path=default_scheduler_path() if scheduler_path is None else scheduler_path, - candidate_pipeline=candidate_pipeline, - experience_pipeline=experience_pipeline, - experience_generator=experience_generator, - skill_generator=skill_generator, - external_skill_provider=external_skill_provider, - handoff_pipeline=handoff_pipeline, - embedding_model=embedding_model, - instrumentation=resolved_tracing.instrumentation, - scope_cache_observer=None if metrics is None else metrics.set_runtime_scopes, - tracing=resolved_tracing, - ) as runtime: + async with AsyncExitStack() as resources: + runtime = await resources.enter_async_context( + open_builtin_runtime( + config, + scheduler_path=default_scheduler_path() if scheduler_path is None else scheduler_path, + candidate_pipeline=candidate_pipeline, + experience_pipeline=experience_pipeline, + experience_generator=experience_generator, + skill_generator=skill_generator, + external_skill_provider=external_skill_provider, + handoff_pipeline=handoff_pipeline, + embedding_model=embedding_model, + instrumentation=resolved_tracing.instrumentation, + scope_cache_observer=None if metrics is None else metrics.set_runtime_scopes, + tracing=resolved_tracing, + ) + ) + active_access_control = configured_access_control + if active_access_control is None and resolved.auth.enabled and resolved.access.mode != "disabled": + administrators = (static_principal,) if resolved.access.bootstrap_static_principal else () + active_access_control = await resources.enter_async_context( + open_builtin_access_control( + resolved.database, + bootstrap_administrators=administrators, + ) + ) readiness_probe.bind(runtime) app.state.application = runtime + app.state.access_control = active_access_control app.state.capabilities = await _server_capabilities(runtime) await readiness_probe() try: @@ -110,6 +128,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: _log_lifecycle("server.stopping", "PowerContext Server is stopping") readiness_probe.unbind() app.state.application = None + app.state.access_control = configured_access_control app.state.capabilities = Capabilities( source_types=[], artifact_families=[], @@ -128,7 +147,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: if resolved.auth.enabled and auth_token is not None: configured_middleware.insert( 0, - Middleware(StaticBearerMiddleware, token=auth_token.get_secret_value()), + Middleware( + StaticBearerMiddleware, + token=auth_token.get_secret_value(), + principal=static_principal, + ), ) app = create_app( @@ -138,6 +161,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: metrics=metrics, tracing=resolved_tracing, handoff_report_enabled=resolved.handoff_report.enabled, + access_control=configured_access_control, ) _mount_optional_web_ui(app, resolved) if metrics is not None: diff --git a/src/powercontext/server/middleware.py b/src/powercontext/server/middleware.py index abd9891cf..2183b3c16 100644 --- a/src/powercontext/server/middleware.py +++ b/src/powercontext/server/middleware.py @@ -23,7 +23,8 @@ from starlette.types import ASGIApp, Receive, Scope, Send from powercontext.http import ErrorDetail, ErrorResponse -from powercontext.server.context import is_internal_bridge +from powercontext.server.authz import PrincipalRef +from powercontext.server.context import bind_principal, is_internal_bridge, reset_principal _PUBLIC_PATHS = frozenset({"/", "/handoff-reports", "/reviews", "/skills", "/health/live", "/health/ready"}) _PUBLIC_PATH_PREFIXES = ("/static/",) @@ -32,16 +33,31 @@ class StaticBearerMiddleware: """Require one configured bearer token for external HTTP requests.""" - def __init__(self, app: ASGIApp, *, token: str) -> None: + def __init__(self, app: ASGIApp, *, token: str, principal: PrincipalRef | None = None) -> None: if not token: raise ValueError("Bearer token must not be empty") # noqa: TRY003 self.app = app self._token = token.encode() + self._principal = principal or PrincipalRef(type="service", issuer="powercontext:static", id="server-token") async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if self._allows(scope): + if is_internal_bridge(): await self.app(scope, receive, send) return + if self._allows(scope): + if ( + scope["type"] != "http" + or scope["path"] in _PUBLIC_PATHS + or scope["path"].startswith(_PUBLIC_PATH_PREFIXES) + ): + await self.app(scope, receive, send) + return + principal_token = bind_principal(self._principal) + try: + await self.app(scope, receive, send) + finally: + reset_principal(principal_token) + return error = ErrorResponse( error=ErrorDetail( @@ -58,12 +74,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await response(scope, receive, send) def _allows(self, scope: Scope) -> bool: - if ( - scope["type"] != "http" - or scope["path"] in _PUBLIC_PATHS - or scope["path"].startswith(_PUBLIC_PATH_PREFIXES) - or is_internal_bridge() - ): + if scope["type"] != "http" or scope["path"] in _PUBLIC_PATHS or scope["path"].startswith(_PUBLIC_PATH_PREFIXES): return True authorization = Headers(scope=scope).get("authorization") diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 060730d89..d5a4cd91c 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -108,6 +108,13 @@ def require_token_when_enabled(self) -> BearerAuthConfig: return self +class AccessControlConfig(BaseModel): + """Server authorization rollout and bootstrap behavior.""" + + mode: Literal["disabled", "legacy-static-admin", "enforced"] = "legacy-static-admin" + bootstrap_static_principal: bool = True + + class DashboardScopeConfig(BaseModel): """One scope exposed by the personal Dashboard.""" @@ -178,6 +185,7 @@ class ServerSettings(BaseSettings): http: HttpConfig = Field(default_factory=HttpConfig) mcp: McpConfig = Field(default_factory=McpConfig) auth: BearerAuthConfig = Field(default_factory=BearerAuthConfig) + access: AccessControlConfig = Field(default_factory=AccessControlConfig) allow_unauthenticated_non_loopback: bool = False dashboard: DashboardConfig = Field(default_factory=DashboardConfig) logging: ServerLoggingConfig = Field(default_factory=ServerLoggingConfig) @@ -220,6 +228,7 @@ def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: __all__ = [ + "AccessControlConfig", "BearerAuthConfig", "DashboardConfig", "DashboardScopeConfig", diff --git a/tests/builtin/persistence/test_cursors.py b/tests/builtin/persistence/test_cursors.py index fd8dcfb7c..ce3c248ce 100644 --- a/tests/builtin/persistence/test_cursors.py +++ b/tests/builtin/persistence/test_cursors.py @@ -16,6 +16,8 @@ import asyncio from pathlib import Path +from types import SimpleNamespace +from typing import cast import pytest from sqlalchemy.ext.asyncio import AsyncConnection @@ -114,3 +116,47 @@ async def create_cursor(profile: SQLiteProfile, sequence: int) -> StoredSourceCu assert conflicts[0].actual == 1 asyncio.run(scenario()) + + +def test_source_cursor_initial_creation_avoids_savepoints_on_mysql_compatible_connections() -> None: + """OceanBase can discard this write-path SAVEPOINT before SQLAlchemy releases it.""" + + async def scenario() -> None: + class MissingCursorRepository(SourceCursorRepository): + async def load( + self, + connection: AsyncConnection, + scope_id: str, + binding_name: str, + /, + *, + for_update: bool = False, + ) -> StoredSourceCursor | None: + del connection, scope_id, binding_name, for_update + return None + + class MySQLCompatibleConnection: + dialect = SimpleNamespace(name="mysql") + + def __init__(self) -> None: + self.executions = 0 + + async def execute(self, _statement: object) -> None: + self.executions += 1 + + def begin_nested(self) -> None: + raise AssertionError + + connection = MySQLCompatibleConnection() + created = await MissingCursorRepository().save( + cast(AsyncConnection, connection), + "scope-a", + "handoff-boundary", + SourceCursor(sequence=1), + expected_generation=None, + ) + + assert created.generation == 1 + assert connection.executions == 1 + + asyncio.run(scenario()) diff --git a/tests/test_access_control.py b/tests/test_access_control.py new file mode 100644 index 000000000..39ce3d3c5 --- /dev/null +++ b/tests/test_access_control.py @@ -0,0 +1,210 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest + +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessConflictError, + AccessControlService, + AccessDeniedError, + AccessResourceType, + AccessRole, + BuiltinAuthorizationProvider, + CreateBinding, + PrincipalRef, + ResourceRef, +) +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository + +NOW = datetime(2026, 8, 30, 10, tzinfo=UTC) +ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") +ALICE = PrincipalRef(type="user", issuer="https://identity.example", id="alice") +BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") +AUDIT = AccessAuditContext(transport="http", operation="test", request_id="req-1") + + +def test_exact_handoff_receiver_cannot_discover_other_handoffs_or_scope_data() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, repository = _service(profile.database) + exact = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=3) + created = await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=exact, + role=AccessRole.HANDOFF_RECEIVER, + idempotency_key="handoff-a-to-bob", + ), + context=AUDIT, + ) + + allowed = await service.require( + BOB, + AccessAction.HANDOFF_ACKNOWLEDGE, + exact, + context=AUDIT, + ) + assert allowed.allowed is True + with pytest.raises(AccessDeniedError): + await service.require( + BOB, + AccessAction.HANDOFF_READ, + ResourceRef.handoff("scope-a", artifact_id="handoff-b", revision=1), + context=AUDIT, + ) + with pytest.raises(AccessDeniedError): + await service.require(BOB, AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), context=AUDIT) + + visible = await service.provider.list_resources( + BOB, + action=AccessAction.HANDOFF_READ, + resource_type=AccessResourceType.HANDOFF, + ) + assert visible.items == (exact,) + assert created.policy_revision == "1" + assert len(await repository.list_audit()) == 5 + + asyncio.run(scenario()) + + +def test_scope_role_covers_handoffs_but_expired_bindings_do_not() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, repository = _service(profile.database) + await service.create_binding( + ADMIN, + CreateBinding( + subject=ALICE, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="scope-a-viewer", + expires_at=NOW + timedelta(hours=1), + ), + context=AUDIT, + ) + handoff = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=1) + assert (await service.require(ALICE, AccessAction.HANDOFF_READ, handoff, context=AUDIT)).allowed + assert not (await service.check(ALICE, AccessAction.HANDOFF_ACKNOWLEDGE, handoff, context=AUDIT)).allowed + + expired_provider = BuiltinAuthorizationProvider( + repository, + bootstrap_administrators=(ADMIN,), + clock=lambda: NOW + timedelta(hours=2), + ) + expired = await expired_provider.check(ALICE, AccessAction.HANDOFF_READ, handoff) + assert expired.allowed is False + + asyncio.run(scenario()) + + +def test_binding_creation_is_idempotent_and_revocation_uses_cas() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, repository = _service(profile.database) + request = CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="stable-key", + reason="pairing session", + ) + first = await service.create_binding(ADMIN, request, context=AUDIT) + repeated = await service.create_binding(ADMIN, request, context=AUDIT) + assert repeated.binding_id == first.binding_id + assert await repository.policy_revision() == "1" + + with pytest.raises(AccessConflictError, match="idempotency"): + await service.create_binding( + ADMIN, + CreateBinding( + subject=ALICE, + resource=request.resource, + role=request.role, + idempotency_key=request.idempotency_key, + ), + context=AUDIT, + ) + + revoked = await service.revoke_binding( + ADMIN, + first.binding_id, + expected_version=1, + context=AUDIT, + ) + assert revoked.version == 2 + assert revoked.policy_revision == "2" + with pytest.raises(AccessConflictError, match="version"): + await service.revoke_binding( + ADMIN, + first.binding_id, + expected_version=1, + context=AUDIT, + ) + + asyncio.run(scenario()) + + +def test_persisted_server_admin_covers_scope_administration() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, _ = _service(profile.database) + await service.create_binding( + ADMIN, + CreateBinding( + subject=ALICE, + resource=ResourceRef.server(), + role=AccessRole.SERVER_ADMIN, + idempotency_key="alice-server-admin", + ), + context=AUDIT, + ) + delegated = await service.create_binding( + ALICE, + CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="bob-scope-viewer", + ), + context=AUDIT, + ) + + assert delegated.granted_by == ALICE + assert ( + await service.require(BOB, AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), context=AUDIT) + ).allowed + + asyncio.run(scenario()) + + +def _service(database) -> tuple[AccessControlService, RelationalAccessRepository]: + repository = RelationalAccessRepository(database) + provider = BuiltinAuthorizationProvider( + repository, + bootstrap_administrators=(ADMIN,), + clock=lambda: NOW, + ) + return ( + AccessControlService(provider, relationships=repository, audit=repository, clock=lambda: NOW), + repository, + ) diff --git a/tests/test_access_http.py b/tests/test_access_http.py new file mode 100644 index 000000000..196c936fb --- /dev/null +++ b/tests/test_access_http.py @@ -0,0 +1,148 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio + +import httpx +from starlette.middleware import Middleware + +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.server.app import create_app +from powercontext.server.authz import AccessControlService, BuiltinAuthorizationProvider, PrincipalRef +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.middleware import StaticBearerMiddleware + +ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") +BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") + + +def test_access_api_and_handoff_pep_enforce_exact_receiver_visibility() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + service = AccessControlService( + BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + relationships=repository, + audit=repository, + ) + admin_app = _app(service, principal=ADMIN, token="admin-token") # noqa: S106 - test credential. + async with _client(admin_app) as admin: + principal = await admin.get("/v1/access/me", headers=_auth("admin-token")) + assert principal.status_code == 200 + assert principal.json() == { + "type": "user", + "issuer": "https://identity.example", + "id": "admin", + } + created = await admin.post( + "/v1/access/bindings/create", + headers=_auth("admin-token"), + json={ + "subject": {"type": "user", "issuer": "https://identity.example", "id": "bob"}, + "resource": { + "type": "handoff", + "scope_id": "scope-a", + "family": "handoff", + "artifact_id": "handoff-a", + "revision": 3, + }, + "role": "handoff.receiver", + "idempotency_key": "handoff-a-to-bob", + }, + ) + assert created.status_code == 201 + assert created.json()["policy_revision"] == "1" + + bob_app = _app(service, principal=BOB, token="bob-token") # noqa: S106 - test credential. + async with _client(bob_app) as bob: + exact = { + "type": "handoff", + "scope_id": "scope-a", + "family": "handoff", + "artifact_id": "handoff-a", + "revision": 3, + } + decision = await bob.post( + "/v1/access/check", + headers=_auth("bob-token"), + json={"action": "handoff.acknowledge", "resource": exact}, + ) + assert decision.status_code == 200 + assert decision.json()["allowed"] is True + + resources = await bob.post( + "/v1/access/resources/list", + headers=_auth("bob-token"), + json={"action": "handoff.read", "resource_type": "handoff"}, + ) + assert resources.status_code == 200 + assert resources.json()["items"] == [exact] + + denied = await bob.post( + "/v1/handoff/continue", + headers=_auth("bob-token"), + json={ + "scope_id": "scope-a", + "selection": "exact", + "revision": {"family": "handoff", "artifact_id": "handoff-b", "revision": 1}, + }, + ) + assert denied.status_code == 403, denied.json() + assert denied.json()["error"]["code"] == "forbidden" + + allowed_to_runtime_boundary = await bob.post( + "/v1/handoff/continue", + headers=_auth("bob-token"), + json={ + "scope_id": "scope-a", + "selection": "exact", + "revision": {"family": "handoff", "artifact_id": "handoff-a", "revision": 3}, + }, + ) + assert allowed_to_runtime_boundary.status_code == 503 + assert allowed_to_runtime_boundary.json()["error"]["code"] == "runtime_not_ready" + + cannot_delegate = await bob.post( + "/v1/access/bindings/create", + headers=_auth("bob-token"), + json={ + "subject": {"type": "user", "issuer": "https://identity.example", "id": "alice"}, + "resource": exact, + "role": "handoff.viewer", + "idempotency_key": "bob-cannot-delegate", + }, + ) + assert cannot_delegate.status_code == 403 + + unauthenticated = await bob.get("/v1/access/me") + assert unauthenticated.status_code == 401 + + asyncio.run(scenario()) + + +def _app(service: AccessControlService, *, principal: PrincipalRef, token: str): + return create_app( + access_control=service, + middleware=(Middleware(StaticBearerMiddleware, token=token, principal=principal),), + ) + + +def _client(app) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} diff --git a/tests/test_access_mcp.py b/tests/test_access_mcp.py new file mode 100644 index 000000000..89eec6342 --- /dev/null +++ b/tests/test_access_mcp.py @@ -0,0 +1,118 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Self + +import httpx +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport +from starlette.middleware import Middleware + +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.runtime import MemoryEntriesPage +from powercontext.server.app import create_app +from powercontext.server.authz import ( + AccessAuditContext, + AccessControlService, + AccessRole, + BuiltinAuthorizationProvider, + CreateBinding, + PrincipalRef, + ResourceRef, +) +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.mcp import mount_mcp +from powercontext.server.middleware import StaticBearerMiddleware + +ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") +BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") + + +class _MemoryApplication: + def for_scope(self, scope_id: str) -> Self: + del scope_id + return self + + async def list(self, *, include_inactive: bool = False) -> MemoryEntriesPage: + del include_inactive + return MemoryEntriesPage(memory_ref=None) + + +def test_mcp_internal_bridge_preserves_principal_and_audits_mcp_transport() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + service = AccessControlService( + BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + relationships=repository, + audit=repository, + ) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="bob-scope-a-viewer", + ), + context=AccessAuditContext(transport="test", operation="seed"), + ) + app = create_app( + application=SimpleNamespace(memory=_MemoryApplication()), + access_control=service, + middleware=( + Middleware( + StaticBearerMiddleware, + token="bob-token", # noqa: S106 - test credential. + principal=BOB, + ), + ), + ) + mount_mcp(app) + + def create_http_client( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + **_: object, + ) -> httpx.AsyncClient: + combined_headers = {"Authorization": "Bearer bob-token", **(headers or {})} + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + headers=combined_headers, + timeout=timeout, + auth=auth, + follow_redirects=True, + ) + + transport = StreamableHttpTransport( + "http://testserver/mcp/", + httpx_client_factory=create_http_client, + ) + async with app.router.lifespan_context(app), Client(transport) as client: + result = await client.call_tool("list_memory_entries", {"scope_id": "scope-a"}) + assert result.is_error is False + + audit = await repository.list_audit() + decision = next(event for event in audit if event.operation == "list_memory_entries") + assert decision.transport == "mcp" + assert decision.principal == BOB + assert decision.allowed is True + + asyncio.run(scenario()) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 537e827ef..b9410b02f 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -124,7 +124,7 @@ def test_contract_declares_optional_bearer_authentication() -> None: assert contract["components"]["securitySchemes"]["BearerAuth"] == { "type": "http", "scheme": "bearer", - "description": "Static bearer token used when local Server authentication is enabled.", + "description": "Bearer credential resolved to an opaque authenticated Principal by the Server deployment.", } for path, path_item in contract["paths"].items(): operation = next(iter(path_item.values())) @@ -132,6 +132,8 @@ def test_contract_declares_optional_bearer_authentication() -> None: assert operation["security"] == [] else: assert operation["responses"]["401"] == {"$ref": "#/components/responses/Unauthorized"} + assert operation["responses"]["403"] == {"$ref": "#/components/responses/Forbidden"} + assert "x-powercontext-access" in operation def test_capabilities_report_semantics_without_runtime_tuning_values() -> None: @@ -212,6 +214,15 @@ def test_memory_search_declares_the_revision_conflict_response() -> None: assert SEARCH_MEMORY.responses[409] == {"$ref": "#/components/responses/Conflict"} +def test_handoff_access_metadata_preserves_exact_revision_authorization() -> None: + assert CONTINUE_HANDOFF.access is not None + assert CONTINUE_HANDOFF.access.action == "scope.read" + assert CONTINUE_HANDOFF.access.resolver == "continue_handoff" + assert ACKNOWLEDGE_HANDOFF.access is not None + assert ACKNOWLEDGE_HANDOFF.access.action == "scope.contribute" + assert ACKNOWLEDGE_HANDOFF.access.resolver == "acknowledge_handoff" + + def test_prepared_context_is_a_generic_typed_operation_outside_the_mcp_memory_tools() -> None: assert PREPARE_CONTEXT.path == "/v1/context/prepare" assert PREPARE_CONTEXT.request_type is PrepareContextRequest diff --git a/tests/test_client.py b/tests/test_client.py index 40e036b7d..7b880f978 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -22,11 +22,48 @@ from powercontext.client import InvalidResponseError, PowerContextClient, ServerResponseError, TransportError from powercontext.client.settings import ClientSettings from powercontext.http import ( + AccessAction, + AccessCheckRequest, + AccessResource, + AccessResourceType, CaptureContentSourceRequest, GetHandoffReportRequest, ) +def test_client_exposes_typed_access_check() -> None: + async def scenario() -> None: + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={"allowed": True, "reason_code": "role-binding", "policy_revision": "7"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client = PowerContextClient("https://memory.example", http_client=http_client) + decision = await client.check_access( + AccessCheckRequest( + action=AccessAction.HANDOFF_READ, + resource=AccessResource( + type=AccessResourceType.HANDOFF, + scope_id="scope-a", + family="handoff", + artifact_id="handoff-a", + revision=3, + ), + ) + ) + + assert decision.allowed is True + assert requests[0].url.path == "/v1/access/check" + assert json.loads(requests[0].content)["resource"]["artifact_id"] == "handoff-a" + + asyncio.run(scenario()) + + def test_client_rejects_an_undeclared_success_status() -> None: async def scenario() -> None: response = httpx.Response( diff --git a/tests/test_server.py b/tests/test_server.py index 9c541f11b..5c677448a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -309,6 +309,26 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: assert liveness.status_code == 200 +def test_server_factory_maps_static_token_to_bootstrap_principal() -> None: + app = create_server_app( + settings=ServerSettings( + auth=BearerAuthConfig(enabled=True, token=SecretStr("server-secret")), + database=SQLiteConfig(), + mcp=McpConfig(enabled=False), + ) + ) + + with TestClient(app) as client: + response = client.get("/v1/access/me", headers={"Authorization": "Bearer server-secret"}) + + assert response.status_code == 200 + assert response.json() == { + "type": "service", + "issuer": "powercontext:static", + "id": "server-token", + } + + def test_readiness_reports_unavailable_bindings() -> None: async def probe() -> ReadinessResponse: return ReadinessResponse( From 9b44c18f9554cf719195f41fc63053956543d754 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sun, 30 Aug 2026 20:50:07 +0800 Subject: [PATCH 02/22] fix(dsh): sync Access API artifacts --- .../dsh/plugins/powercontext/lib/index.js | 54 ++ .../powercontext/openapi/powercontext.yaml | 697 +++++++++++++++++- 2 files changed, 749 insertions(+), 2 deletions(-) diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 9c977a528..660e09cc1 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -398,6 +398,60 @@ const OPERATIONS = { path: "/v1/handoff-reports/workspace-bindings/detach", location: "body", scope: false + }, + get_access_principal: { + method: "GET", + path: "/v1/access/me", + location: null, + scope: false + }, + check_access: { + method: "POST", + path: "/v1/access/check", + location: "body", + scope: false + }, + check_access_batch: { + method: "POST", + path: "/v1/access/check-batch", + location: "body", + scope: false + }, + list_access_resources: { + method: "POST", + path: "/v1/access/resources/list", + location: "body", + scope: false + }, + list_access_roles: { + method: "POST", + path: "/v1/access/roles/list", + location: "body", + scope: false + }, + list_access_bindings: { + method: "POST", + path: "/v1/access/bindings/list", + location: "body", + scope: false + }, + create_access_binding: { + method: "POST", + path: "/v1/access/bindings/create", + location: "body", + scope: false + }, + revoke_access_binding: { + method: "POST", + path: "/v1/access/bindings/revoke", + location: "body", + scope: false + }, + list_access_audit: { + method: "POST", + path: "/v1/access/audit/list", + location: "body", + scope: false } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index 2c8681f99..d2e232300 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -67,6 +67,7 @@ paths: tags: [capabilities] summary: Get runtime capabilities operationId: get_capabilities + x-powercontext-access: {action: server.observe, resource: server} responses: "200": description: Behavior enabled by the assembled runtime. @@ -79,12 +80,15 @@ paths: $ref: "#/components/schemas/Capabilities" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /v1/sources/content: post: tags: [sources] summary: Capture durable ContentSource evidence description: Accept raw content as an idempotent Source without synchronously deriving Artifacts. operationId: capture_content_source + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -105,6 +109,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -117,6 +123,7 @@ paths: summary: Prepare bounded context for an Agent turn description: Prepare final, ephemeral context from Runtime-owned sources without persisting or injecting it. operationId: prepare_context + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -135,6 +142,8 @@ paths: $ref: "#/components/schemas/PreparedContext" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -147,6 +156,7 @@ paths: summary: Create a grounded Work Contract description: Persist an inspectable delegation baseline without granting execution authority. operationId: create_work_contract + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -169,6 +179,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -181,6 +193,7 @@ paths: summary: Hand off current work in one high-level operation description: Capture an inspected boundary and prepare a temporary evidence-bearing Handoff without committing it. operationId: handoff_current_work + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -203,6 +216,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -215,6 +230,10 @@ paths: summary: Resolve and acknowledge a Handoff description: Re-resolve one prepared or exact Handoff, check evidence, and capture the receiver's explicit live-state, capability, and authorization checks. operationId: acknowledge_handoff + x-powercontext-access: + action: scope.contribute + resource: scope + resolver: acknowledge_handoff requestBody: required: true content: @@ -237,6 +256,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -249,6 +270,7 @@ paths: summary: Record a completion-aware Task Outcome description: Preserve one attempt's status and checks, optionally linked to the exact accepted Handoff Receipt that the result covers. operationId: record_task_outcome + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -271,6 +293,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -283,6 +307,7 @@ paths: summary: Activate Handoff generation at a Source boundary description: Evaluate the standard Handoff Trigger and synchronously execute any emitted PrepareHandoff Action. operationId: activate_handoff + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -303,6 +328,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -314,6 +341,7 @@ paths: tags: [handoff] summary: Generate an inspectable Handoff Draft operationId: prepare_handoff + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -334,6 +362,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -345,6 +375,7 @@ paths: tags: [handoff] summary: Finalize an inspected Handoff Draft operationId: finalize_handoff + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -365,6 +396,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -376,6 +409,7 @@ paths: tags: [handoff] summary: Commit an explicit Handoff milestone operationId: commit_handoff + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -398,6 +432,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -409,6 +445,10 @@ paths: tags: [handoff] summary: Resolve a Handoff as untrusted historical input operationId: continue_handoff + x-powercontext-access: + action: scope.read + resource: scope + resolver: continue_handoff requestBody: required: true content: @@ -429,6 +469,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -441,6 +483,7 @@ paths: summary: Process the pending Source window into Memory description: Run one bounded Source-to-Memory activation for operational control and testing. operationId: flush_memory + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -459,6 +502,8 @@ paths: $ref: "#/components/schemas/FlushMemoryResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -471,6 +516,7 @@ paths: summary: Remember explicit Memory content description: Save one already-curated Memory entry without creating a Source or invoking extraction. operationId: remember_memory + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -491,6 +537,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -503,6 +551,7 @@ paths: summary: Search active Memory entries description: Retrieve relevant active Memory entries within one explicit application scope. operationId: search_memory + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -523,6 +572,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -537,6 +588,7 @@ paths: Read active entries from the current Memory head. Inactive entries are available only when explicitly requested for audit. operationId: list_memory_entries + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -557,6 +609,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -569,6 +623,7 @@ paths: summary: Get an exact Memory entry version description: Resolve an immutable entry citation within one Memory Revision. operationId: get_memory_entry + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -589,6 +644,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -601,6 +658,7 @@ paths: summary: Revise an exact Memory entry description: Replace active entry content against an explicit current Memory Revision. operationId: revise_memory_entry + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -623,6 +681,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -635,6 +695,7 @@ paths: summary: Retire an exact Memory entry description: Deactivate an entry against an explicit current Memory Revision without deleting history. operationId: retire_memory_entry + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -657,6 +718,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -669,6 +732,7 @@ paths: summary: List Memory Revision changes description: Read compact entry changes without expanding entry bodies. operationId: list_memory_changes + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -689,6 +753,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -701,6 +767,7 @@ paths: summary: Propose Experience content description: Persist a pending Experience Candidate without creating an Artifact Revision. operationId: propose_experience + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -721,6 +788,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -733,6 +802,7 @@ paths: summary: Generate an Experience Candidate description: Use the configured model and caller-selected exact evidence; persist only a schema-valid pending Candidate. operationId: generate_experience + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -753,6 +823,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -765,6 +837,7 @@ paths: summary: Get an exact Experience Revision description: Read approved Experience content and its exact direct evidence. operationId: get_experience + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -785,6 +858,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -797,6 +872,7 @@ paths: summary: Propose managed Skill content description: Persist a pending managed Skill Candidate without creating an Artifact Revision. operationId: propose_skill + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -817,6 +893,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -829,6 +907,7 @@ paths: summary: Generate a managed Skill Candidate description: Use the configured model with an explicit provenance shape; persist only a schema-valid pending Candidate. operationId: generate_skill + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -849,6 +928,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -861,6 +942,7 @@ paths: summary: Get an exact managed Skill Revision description: Read approved managed Skill content and its exact direct evidence. operationId: get_skill + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -881,6 +963,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -893,6 +977,7 @@ paths: summary: Scan configured external Skill roots description: Replace the current host-local Registry projection without copying or rewriting package content. operationId: scan_external_skills + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -911,6 +996,8 @@ paths: $ref: "#/components/schemas/ScanExternalSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -923,6 +1010,7 @@ paths: summary: List external Skills visible on this host description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. operationId: list_external_skills + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -941,6 +1029,8 @@ paths: $ref: "#/components/schemas/ListExternalSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -953,6 +1043,7 @@ paths: summary: Resolve an exact external Skill fingerprint description: Resolve only the registered local package version requested by the caller; never install or fall back. operationId: resolve_external_skill + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -973,6 +1064,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -985,6 +1078,7 @@ paths: summary: Import or fork an external Skill into Review description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. operationId: import_external_skill + x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1007,6 +1101,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1019,6 +1115,7 @@ paths: summary: List Artifact Candidates description: Page current Candidate heads; pending is the default Review Inbox view. operationId: list_artifact_candidates + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1037,6 +1134,8 @@ paths: $ref: "#/components/schemas/ArtifactCandidatePage" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1049,6 +1148,7 @@ paths: summary: Get an Artifact Candidate description: Read the current head and exact immutable proposal version. operationId: get_artifact_candidate + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1069,6 +1169,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1081,6 +1183,7 @@ paths: summary: Approve an Artifact Candidate description: Commit the reviewed proposal and mark the Candidate approved in one transaction. operationId: approve_artifact_candidate + x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1103,6 +1206,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1115,6 +1220,7 @@ paths: summary: Reject an Artifact Candidate description: Move the exact pending version to its rejected terminal state without writing an Artifact. operationId: reject_artifact_candidate + x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1137,6 +1243,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1149,6 +1257,7 @@ paths: summary: Revise an Artifact Candidate description: Append a complete replacement proposal as the next immutable pending version. operationId: revise_artifact_candidate + x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1171,6 +1280,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1182,6 +1293,7 @@ paths: tags: [stats] summary: Get scoped product statistics operationId: get_stats + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} parameters: - name: scope_id in: query @@ -1213,6 +1325,8 @@ paths: $ref: "#/components/schemas/ScopedStats" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1224,6 +1338,7 @@ paths: tags: [handoff-reports] summary: Create a Handoff Report Project operationId: create_handoff_report_project + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1244,6 +1359,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1253,6 +1370,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Projects operationId: list_handoff_report_projects + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1271,6 +1389,8 @@ paths: $ref: "#/components/schemas/ProjectPage" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1280,6 +1400,7 @@ paths: tags: [handoff-reports] summary: List scopes that contain a committed Handoff operationId: list_handoff_report_known_scopes + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1298,6 +1419,8 @@ paths: $ref: "#/components/schemas/KnownHandoffScopePage" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1307,6 +1430,7 @@ paths: tags: [handoff-reports] summary: Get a Handoff Report Project operationId: get_handoff_report_project + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1327,6 +1451,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1336,6 +1462,7 @@ paths: tags: [handoff-reports] summary: Update a Handoff Report Project operationId: update_handoff_report_project + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1358,6 +1485,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1367,6 +1496,7 @@ paths: tags: [handoff-reports] summary: Register a Handoff Report Workstream operationId: register_handoff_report_workstream + x-powercontext-access: {action: scope.admin, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1389,6 +1519,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1398,6 +1530,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Workstreams operationId: list_handoff_report_workstreams + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1418,6 +1551,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1427,6 +1562,7 @@ paths: tags: [handoff-reports] summary: Update a Handoff Report Workstream operationId: update_handoff_report_workstream + x-powercontext-access: {action: scope.admin, resource: scope, scope_id_field: workstream.scope_id} requestBody: required: true content: @@ -1449,6 +1585,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1458,6 +1596,7 @@ paths: tags: [handoff-reports] summary: Generate a Handoff Report operationId: get_handoff_report + x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} requestBody: required: true content: @@ -1500,6 +1639,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "413": @@ -1513,6 +1654,7 @@ paths: tags: [handoff-reports] summary: Record a Handoff Report Activity operationId: record_handoff_report_activity + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1535,6 +1677,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1544,6 +1688,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Activities operationId: list_handoff_report_activities + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1564,6 +1709,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1573,6 +1720,7 @@ paths: tags: [handoff-reports] summary: Purge Handoff Report Activities operationId: purge_handoff_report_activities + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1593,6 +1741,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1602,6 +1752,7 @@ paths: tags: [handoff-reports] summary: Get a Handoff Report Workspace Binding operationId: get_handoff_report_workspace + x-powercontext-access: {action: server.observe, resource: server} requestBody: required: true content: @@ -1622,6 +1773,8 @@ paths: $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1631,6 +1784,7 @@ paths: tags: [handoff-reports] summary: Attach a Handoff Report Workspace Binding operationId: attach_handoff_report_workspace + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1653,6 +1807,8 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": @@ -1662,6 +1818,7 @@ paths: tags: [handoff-reports] summary: Detach a Handoff Report Workspace Binding operationId: detach_handoff_report_workspace + x-powercontext-access: {action: server.admin, resource: server} requestBody: required: true content: @@ -1684,16 +1841,255 @@ paths: $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" + /v1/access/me: + get: + tags: [access] + summary: Get the authenticated Principal + operationId: get_access_principal + x-powercontext-access: {action: access.self, resource: server} + responses: + "200": + description: The opaque Principal established by the authentication adapter. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessPrincipal" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/check: + post: + tags: [access] + summary: Check one authorization decision + operationId: check_access + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccessCheckRequest" + responses: + "200": + description: A low-sensitivity allow or deny decision. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessDecision" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/check-batch: + post: + tags: [access] + summary: Check a bounded batch of authorization decisions + operationId: check_access_batch + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccessCheckBatchRequest" + responses: + "200": + description: Ordered low-sensitivity decisions matching the submitted checks. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessCheckBatchResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/resources/list: + post: + tags: [access] + summary: List only resources already visible to the Principal + operationId: list_access_resources + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListAccessResourcesRequest" + responses: + "200": + description: A non-discovering page derived from authorized relationships. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessResourcePage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/roles/list: + post: + tags: [access] + summary: List stable built-in role definitions + operationId: list_access_roles + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListAccessRolesRequest" + responses: + "200": + description: Stable role names and the resource type accepted by each role. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessRolePage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/access/bindings/list: + post: + tags: [access] + summary: List Access Bindings under an administrative boundary + operationId: list_access_bindings + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListAccessBindingsRequest" + responses: + "200": + description: Matching immutable Access Bindings. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessBindingPage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/bindings/create: + post: + tags: [access] + summary: Create an idempotent Access Binding + operationId: create_access_binding + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateAccessBindingRequest" + responses: + "201": + description: The Access Binding was created or an identical idempotent result was returned. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessBinding" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/bindings/revoke: + post: + tags: [access] + summary: Revoke an Access Binding using compare-and-swap + operationId: revoke_access_binding + x-powercontext-access: {action: access.self, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RevokeAccessBindingRequest" + responses: + "200": + description: The revoked Access Binding with its incremented version. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessBinding" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/audit/list: + post: + tags: [access] + summary: List data-minimized Access audit events + operationId: list_access_audit + x-powercontext-access: {action: server.admin, resource: server} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListAccessAuditRequest" + responses: + "200": + description: Ordered authorization and relationship audit events. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessAuditPage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" components: securitySchemes: BearerAuth: type: http scheme: bearer - description: Static bearer token used when local Server authentication is enabled. + description: Bearer credential resolved to an opaque authenticated Principal by the Server deployment. headers: BearerChallenge: description: Authentication scheme required by the Server. @@ -1706,7 +2102,7 @@ components: type: string responses: Unauthorized: - description: A valid bearer token is required by this Server deployment. + description: The Server could not establish an authenticated Principal. headers: WWW-Authenticate: $ref: "#/components/headers/BearerChallenge" @@ -1716,6 +2112,15 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: The authenticated Principal is not authorized for the requested action and resource. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" Conflict: description: The command conflicts with current immutable state. headers: @@ -1771,6 +2176,294 @@ components: schema: $ref: "#/components/schemas/ErrorResponse" schemas: + AccessPrincipal: + type: object + additionalProperties: false + required: [type, issuer, id] + properties: + type: {type: string, minLength: 1, maxLength: 64} + issuer: {type: string, minLength: 1, maxLength: 255} + id: {type: string, minLength: 1, maxLength: 255} + AccessAction: + type: string + enum: + - access.self + - server.observe + - server.admin + - scope.read + - scope.contribute + - scope.review + - scope.delegate + - scope.admin + - handoff.read + - handoff.evidence.read + - handoff.acknowledge + AccessResourceType: + type: string + enum: [server, scope, handoff] + AccessResource: + type: object + additionalProperties: false + required: [type] + properties: + type: + $ref: "#/components/schemas/AccessResourceType" + scope_id: {type: string, minLength: 1, maxLength: 256, nullable: true} + family: {type: string, minLength: 1, maxLength: 64, nullable: true} + artifact_id: {type: string, minLength: 1, maxLength: 256, nullable: true} + revision: {type: integer, minimum: 1, nullable: true} + AccessDecision: + type: object + additionalProperties: false + required: [allowed, reason_code, policy_revision] + properties: + allowed: {type: boolean} + reason_code: {type: string, minLength: 1, maxLength: 64} + policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} + AccessCheckRequest: + type: object + additionalProperties: false + required: [action, resource] + properties: + action: + $ref: "#/components/schemas/AccessAction" + resource: + $ref: "#/components/schemas/AccessResource" + AccessCheckBatchRequest: + type: object + additionalProperties: false + required: [checks] + properties: + checks: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/AccessCheckRequest" + AccessCheckBatchResponse: + type: object + additionalProperties: false + required: [decisions] + properties: + decisions: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/AccessDecision" + ListAccessResourcesRequest: + type: object + additionalProperties: false + required: [action, resource_type] + properties: + action: + $ref: "#/components/schemas/AccessAction" + resource_type: + $ref: "#/components/schemas/AccessResourceType" + cursor: {type: string, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessResourcePage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessResource" + next_cursor: {type: string, nullable: true} + AccessRole: + type: string + enum: + - handoff.viewer + - handoff.receiver + - scope.viewer + - scope.contributor + - scope.reviewer + - scope.delegator + - scope.admin + - server.observer + - server.admin + ListAccessRolesRequest: + type: object + additionalProperties: false + properties: + resource_type: + allOf: + - $ref: "#/components/schemas/AccessResourceType" + nullable: true + AccessRoleDescriptor: + type: object + additionalProperties: false + required: [role, resource_type, actions] + properties: + role: + $ref: "#/components/schemas/AccessRole" + resource_type: + $ref: "#/components/schemas/AccessResourceType" + actions: + type: array + items: + $ref: "#/components/schemas/AccessAction" + AccessRolePage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + maxItems: 16 + items: + $ref: "#/components/schemas/AccessRoleDescriptor" + AccessBindingState: + type: string + enum: [active, revoked] + AccessBinding: + type: object + additionalProperties: false + required: + - binding_id + - subject + - resource + - role + - granted_by + - reason + - created_at + - expires_at + - state + - version + - policy_revision + - idempotency_key + - revoked_at + - revoked_by + properties: + binding_id: {type: string, minLength: 1, maxLength: 64} + subject: + $ref: "#/components/schemas/AccessPrincipal" + resource: + $ref: "#/components/schemas/AccessResource" + role: + $ref: "#/components/schemas/AccessRole" + granted_by: + $ref: "#/components/schemas/AccessPrincipal" + reason: {type: string, maxLength: 1024, nullable: true} + created_at: {type: string, format: date-time} + expires_at: {type: string, format: date-time, nullable: true} + state: + $ref: "#/components/schemas/AccessBindingState" + version: {type: integer, minimum: 1} + policy_revision: {type: string, minLength: 1, maxLength: 64} + idempotency_key: {type: string, minLength: 1, maxLength: 255} + revoked_at: {type: string, format: date-time, nullable: true} + revoked_by: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + ListAccessBindingsRequest: + type: object + additionalProperties: false + properties: + subject: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + resource: + allOf: + - $ref: "#/components/schemas/AccessResource" + nullable: true + include_revoked: {type: boolean, default: false} + AccessBindingPage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessBinding" + CreateAccessBindingRequest: + type: object + additionalProperties: false + required: [subject, resource, role, idempotency_key] + properties: + subject: + $ref: "#/components/schemas/AccessPrincipal" + resource: + $ref: "#/components/schemas/AccessResource" + role: + $ref: "#/components/schemas/AccessRole" + idempotency_key: {type: string, minLength: 1, maxLength: 255} + reason: {type: string, maxLength: 1024, nullable: true} + expires_at: {type: string, format: date-time, nullable: true} + RevokeAccessBindingRequest: + type: object + additionalProperties: false + required: [binding_id, expected_version] + properties: + binding_id: {type: string, minLength: 1, maxLength: 64} + expected_version: {type: integer, minimum: 1} + ListAccessAuditRequest: + type: object + additionalProperties: false + properties: + after: {type: integer, minimum: 0, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessAuditEvent: + type: object + additionalProperties: false + required: + - cursor + - event_id + - occurred_at + - request_id + - transport + - operation + - principal + - action + - resource + - allowed + - reason_code + - policy_revision + - binding_id + - target + - role + properties: + cursor: {type: integer, minimum: 1} + event_id: {type: string, minLength: 1, maxLength: 64} + occurred_at: {type: string, format: date-time} + request_id: {type: string, maxLength: 128, nullable: true} + transport: {type: string, minLength: 1, maxLength: 16} + operation: {type: string, minLength: 1, maxLength: 128} + principal: + $ref: "#/components/schemas/AccessPrincipal" + action: + $ref: "#/components/schemas/AccessAction" + resource: + $ref: "#/components/schemas/AccessResource" + allowed: {type: boolean} + reason_code: {type: string, minLength: 1, maxLength: 64} + policy_revision: {type: string, maxLength: 64, nullable: true} + binding_id: {type: string, maxLength: 64, nullable: true} + target: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + role: + allOf: + - $ref: "#/components/schemas/AccessRole" + nullable: true + AccessAuditPage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessAuditEvent" + next_cursor: {type: integer, minimum: 1, nullable: true} ActivateHandoffRequest: type: object additionalProperties: false From 4fbd65909f7a1de827e7faf26c2ae4c08b5fa599 Mon Sep 17 00:00:00 2001 From: Teingi Date: Tue, 1 Sep 2026 21:55:52 +0800 Subject: [PATCH 03/22] feat(access): implement RFC 1396 access control --- .env.example | 6 + docs/en/docs/reference/configuration.md | 24 +- docs/en/docs/reference/http-api.md | 20 +- docs/zh/docs/reference/configuration.md | 19 +- docs/zh/docs/reference/http-api.md | 18 +- .../dsh/plugins/powercontext/lib/index.js | 14 +- .../powercontext/openapi/powercontext.yaml | 403 +++++++++--- .../powercontext/src/operations.generated.ts | 4 +- .../plugins/powercontext/lib/index.js | 74 ++- .../powercontext/src/operations.generated.ts | 4 +- .../powercontext/src/operations.generated.ts | 4 +- openapi/powercontext.yaml | 403 +++++++++--- pyproject.toml | 2 + scripts/generate_api.py | 39 +- src/powercontext/client/__init__.py | 13 +- src/powercontext/client/client.py | 31 +- src/powercontext/client/errors.py | 48 ++ src/powercontext/http/__init__.py | 32 + src/powercontext/http/_generated/models.py | 387 ++++++++---- .../http/_generated/operations.py | 85 ++- src/powercontext/http/_generated/schema.py | 467 +++++++++++--- src/powercontext/server/app.py | 593 ++++++++++++++++-- src/powercontext/server/authz/__init__.py | 22 + src/powercontext/server/authz/authzen.py | 203 ++++++ src/powercontext/server/authz/casbin.py | 232 +++++++ src/powercontext/server/authz/composition.py | 65 +- src/powercontext/server/authz/errors.py | 23 +- src/powercontext/server/authz/models.py | 203 ++++-- src/powercontext/server/authz/profiles.py | 163 +++++ src/powercontext/server/authz/repository.py | 170 ++++- src/powercontext/server/authz/service.py | 413 +++++++++--- src/powercontext/server/factory.py | 41 +- src/powercontext/server/settings.py | 1 + src/powercontext/server/static/review.js | 2 - src/powercontext/server/static/skills.js | 2 - .../server/templates/pages/review.html | 4 - .../server/templates/pages/skills.html | 4 - src/powercontext/server/web.py | 102 ++- .../test_access_control.py | 173 +++++ tests/e2e/test_access_control_http.py | 236 +++++++ tests/e2e/test_runtime_server.py | 10 +- tests/test_access_adapters.py | 361 +++++++++++ tests/test_access_control.py | 328 +++++++++- tests/test_access_http.py | 360 ++++++++++- tests/test_api_contract.py | 36 +- tests/test_client.py | 54 +- tests/test_dashboard.py | 3 + tests/test_server.py | 39 +- uv.lock | 49 +- 49 files changed, 5278 insertions(+), 711 deletions(-) create mode 100644 src/powercontext/server/authz/authzen.py create mode 100644 src/powercontext/server/authz/casbin.py create mode 100644 src/powercontext/server/authz/profiles.py create mode 100644 tests/e2e/real_experience_skill/test_access_control.py create mode 100644 tests/e2e/test_access_control_http.py create mode 100644 tests/test_access_adapters.py diff --git a/.env.example b/.env.example index 4cff4556a..e0e27c4af 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,12 @@ POWERCONTEXT_SERVER_MCP_PATH=/mcp POWERCONTEXT_SERVER_AUTH_ENABLED=false # POWERCONTEXT_SERVER_AUTH_TOKEN=replace-me +# Access Control -------------------------------------------------------------- +# Use enforced only with an authentication provider that establishes a distinct Principal per caller. +POWERCONTEXT_SERVER_ACCESS_MODE=legacy-static-admin +POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=powercontext +POWERCONTEXT_SERVER_ACCESS_BOOTSTRAP_STATIC_PRINCIPAL=true + # Dashboard ------------------------------------------------------------------- # Every Coding Agent below uses this same Scope ID. POWERCONTEXT_SERVER_DASHBOARD_ENABLED=true diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index 240a2a4c7..75892a128 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -57,6 +57,7 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_AUTH_TOKEN` | unset | Static bearer token; required when authentication is enabled | | `POWERCONTEXT_SERVER_ACCESS_MODE` | `legacy-static-admin` | Authorization rollout: `disabled`, `legacy-static-admin`, or `enforced` | | `POWERCONTEXT_SERVER_ACCESS_BOOTSTRAP_STATIC_PRINCIPAL` | `true` | Treat the deployment-local static-token Principal as a bootstrap Server administrator | +| `POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID` | `powercontext` | Stable deployment identity used by the `server` Access Resource and static Principal issuer | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | Opt in to a non-loopback bind while authentication is disabled | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | Enable the Dashboard at the Server root path `/` | | `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | JSON array of selectable Dashboard scopes | @@ -104,11 +105,26 @@ multi-user authentication and Authorization Provider. Set `bootstrap_static_prin administrator relationship is available. `disabled` bypasses authorization decisions and is intended only for an explicit compatibility rollback inside an already trusted network boundary. +Remote, multi-user, and shared-Dashboard deployments must use `enforced`. In that mode, HTTP, MCP, Dashboard data +routes, and metrics share one Server PEP. Configured Dashboard scopes are filtered by the current Principal's +`scope.read` decision before they are returned. `/v1/access/me` reports the `server`/`scope`/`artifact` Resource Kinds, +Provider batch/list/relationship capabilities, Artifact Family profiles, and whether this deployment has a managed +Skill publication operation protected by both required actions. + The built-in Access schema uses the configured SQLite, seekDB, or OceanBase backend, but remains Server-owned rather -than becoming a Runtime domain. A custom deployment can inject an `AccessControlService` into `create_server_app` and -implement the `AuthorizationProvider` and `RelationshipWriter` protocols with OpenFGA, Casbin, Oso, or another policy -system. Its authentication middleware must bind an opaque `PrincipalRef`; `scope_id` is only a resource partition and -never establishes identity. +than becoming a Runtime domain. A custom deployment can inject an `AccessControlService` into `create_server_app`. +`CasbinAuthorizationProvider` is the included writable external adapter: it evaluates the fixed action vocabulary in +embedded Casbin while using the canonical Binding Store as its persistent adapter, so it supports point/batch checks, +safe resource filters, create/revoke, expiry, and CAS without a second policy shadow. Pass that provider as both the +decision provider and `relationships`, and retain the relational repository as the audit store. + +`AuthZenAuthorizationProvider` is an included decision-only adapter for the OpenID AuthZEN Authorization API 1.0 +`evaluation` and `evaluations` endpoints. Configure its capabilities with `multi_requirement_check=true`, +`relationship_management=false`, and `safe_resource_filtering=false`; self-service Binding mutation and authorized +resource listing then return 503 instead of claiming an unsafe capability. The adapter accepts HTTPS endpoints or +loopback HTTP, rejects credentials embedded in URLs, and does not expose PDP response bodies or errors. An +authentication middleware must still bind an opaque `PrincipalRef`; `scope_id` is only a resource partition and never +establishes identity. The Python Client and CLI apply the matching rule for outbound requests: a configured unencrypted `http://` Server URL is accepted only for loopback hosts. The Client refuses to send any request, authenticated or not, over diff --git a/docs/en/docs/reference/http-api.md b/docs/en/docs/reference/http-api.md index 88aa039b1..79602b656 100644 --- a/docs/en/docs/reference/http-api.md +++ b/docs/en/docs/reference/http-api.md @@ -107,11 +107,10 @@ curl --fail \ --data '{ "subject": {"type": "user", "issuer": "https://id.example", "id": "user-b"}, "resource": { - "type": "handoff", + "type": "artifact", "scope_id": "project:example", - "family": "handoff", - "artifact_id": "handoff-42", - "revision": 3 + "reference": {"family": "handoff", "artifact_id": "handoff-42", "revision": 3}, + "selector": null }, "role": "handoff.receiver", "idempotency_key": "handoff-42-r3-to-user-b" @@ -126,6 +125,17 @@ verify which Principal the deployment established, `/v1/access/check` for one de grantor and key; revocation uses `binding_id` plus `expected_version`. Relationship and decision events are available to Server administrators through `/v1/access/audit/list`. +The Access wire contract has only three Resource Kinds: `server`, `scope`, and `artifact`. An Artifact `reference` +must identify one exact Revision. Memory also requires a complete `memory_entry` selector containing `entry_id` and +`entry_version_id`. Unknown Families, `prompt` when no Prompt lifecycle is implemented, mismatched selectors or roles, +and `latest` never create a Binding. `/v1/access/me` reports the current mode, Provider capabilities, and each Artifact +Family's enabled state. + +Reading a managed Skill and publishing it are separate permissions. Both `/v1/skills/publication-targets/list` and +`/v1/skills/publish` require `artifact.read` plus `skill.publish` on the same exact Skill Revision. Requests submit only +an opaque `target_id`; public responses and errors omit host paths, Agent homes, credentials, and locators. Detailed +Dashboard publication status is separately protected by `server.observe`. + The built-in static token represents one local administrator and cannot model different A/B users. A real multi-user deployment must authenticate each caller to a different Principal and inject an Authorization Provider. HTTP and MCP use the same policy enforcement point; MCP tool visibility is not permission. @@ -140,7 +150,7 @@ use the same policy enforcement point; MCP tool visibility is not permission. | Work continuity | `/v1/work/*` | Create work contracts, prepare or acknowledge Handoffs, and record outcomes | | Low-level Handoff | `/v1/handoff/*` | Activate, prepare, finalize, commit, or continue a Handoff | | Memory | `/v1/memory/*` | Flush, remember, search, list, get, revise, retire, and inspect changes | -| Experience and Skill | `/v1/experience/*`, `/v1/skill/*` | Propose, generate, and read Artifact revisions | +| Experience and Skill | `/v1/experience/*`, `/v1/skill/*`, `/v1/skills/*` | Propose, generate, read Artifact revisions, and publish managed Skills under dual authorization | | Review | `/v1/artifact-candidates/*` | List, inspect, revise, approve, or reject pending Candidates | | External Skills | `/v1/external-skills/*` | Scan configured targets and resolve or import packages | | Handoff Reports | `/v1/handoff-reports/*` | Manage Projects, Workstreams, activities, reports, and workspace bindings | diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index a115cab34..bf4902aca 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -54,6 +54,7 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_AUTH_TOKEN` | 未设置 | 静态 Bearer token;启用鉴权时必须设置 | | `POWERCONTEXT_SERVER_ACCESS_MODE` | `legacy-static-admin` | 权限启用模式:`disabled`、`legacy-static-admin` 或 `enforced` | | `POWERCONTEXT_SERVER_ACCESS_BOOTSTRAP_STATIC_PRINCIPAL` | `true` | 是否把部署本地静态 token 的 Principal 作为初始 Server 管理员 | +| `POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID` | `powercontext` | `server` Access Resource 与静态 Principal issuer 使用的稳定部署标识 | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | 在鉴权关闭时显式允许绑定非 loopback 地址 | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | 在 Server 根路径 `/` 启用 Dashboard | | `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | Dashboard 可选择的 scope JSON 数组 | @@ -98,10 +99,22 @@ Server 管理员,以保持单用户本地部署的兼容行为。`enforced` 多用户 authentication 与 Authorization Provider 使用。在已有其他管理员关系后,可设置 `bootstrap_static_principal=false`。`disabled` 会跳过授权决策,只应作为可信网络边界内的显式兼容回退。 +远程、多用户或共享 Dashboard 必须使用 `enforced`。此模式下,HTTP、MCP、Dashboard 数据路由和 metrics 共用同一个 +Server PEP;Dashboard 配置的 scope 会在返回前按当前 Principal 的 `scope.read` 判定过滤。`/v1/access/me` 返回 +`server`/`scope`/`artifact` Resource Kind、Provider 的 batch/list/relationship 能力、Family profile,以及当前部署是否 +具备受双重授权保护的 managed Skill publication operation。 + 内置 Access schema 使用配置好的 SQLite、seekDB 或 OceanBase,但由 Server 独立持有,不进入 Runtime 领域。自定义部署 -可以向 `create_server_app` 注入 `AccessControlService`,并用 OpenFGA、Casbin、Oso 或其他策略系统实现 -`AuthorizationProvider` 与 `RelationshipWriter` protocol。authentication middleware 必须绑定不透明的 -`PrincipalRef`;`scope_id` 只用于资源分区,不能建立身份。 +可以向 `create_server_app` 注入 `AccessControlService`。内置的可写外部 adapter `CasbinAuthorizationProvider` 使用 +embedded Casbin 判定固定 action vocabulary,并把 canonical Binding Store 作为持久化 adapter,因此在不维护第二份影子 +策略的前提下支持 point/batch check、safe resource filter、create/revoke、过期和 CAS。组装时将它同时作为 decision +provider 与 `relationships`,relational repository 仍作为 audit store。 + +`AuthZenAuthorizationProvider` 是对接 OpenID AuthZEN Authorization API 1.0 `evaluation`/`evaluations` endpoint 的 +decision-only adapter。其 capability 应配置为 `multi_requirement_check=true`、`relationship_management=false` 和 +`safe_resource_filtering=false`;此时 self-service Binding mutation 和授权资源列表会返回 503,而不会虚报不安全的能力。 +该 adapter 只接受 HTTPS endpoint 或 loopback HTTP,拒绝 URL 内嵌 credential,也不会把 PDP response body 或原始错误 +暴露出去。authentication middleware 仍必须绑定不透明的 `PrincipalRef`;`scope_id` 只用于资源分区,不能建立身份。 Python Client 和 CLI 对出站请求应用相同规则:配置的明文 `http://` Server URL 仅接受 loopback 主机,并且 Client 拒绝 通过明文的非 loopback HTTP 发送任何请求,无论是否携带 Bearer token。当代码的 `http://` base URL 只是路由标签、 diff --git a/docs/zh/docs/reference/http-api.md b/docs/zh/docs/reference/http-api.md index d06dec02c..7f8004c52 100644 --- a/docs/zh/docs/reference/http-api.md +++ b/docs/zh/docs/reference/http-api.md @@ -100,11 +100,10 @@ curl --fail \ --data '{ "subject": {"type": "user", "issuer": "https://id.example", "id": "user-b"}, "resource": { - "type": "handoff", + "type": "artifact", "scope_id": "project:example", - "family": "handoff", - "artifact_id": "handoff-42", - "revision": 3 + "reference": {"family": "handoff", "artifact_id": "handoff-42", "revision": 3}, + "selector": null }, "role": "handoff.receiver", "idempotency_key": "handoff-42-r3-to-user-b" @@ -117,6 +116,15 @@ curl --fail \ 用 `/v1/access/resources/list` 非发现式地列出已经可见的资源。创建操作按授权者与幂等键保证幂等;撤销时必须提交 `binding_id` 和 `expected_version`。Server 管理员可通过 `/v1/access/audit/list` 查看关系变更与决策事件。 +Access wire contract 只使用 `server`、`scope` 和 `artifact` 三种 Resource Kind。Artifact 的 `reference` 必须指向精确 +Revision;Memory 还必须提供完整 `memory_entry` selector(`entry_id` 和 `entry_version_id`)。未知 Family、未实现 +Prompt lifecycle 的 `prompt`、不匹配的 selector/role 或 `latest` 都不会创建 Binding。`/v1/access/me` 会报告当前 mode、 +Provider 能力和每个 Artifact Family 的启用状态。 + +读取一个 managed Skill 与发布它是两项权限。`/v1/skills/publication-targets/list` 和 `/v1/skills/publish` 都要求同一个 +精确 Skill Revision 上的 `artifact.read` 与 `skill.publish`。请求只提交不透明的 `target_id`;公共响应和错误不返回 +host path、Agent home、credential 或 locator。详细 Dashboard publication status 另由 `server.observe` 保护。 + 内置静态 token 只代表一个本地管理员,无法表达不同的 A/B 用户。真正的多用户部署必须把每个调用者认证为不同的 Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策略执行点;MCP tool 可见不等于有权限。 @@ -130,7 +138,7 @@ Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策 | 工作连续性 | `/v1/work/*` | 创建 Work Contract、准备或确认 Handoff、记录 Outcome | | 底层 Handoff | `/v1/handoff/*` | activate、prepare、finalize、commit 或 continue Handoff | | Memory | `/v1/memory/*` | flush、remember、search、list、get、revise、retire 和查看变更 | -| Experience 与 Skill | `/v1/experience/*`、`/v1/skill/*` | propose、generate 和读取 Artifact Revision | +| Experience 与 Skill | `/v1/experience/*`、`/v1/skill/*`、`/v1/skills/*` | propose、generate、读取 Artifact Revision 和受控发布 managed Skill | | 审核 | `/v1/artifact-candidates/*` | 列出、检查、修订、批准或拒绝 pending Candidate | | 外部 Skill | `/v1/external-skills/*` | 扫描已配置 target,解析或导入 package | | Handoff Report | `/v1/handoff-reports/*` | 管理 Project、Workstream、activity、report 和 workspace binding | diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 660e09cc1..59a0bbb12 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -249,6 +249,18 @@ const OPERATIONS = { location: "body", scope: true }, + list_skill_publication_targets: { + method: "POST", + path: "/v1/skills/publication-targets/list", + location: "body", + scope: true + }, + publish_managed_skill: { + method: "POST", + path: "/v1/skills/publish", + location: "body", + scope: true + }, scan_external_skills: { method: "POST", path: "/v1/external-skills/scan", @@ -451,7 +463,7 @@ const OPERATIONS = { method: "POST", path: "/v1/access/audit/list", location: "body", - scope: false + scope: true } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index b7cfeb9d0..b549d8ffd 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -67,7 +67,7 @@ paths: tags: [capabilities] summary: Get runtime capabilities operationId: get_capabilities - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} responses: "200": description: Behavior enabled by the assembled runtime. @@ -88,7 +88,7 @@ paths: summary: Capture durable ContentSource evidence description: Accept raw content as an idempotent Source without synchronously deriving Artifacts. operationId: capture_content_source - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -123,7 +123,7 @@ paths: summary: Prepare bounded context for an Agent turn description: Prepare final, ephemeral context from Runtime-owned sources without persisting or injecting it. operationId: prepare_context - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -156,7 +156,7 @@ paths: summary: Create a grounded Work Contract description: Persist an inspectable delegation baseline without granting execution authority. operationId: create_work_contract - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -193,7 +193,7 @@ paths: summary: Hand off current work in one high-level operation description: Capture an inspected boundary and prepare a temporary evidence-bearing Handoff without committing it. operationId: handoff_current_work - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -231,9 +231,7 @@ paths: description: Re-resolve one prepared or exact Handoff, check evidence, and capture the receiver's explicit live-state, capability, and authorization checks. operationId: acknowledge_handoff x-powercontext-access: - action: scope.contribute - resource: scope - resolver: acknowledge_handoff + resolver: acknowledge_handoff_access requestBody: required: true content: @@ -270,7 +268,7 @@ paths: summary: Record a completion-aware Task Outcome description: Preserve one attempt's status and checks, optionally linked to the exact accepted Handoff Receipt that the result covers. operationId: record_task_outcome - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -307,7 +305,7 @@ paths: summary: Activate Handoff generation at a Source boundary description: Evaluate the standard Handoff Trigger and synchronously execute any emitted PrepareHandoff Action. operationId: activate_handoff - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -341,7 +339,7 @@ paths: tags: [handoff] summary: Generate an inspectable Handoff Draft operationId: prepare_handoff - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -375,7 +373,7 @@ paths: tags: [handoff] summary: Finalize an inspected Handoff Draft operationId: finalize_handoff - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -409,7 +407,7 @@ paths: tags: [handoff] summary: Commit an explicit Handoff milestone operationId: commit_handoff - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -446,9 +444,7 @@ paths: summary: Resolve a Handoff as untrusted historical input operationId: continue_handoff x-powercontext-access: - action: scope.read - resource: scope - resolver: continue_handoff + resolver: continue_handoff_access requestBody: required: true content: @@ -483,7 +479,7 @@ paths: summary: Process the pending Source window into Memory description: Run one bounded Source-to-Memory activation for operational control and testing. operationId: flush_memory - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -516,7 +512,7 @@ paths: summary: Remember explicit Memory content description: Save one already-curated Memory entry without creating a Source or invoking extraction. operationId: remember_memory - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -551,7 +547,7 @@ paths: summary: Search active Memory entries description: Retrieve relevant active Memory entries within one explicit application scope. operationId: search_memory - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -588,7 +584,7 @@ paths: Read active entries from the current Memory head. Inactive entries are available only when explicitly requested for audit. operationId: list_memory_entries - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -623,7 +619,7 @@ paths: summary: Get an exact Memory entry version description: Resolve an immutable entry citation within one Memory Revision. operationId: get_memory_entry - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {resolver: exact_memory_access} requestBody: required: true content: @@ -658,7 +654,7 @@ paths: summary: Revise an exact Memory entry description: Replace active entry content against an explicit current Memory Revision. operationId: revise_memory_entry - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -695,7 +691,7 @@ paths: summary: Retire an exact Memory entry description: Deactivate an entry against an explicit current Memory Revision without deleting history. operationId: retire_memory_entry - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -732,7 +728,7 @@ paths: summary: List Memory Revision changes description: Read compact entry changes without expanding entry bodies. operationId: list_memory_changes - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -767,7 +763,7 @@ paths: summary: Propose Experience content description: Persist a pending Experience Candidate without creating an Artifact Revision. operationId: propose_experience - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -802,7 +798,7 @@ paths: summary: Generate an Experience Candidate description: Use the configured model and caller-selected exact evidence; persist only a schema-valid pending Candidate. operationId: generate_experience - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -837,7 +833,7 @@ paths: summary: Get an exact Experience Revision description: Read approved Experience content and its exact direct evidence. operationId: get_experience - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {resolver: exact_experience_access} requestBody: required: true content: @@ -872,7 +868,7 @@ paths: summary: Propose managed Skill content description: Persist a pending managed Skill Candidate without creating an Artifact Revision. operationId: propose_skill - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -907,7 +903,7 @@ paths: summary: Generate a managed Skill Candidate description: Use the configured model with an explicit provenance shape; persist only a schema-valid pending Candidate. operationId: generate_skill - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -942,7 +938,7 @@ paths: summary: Get an exact managed Skill Revision description: Read approved managed Skill content and its exact direct evidence. operationId: get_skill - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {resolver: exact_skill_access} requestBody: required: true content: @@ -971,13 +967,85 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/skills/publication-targets/list: + post: + tags: [skill] + summary: List safe publication targets for an exact managed Skill + description: Return only enabled opaque host-local targets after the exact Skill read and publish checks both allow. + operationId: list_skill_publication_targets + x-powercontext-access: {resolver: publish_managed_skill_access} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListSkillPublicationTargetsRequest" + responses: + "200": + description: Enabled publication targets without host paths, locators, or credentials. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ListSkillPublicationTargetsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/skills/publish: + post: + tags: [skill] + summary: Publish an exact managed Skill to one configured target + description: Publish only after artifact.read and skill.publish both allow; target_id is resolved after authorization. + operationId: publish_managed_skill + x-powercontext-access: {resolver: publish_managed_skill_access} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishManagedSkillRequest" + responses: + "200": + description: Safe publication result for the selected exact Revision and opaque target. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedSkillPublication" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" /v1/external-skills/scan: post: tags: [skill] summary: Scan configured external Skill roots description: Replace the current host-local Registry projection without copying or rewriting package content. operationId: scan_external_skills - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1010,7 +1078,7 @@ paths: summary: List external Skills visible on this host description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. operationId: list_external_skills - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1043,7 +1111,7 @@ paths: summary: Resolve an exact external Skill fingerprint description: Resolve only the registered local package version requested by the caller; never install or fall back. operationId: resolve_external_skill - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1078,7 +1146,7 @@ paths: summary: Import or fork an external Skill into Review description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. operationId: import_external_skill - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1115,7 +1183,7 @@ paths: summary: List Artifact Candidates description: Page current Candidate heads; pending is the default Review Inbox view. operationId: list_artifact_candidates - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1148,7 +1216,7 @@ paths: summary: Get an Artifact Candidate description: Read the current head and exact immutable proposal version. operationId: get_artifact_candidate - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1183,7 +1251,7 @@ paths: summary: Approve an Artifact Candidate description: Commit the reviewed proposal and mark the Candidate approved in one transaction. operationId: approve_artifact_candidate - x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.review, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1220,7 +1288,7 @@ paths: summary: Reject an Artifact Candidate description: Move the exact pending version to its rejected terminal state without writing an Artifact. operationId: reject_artifact_candidate - x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.review, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1257,7 +1325,7 @@ paths: summary: Revise an Artifact Candidate description: Append a complete replacement proposal as the next immutable pending version. operationId: revise_artifact_candidate - x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.review, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1293,7 +1361,7 @@ paths: tags: [stats] summary: Get scoped product statistics operationId: get_stats - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} parameters: - name: scope_id in: query @@ -1338,7 +1406,7 @@ paths: tags: [handoff-reports] summary: Create a Handoff Report Project operationId: create_handoff_report_project - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1370,7 +1438,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Projects operationId: list_handoff_report_projects - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1400,7 +1468,7 @@ paths: tags: [handoff-reports] summary: List scopes that contain a committed Handoff operationId: list_handoff_report_known_scopes - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1430,7 +1498,7 @@ paths: tags: [handoff-reports] summary: Get a Handoff Report Project operationId: get_handoff_report_project - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1462,7 +1530,7 @@ paths: tags: [handoff-reports] summary: Update a Handoff Report Project operationId: update_handoff_report_project - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1496,7 +1564,7 @@ paths: tags: [handoff-reports] summary: Register a Handoff Report Workstream operationId: register_handoff_report_workstream - x-powercontext-access: {action: scope.admin, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.admin, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1530,7 +1598,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Workstreams operationId: list_handoff_report_workstreams - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1562,7 +1630,7 @@ paths: tags: [handoff-reports] summary: Update a Handoff Report Workstream operationId: update_handoff_report_workstream - x-powercontext-access: {action: scope.admin, resource: scope, scope_id_field: workstream.scope_id} + x-powercontext-access: {action: scope.admin, resource: {type: scope, scope-id-from: workstream.scope_id}} requestBody: required: true content: @@ -1596,7 +1664,7 @@ paths: tags: [handoff-reports] summary: Generate a Handoff Report operationId: get_handoff_report - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1654,7 +1722,7 @@ paths: tags: [handoff-reports] summary: Record a Handoff Report Activity operationId: record_handoff_report_activity - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1688,7 +1756,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Activities operationId: list_handoff_report_activities - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1720,7 +1788,7 @@ paths: tags: [handoff-reports] summary: Purge Handoff Report Activities operationId: purge_handoff_report_activities - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1752,7 +1820,7 @@ paths: tags: [handoff-reports] summary: Get a Handoff Report Workspace Binding operationId: get_handoff_report_workspace - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1784,7 +1852,7 @@ paths: tags: [handoff-reports] summary: Attach a Handoff Report Workspace Binding operationId: attach_handoff_report_workspace - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1818,7 +1886,7 @@ paths: tags: [handoff-reports] summary: Detach a Handoff Report Workspace Binding operationId: detach_handoff_report_workspace - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1850,16 +1918,16 @@ paths: /v1/access/me: get: tags: [access] - summary: Get the authenticated Principal + summary: Get the authenticated Principal and Access capabilities operationId: get_access_principal - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} responses: "200": - description: The opaque Principal established by the authentication adapter. + description: The opaque Principal and enforceable deployment Access capabilities. content: application/json: schema: - $ref: "#/components/schemas/AccessPrincipal" + $ref: "#/components/schemas/AccessMeResponse" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -1871,7 +1939,7 @@ paths: tags: [access] summary: Check one authorization decision operationId: check_access - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -1898,7 +1966,7 @@ paths: tags: [access] summary: Check a bounded batch of authorization decisions operationId: check_access_batch - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -1925,7 +1993,7 @@ paths: tags: [access] summary: List only resources already visible to the Principal operationId: list_access_resources - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -1952,7 +2020,7 @@ paths: tags: [access] summary: List stable built-in role definitions operationId: list_access_roles - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -1977,7 +2045,7 @@ paths: tags: [access] summary: List Access Bindings under an administrative boundary operationId: list_access_bindings - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -2004,7 +2072,7 @@ paths: tags: [access] summary: Create an idempotent Access Binding operationId: create_access_binding - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -2033,7 +2101,7 @@ paths: tags: [access] summary: Revoke an Access Binding using compare-and-swap operationId: revoke_access_binding - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -2062,7 +2130,7 @@ paths: tags: [access] summary: List data-minimized Access audit events operationId: list_access_audit - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {resolver: access_audit_access} requestBody: required: true content: @@ -2184,10 +2252,78 @@ components: type: {type: string, minLength: 1, maxLength: 64} issuer: {type: string, minLength: 1, maxLength: 255} id: {type: string, minLength: 1, maxLength: 255} + AccessControlMode: + type: string + enum: [legacy-static-admin, enforced] + AccessProviderCapabilities: + type: object + additionalProperties: false + required: [safe_resource_filtering, multi_requirement_check, relationship_management] + properties: + safe_resource_filtering: {type: boolean} + multi_requirement_check: {type: boolean} + relationship_management: {type: boolean} + ArtifactFamilyAccessCapability: + type: object + additionalProperties: false + required: [family, enabled, share_unit, actions, grantable_roles] + properties: + family: {type: string, minLength: 1, maxLength: 128} + enabled: {type: boolean} + share_unit: + type: string + enum: [revision, memory_entry] + actions: + type: array + items: + $ref: "#/components/schemas/AccessAction" + grantable_roles: + type: array + items: + $ref: "#/components/schemas/AccessRole" + AccessOperationCapability: + type: object + additionalProperties: false + required: [enabled] + properties: + enabled: {type: boolean} + AccessOperationCapabilities: + type: object + additionalProperties: false + required: [skill_publication] + properties: + skill_publication: + $ref: "#/components/schemas/AccessOperationCapability" + AccessMeResponse: + type: object + additionalProperties: false + required: + - principal + - mode + - resource_kinds + - provider_capabilities + - artifact_families + - operation_capabilities + properties: + principal: + $ref: "#/components/schemas/AccessPrincipal" + mode: + $ref: "#/components/schemas/AccessControlMode" + resource_kinds: + type: array + items: + $ref: "#/components/schemas/AccessResourceType" + provider_capabilities: + $ref: "#/components/schemas/AccessProviderCapabilities" + artifact_families: + type: array + items: + $ref: "#/components/schemas/ArtifactFamilyAccessCapability" + operation_capabilities: + $ref: "#/components/schemas/AccessOperationCapabilities" AccessAction: type: string enum: - - access.self - server.observe - server.admin - scope.read @@ -2195,23 +2331,60 @@ components: - scope.review - scope.delegate - scope.admin - - handoff.read + - artifact.read - handoff.evidence.read - handoff.acknowledge + - prompt.use + - skill.publish AccessResourceType: type: string - enum: [server, scope, handoff] - AccessResource: + enum: [server, scope, artifact] + ServerAccessResource: type: object additionalProperties: false - required: [type] + required: [type, deployment_id] properties: - type: - $ref: "#/components/schemas/AccessResourceType" - scope_id: {type: string, minLength: 1, maxLength: 256, nullable: true} - family: {type: string, minLength: 1, maxLength: 64, nullable: true} - artifact_id: {type: string, minLength: 1, maxLength: 256, nullable: true} - revision: {type: integer, minimum: 1, nullable: true} + type: {type: string, enum: [server]} + deployment_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + ScopeAccessResource: + type: object + additionalProperties: false + required: [type, scope_id] + properties: + type: {type: string, enum: [scope]} + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + MemoryEntryAccessSelector: + type: object + additionalProperties: false + required: [type, entry_id, entry_version_id] + properties: + type: {type: string, enum: [memory_entry]} + entry_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + entry_version_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + ArtifactAccessResource: + type: object + additionalProperties: false + required: [type, scope_id, reference, selector] + properties: + type: {type: string, enum: [artifact]} + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + reference: + $ref: "#/components/schemas/ArtifactReference" + selector: + allOf: + - $ref: "#/components/schemas/MemoryEntryAccessSelector" + nullable: true + AccessResource: + oneOf: + - $ref: "#/components/schemas/ServerAccessResource" + - $ref: "#/components/schemas/ScopeAccessResource" + - $ref: "#/components/schemas/ArtifactAccessResource" + discriminator: + propertyName: type + mapping: + server: "#/components/schemas/ServerAccessResource" + scope: "#/components/schemas/ScopeAccessResource" + artifact: "#/components/schemas/ArtifactAccessResource" AccessDecision: type: object additionalProperties: false @@ -2259,24 +2432,29 @@ components: $ref: "#/components/schemas/AccessAction" resource_type: $ref: "#/components/schemas/AccessResourceType" + family: {type: string, minLength: 1, maxLength: 128, nullable: true} cursor: {type: string, nullable: true} limit: {type: integer, minimum: 1, maximum: 500, default: 100} AccessResourcePage: type: object additionalProperties: false - required: [items, next_cursor] + required: [items, total, next_cursor] properties: items: type: array maxItems: 500 items: $ref: "#/components/schemas/AccessResource" + total: {type: integer, minimum: 0} next_cursor: {type: string, nullable: true} AccessRole: type: string enum: - handoff.viewer - handoff.receiver + - artifact.viewer + - prompt.user + - skill.publisher - scope.viewer - scope.contributor - scope.reviewer @@ -2295,7 +2473,7 @@ components: AccessRoleDescriptor: type: object additionalProperties: false - required: [role, resource_type, actions] + required: [role, resource_type, actions, artifact_families] properties: role: $ref: "#/components/schemas/AccessRole" @@ -2305,6 +2483,9 @@ components: type: array items: $ref: "#/components/schemas/AccessAction" + artifact_families: + type: array + items: {type: string, minLength: 1, maxLength: 128} AccessRolePage: type: object additionalProperties: false @@ -2407,6 +2588,7 @@ components: type: object additionalProperties: false properties: + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*', nullable: true} after: {type: integer, minimum: 0, nullable: true} limit: {type: integer, minimum: 1, maximum: 500, default: 100} AccessAuditEvent: @@ -3746,6 +3928,50 @@ components: type: array items: $ref: "#/components/schemas/ArtifactReference" + AgentKind: + type: string + enum: [codex, claude_code] + SkillPublicationTarget: + type: object + additionalProperties: false + required: [target_id, agent_kind, installation_scope, capabilities] + properties: + target_id: {type: string, minLength: 1, maxLength: 64} + agent_kind: + $ref: "#/components/schemas/AgentKind" + installation_scope: + $ref: "#/components/schemas/ExternalSkillInstallationScope" + capabilities: + type: array + items: + type: string + enum: [publish] + ListSkillPublicationTargetsResponse: + type: object + additionalProperties: false + required: [artifact, targets] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + targets: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/SkillPublicationTarget" + ManagedSkillPublication: + type: object + additionalProperties: false + required: [artifact, target_id, agent_kind, installation_scope, state, applied_revision] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + target_id: {type: string, minLength: 1, maxLength: 64} + agent_kind: + $ref: "#/components/schemas/AgentKind" + installation_scope: + $ref: "#/components/schemas/ExternalSkillInstallationScope" + state: {type: string, enum: [published]} + applied_revision: {type: integer, minimum: 1} SkillProposal: type: object additionalProperties: false @@ -3965,6 +4191,23 @@ components: pattern: '.*\S.*' artifact: $ref: "#/components/schemas/ArtifactReference" + ListSkillPublicationTargetsRequest: + type: object + additionalProperties: false + required: [scope_id, artifact] + properties: + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + artifact: + $ref: "#/components/schemas/ArtifactReference" + PublishManagedSkillRequest: + type: object + additionalProperties: false + required: [scope_id, artifact, target_id] + properties: + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + artifact: + $ref: "#/components/schemas/ArtifactReference" + target_id: {type: string, minLength: 1, maxLength: 64, pattern: '^[\x21-\x7E]+$'} CreateHandoffReportProjectRequest: type: object additionalProperties: false diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 49d191ee1..b813ffd1d 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -45,6 +45,8 @@ export const OPERATIONS = { propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, + list_skill_publication_targets: { method: 'POST', path: '/v1/skills/publication-targets/list', location: "body", scope: true }, + publish_managed_skill: { method: 'POST', path: '/v1/skills/publish', location: "body", scope: true }, scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, @@ -78,7 +80,7 @@ export const OPERATIONS = { list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: true }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 67358051b..ae847bec2 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -236,6 +236,18 @@ const OPERATIONS = { location: "body", scope: true }, + list_skill_publication_targets: { + method: "POST", + path: "/v1/skills/publication-targets/list", + location: "body", + scope: true + }, + publish_managed_skill: { + method: "POST", + path: "/v1/skills/publish", + location: "body", + scope: true + }, scan_external_skills: { method: "POST", path: "/v1/external-skills/scan", @@ -308,6 +320,12 @@ const OPERATIONS = { location: "body", scope: false }, + list_handoff_report_known_scopes: { + method: "POST", + path: "/v1/handoff-reports/scopes/list-known", + location: "body", + scope: false + }, get_handoff_report_project: { method: "POST", path: "/v1/handoff-reports/projects/get", @@ -342,7 +360,7 @@ const OPERATIONS = { method: "POST", path: "/v1/handoff-reports/get", location: "body", - scope: false + scope: true }, record_handoff_report_activity: { method: "POST", @@ -379,6 +397,60 @@ const OPERATIONS = { path: "/v1/handoff-reports/workspace-bindings/detach", location: "body", scope: false + }, + get_access_principal: { + method: "GET", + path: "/v1/access/me", + location: null, + scope: false + }, + check_access: { + method: "POST", + path: "/v1/access/check", + location: "body", + scope: false + }, + check_access_batch: { + method: "POST", + path: "/v1/access/check-batch", + location: "body", + scope: false + }, + list_access_resources: { + method: "POST", + path: "/v1/access/resources/list", + location: "body", + scope: false + }, + list_access_roles: { + method: "POST", + path: "/v1/access/roles/list", + location: "body", + scope: false + }, + list_access_bindings: { + method: "POST", + path: "/v1/access/bindings/list", + location: "body", + scope: false + }, + create_access_binding: { + method: "POST", + path: "/v1/access/bindings/create", + location: "body", + scope: false + }, + revoke_access_binding: { + method: "POST", + path: "/v1/access/bindings/revoke", + location: "body", + scope: false + }, + list_access_audit: { + method: "POST", + path: "/v1/access/audit/list", + location: "body", + scope: true } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 49d191ee1..b813ffd1d 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -45,6 +45,8 @@ export const OPERATIONS = { propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, + list_skill_publication_targets: { method: 'POST', path: '/v1/skills/publication-targets/list', location: "body", scope: true }, + publish_managed_skill: { method: 'POST', path: '/v1/skills/publish', location: "body", scope: true }, scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, @@ -78,7 +80,7 @@ export const OPERATIONS = { list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: true }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 49d191ee1..b813ffd1d 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -45,6 +45,8 @@ export const OPERATIONS = { propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, + list_skill_publication_targets: { method: 'POST', path: '/v1/skills/publication-targets/list', location: "body", scope: true }, + publish_managed_skill: { method: 'POST', path: '/v1/skills/publish', location: "body", scope: true }, scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, @@ -78,7 +80,7 @@ export const OPERATIONS = { list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: true }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index b7cfeb9d0..b549d8ffd 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -67,7 +67,7 @@ paths: tags: [capabilities] summary: Get runtime capabilities operationId: get_capabilities - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} responses: "200": description: Behavior enabled by the assembled runtime. @@ -88,7 +88,7 @@ paths: summary: Capture durable ContentSource evidence description: Accept raw content as an idempotent Source without synchronously deriving Artifacts. operationId: capture_content_source - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -123,7 +123,7 @@ paths: summary: Prepare bounded context for an Agent turn description: Prepare final, ephemeral context from Runtime-owned sources without persisting or injecting it. operationId: prepare_context - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -156,7 +156,7 @@ paths: summary: Create a grounded Work Contract description: Persist an inspectable delegation baseline without granting execution authority. operationId: create_work_contract - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -193,7 +193,7 @@ paths: summary: Hand off current work in one high-level operation description: Capture an inspected boundary and prepare a temporary evidence-bearing Handoff without committing it. operationId: handoff_current_work - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -231,9 +231,7 @@ paths: description: Re-resolve one prepared or exact Handoff, check evidence, and capture the receiver's explicit live-state, capability, and authorization checks. operationId: acknowledge_handoff x-powercontext-access: - action: scope.contribute - resource: scope - resolver: acknowledge_handoff + resolver: acknowledge_handoff_access requestBody: required: true content: @@ -270,7 +268,7 @@ paths: summary: Record a completion-aware Task Outcome description: Preserve one attempt's status and checks, optionally linked to the exact accepted Handoff Receipt that the result covers. operationId: record_task_outcome - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -307,7 +305,7 @@ paths: summary: Activate Handoff generation at a Source boundary description: Evaluate the standard Handoff Trigger and synchronously execute any emitted PrepareHandoff Action. operationId: activate_handoff - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -341,7 +339,7 @@ paths: tags: [handoff] summary: Generate an inspectable Handoff Draft operationId: prepare_handoff - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -375,7 +373,7 @@ paths: tags: [handoff] summary: Finalize an inspected Handoff Draft operationId: finalize_handoff - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -409,7 +407,7 @@ paths: tags: [handoff] summary: Commit an explicit Handoff milestone operationId: commit_handoff - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -446,9 +444,7 @@ paths: summary: Resolve a Handoff as untrusted historical input operationId: continue_handoff x-powercontext-access: - action: scope.read - resource: scope - resolver: continue_handoff + resolver: continue_handoff_access requestBody: required: true content: @@ -483,7 +479,7 @@ paths: summary: Process the pending Source window into Memory description: Run one bounded Source-to-Memory activation for operational control and testing. operationId: flush_memory - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -516,7 +512,7 @@ paths: summary: Remember explicit Memory content description: Save one already-curated Memory entry without creating a Source or invoking extraction. operationId: remember_memory - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -551,7 +547,7 @@ paths: summary: Search active Memory entries description: Retrieve relevant active Memory entries within one explicit application scope. operationId: search_memory - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -588,7 +584,7 @@ paths: Read active entries from the current Memory head. Inactive entries are available only when explicitly requested for audit. operationId: list_memory_entries - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -623,7 +619,7 @@ paths: summary: Get an exact Memory entry version description: Resolve an immutable entry citation within one Memory Revision. operationId: get_memory_entry - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {resolver: exact_memory_access} requestBody: required: true content: @@ -658,7 +654,7 @@ paths: summary: Revise an exact Memory entry description: Replace active entry content against an explicit current Memory Revision. operationId: revise_memory_entry - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -695,7 +691,7 @@ paths: summary: Retire an exact Memory entry description: Deactivate an entry against an explicit current Memory Revision without deleting history. operationId: retire_memory_entry - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -732,7 +728,7 @@ paths: summary: List Memory Revision changes description: Read compact entry changes without expanding entry bodies. operationId: list_memory_changes - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -767,7 +763,7 @@ paths: summary: Propose Experience content description: Persist a pending Experience Candidate without creating an Artifact Revision. operationId: propose_experience - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -802,7 +798,7 @@ paths: summary: Generate an Experience Candidate description: Use the configured model and caller-selected exact evidence; persist only a schema-valid pending Candidate. operationId: generate_experience - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -837,7 +833,7 @@ paths: summary: Get an exact Experience Revision description: Read approved Experience content and its exact direct evidence. operationId: get_experience - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {resolver: exact_experience_access} requestBody: required: true content: @@ -872,7 +868,7 @@ paths: summary: Propose managed Skill content description: Persist a pending managed Skill Candidate without creating an Artifact Revision. operationId: propose_skill - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -907,7 +903,7 @@ paths: summary: Generate a managed Skill Candidate description: Use the configured model with an explicit provenance shape; persist only a schema-valid pending Candidate. operationId: generate_skill - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -942,7 +938,7 @@ paths: summary: Get an exact managed Skill Revision description: Read approved managed Skill content and its exact direct evidence. operationId: get_skill - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {resolver: exact_skill_access} requestBody: required: true content: @@ -971,13 +967,85 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/skills/publication-targets/list: + post: + tags: [skill] + summary: List safe publication targets for an exact managed Skill + description: Return only enabled opaque host-local targets after the exact Skill read and publish checks both allow. + operationId: list_skill_publication_targets + x-powercontext-access: {resolver: publish_managed_skill_access} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListSkillPublicationTargetsRequest" + responses: + "200": + description: Enabled publication targets without host paths, locators, or credentials. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ListSkillPublicationTargetsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/skills/publish: + post: + tags: [skill] + summary: Publish an exact managed Skill to one configured target + description: Publish only after artifact.read and skill.publish both allow; target_id is resolved after authorization. + operationId: publish_managed_skill + x-powercontext-access: {resolver: publish_managed_skill_access} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishManagedSkillRequest" + responses: + "200": + description: Safe publication result for the selected exact Revision and opaque target. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedSkillPublication" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" /v1/external-skills/scan: post: tags: [skill] summary: Scan configured external Skill roots description: Replace the current host-local Registry projection without copying or rewriting package content. operationId: scan_external_skills - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1010,7 +1078,7 @@ paths: summary: List external Skills visible on this host description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. operationId: list_external_skills - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1043,7 +1111,7 @@ paths: summary: Resolve an exact external Skill fingerprint description: Resolve only the registered local package version requested by the caller; never install or fall back. operationId: resolve_external_skill - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1078,7 +1146,7 @@ paths: summary: Import or fork an external Skill into Review description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. operationId: import_external_skill - x-powercontext-access: {action: scope.contribute, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1115,7 +1183,7 @@ paths: summary: List Artifact Candidates description: Page current Candidate heads; pending is the default Review Inbox view. operationId: list_artifact_candidates - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1148,7 +1216,7 @@ paths: summary: Get an Artifact Candidate description: Read the current head and exact immutable proposal version. operationId: get_artifact_candidate - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1183,7 +1251,7 @@ paths: summary: Approve an Artifact Candidate description: Commit the reviewed proposal and mark the Candidate approved in one transaction. operationId: approve_artifact_candidate - x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.review, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1220,7 +1288,7 @@ paths: summary: Reject an Artifact Candidate description: Move the exact pending version to its rejected terminal state without writing an Artifact. operationId: reject_artifact_candidate - x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.review, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1257,7 +1325,7 @@ paths: summary: Revise an Artifact Candidate description: Append a complete replacement proposal as the next immutable pending version. operationId: revise_artifact_candidate - x-powercontext-access: {action: scope.review, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.review, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1293,7 +1361,7 @@ paths: tags: [stats] summary: Get scoped product statistics operationId: get_stats - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} parameters: - name: scope_id in: query @@ -1338,7 +1406,7 @@ paths: tags: [handoff-reports] summary: Create a Handoff Report Project operationId: create_handoff_report_project - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1370,7 +1438,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Projects operationId: list_handoff_report_projects - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1400,7 +1468,7 @@ paths: tags: [handoff-reports] summary: List scopes that contain a committed Handoff operationId: list_handoff_report_known_scopes - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1430,7 +1498,7 @@ paths: tags: [handoff-reports] summary: Get a Handoff Report Project operationId: get_handoff_report_project - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1462,7 +1530,7 @@ paths: tags: [handoff-reports] summary: Update a Handoff Report Project operationId: update_handoff_report_project - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1496,7 +1564,7 @@ paths: tags: [handoff-reports] summary: Register a Handoff Report Workstream operationId: register_handoff_report_workstream - x-powercontext-access: {action: scope.admin, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.admin, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1530,7 +1598,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Workstreams operationId: list_handoff_report_workstreams - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1562,7 +1630,7 @@ paths: tags: [handoff-reports] summary: Update a Handoff Report Workstream operationId: update_handoff_report_workstream - x-powercontext-access: {action: scope.admin, resource: scope, scope_id_field: workstream.scope_id} + x-powercontext-access: {action: scope.admin, resource: {type: scope, scope-id-from: workstream.scope_id}} requestBody: required: true content: @@ -1596,7 +1664,7 @@ paths: tags: [handoff-reports] summary: Generate a Handoff Report operationId: get_handoff_report - x-powercontext-access: {action: scope.read, resource: scope, scope_id_field: scope_id} + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} requestBody: required: true content: @@ -1654,7 +1722,7 @@ paths: tags: [handoff-reports] summary: Record a Handoff Report Activity operationId: record_handoff_report_activity - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1688,7 +1756,7 @@ paths: tags: [handoff-reports] summary: List Handoff Report Activities operationId: list_handoff_report_activities - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1720,7 +1788,7 @@ paths: tags: [handoff-reports] summary: Purge Handoff Report Activities operationId: purge_handoff_report_activities - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1752,7 +1820,7 @@ paths: tags: [handoff-reports] summary: Get a Handoff Report Workspace Binding operationId: get_handoff_report_workspace - x-powercontext-access: {action: server.observe, resource: server} + x-powercontext-access: {action: server.observe, resource: {type: server}} requestBody: required: true content: @@ -1784,7 +1852,7 @@ paths: tags: [handoff-reports] summary: Attach a Handoff Report Workspace Binding operationId: attach_handoff_report_workspace - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1818,7 +1886,7 @@ paths: tags: [handoff-reports] summary: Detach a Handoff Report Workspace Binding operationId: detach_handoff_report_workspace - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {action: server.admin, resource: {type: server}} requestBody: required: true content: @@ -1850,16 +1918,16 @@ paths: /v1/access/me: get: tags: [access] - summary: Get the authenticated Principal + summary: Get the authenticated Principal and Access capabilities operationId: get_access_principal - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} responses: "200": - description: The opaque Principal established by the authentication adapter. + description: The opaque Principal and enforceable deployment Access capabilities. content: application/json: schema: - $ref: "#/components/schemas/AccessPrincipal" + $ref: "#/components/schemas/AccessMeResponse" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -1871,7 +1939,7 @@ paths: tags: [access] summary: Check one authorization decision operationId: check_access - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -1898,7 +1966,7 @@ paths: tags: [access] summary: Check a bounded batch of authorization decisions operationId: check_access_batch - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -1925,7 +1993,7 @@ paths: tags: [access] summary: List only resources already visible to the Principal operationId: list_access_resources - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -1952,7 +2020,7 @@ paths: tags: [access] summary: List stable built-in role definitions operationId: list_access_roles - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -1977,7 +2045,7 @@ paths: tags: [access] summary: List Access Bindings under an administrative boundary operationId: list_access_bindings - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -2004,7 +2072,7 @@ paths: tags: [access] summary: Create an idempotent Access Binding operationId: create_access_binding - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -2033,7 +2101,7 @@ paths: tags: [access] summary: Revoke an Access Binding using compare-and-swap operationId: revoke_access_binding - x-powercontext-access: {action: access.self, resource: server} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -2062,7 +2130,7 @@ paths: tags: [access] summary: List data-minimized Access audit events operationId: list_access_audit - x-powercontext-access: {action: server.admin, resource: server} + x-powercontext-access: {resolver: access_audit_access} requestBody: required: true content: @@ -2184,10 +2252,78 @@ components: type: {type: string, minLength: 1, maxLength: 64} issuer: {type: string, minLength: 1, maxLength: 255} id: {type: string, minLength: 1, maxLength: 255} + AccessControlMode: + type: string + enum: [legacy-static-admin, enforced] + AccessProviderCapabilities: + type: object + additionalProperties: false + required: [safe_resource_filtering, multi_requirement_check, relationship_management] + properties: + safe_resource_filtering: {type: boolean} + multi_requirement_check: {type: boolean} + relationship_management: {type: boolean} + ArtifactFamilyAccessCapability: + type: object + additionalProperties: false + required: [family, enabled, share_unit, actions, grantable_roles] + properties: + family: {type: string, minLength: 1, maxLength: 128} + enabled: {type: boolean} + share_unit: + type: string + enum: [revision, memory_entry] + actions: + type: array + items: + $ref: "#/components/schemas/AccessAction" + grantable_roles: + type: array + items: + $ref: "#/components/schemas/AccessRole" + AccessOperationCapability: + type: object + additionalProperties: false + required: [enabled] + properties: + enabled: {type: boolean} + AccessOperationCapabilities: + type: object + additionalProperties: false + required: [skill_publication] + properties: + skill_publication: + $ref: "#/components/schemas/AccessOperationCapability" + AccessMeResponse: + type: object + additionalProperties: false + required: + - principal + - mode + - resource_kinds + - provider_capabilities + - artifact_families + - operation_capabilities + properties: + principal: + $ref: "#/components/schemas/AccessPrincipal" + mode: + $ref: "#/components/schemas/AccessControlMode" + resource_kinds: + type: array + items: + $ref: "#/components/schemas/AccessResourceType" + provider_capabilities: + $ref: "#/components/schemas/AccessProviderCapabilities" + artifact_families: + type: array + items: + $ref: "#/components/schemas/ArtifactFamilyAccessCapability" + operation_capabilities: + $ref: "#/components/schemas/AccessOperationCapabilities" AccessAction: type: string enum: - - access.self - server.observe - server.admin - scope.read @@ -2195,23 +2331,60 @@ components: - scope.review - scope.delegate - scope.admin - - handoff.read + - artifact.read - handoff.evidence.read - handoff.acknowledge + - prompt.use + - skill.publish AccessResourceType: type: string - enum: [server, scope, handoff] - AccessResource: + enum: [server, scope, artifact] + ServerAccessResource: type: object additionalProperties: false - required: [type] + required: [type, deployment_id] properties: - type: - $ref: "#/components/schemas/AccessResourceType" - scope_id: {type: string, minLength: 1, maxLength: 256, nullable: true} - family: {type: string, minLength: 1, maxLength: 64, nullable: true} - artifact_id: {type: string, minLength: 1, maxLength: 256, nullable: true} - revision: {type: integer, minimum: 1, nullable: true} + type: {type: string, enum: [server]} + deployment_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + ScopeAccessResource: + type: object + additionalProperties: false + required: [type, scope_id] + properties: + type: {type: string, enum: [scope]} + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + MemoryEntryAccessSelector: + type: object + additionalProperties: false + required: [type, entry_id, entry_version_id] + properties: + type: {type: string, enum: [memory_entry]} + entry_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + entry_version_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + ArtifactAccessResource: + type: object + additionalProperties: false + required: [type, scope_id, reference, selector] + properties: + type: {type: string, enum: [artifact]} + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + reference: + $ref: "#/components/schemas/ArtifactReference" + selector: + allOf: + - $ref: "#/components/schemas/MemoryEntryAccessSelector" + nullable: true + AccessResource: + oneOf: + - $ref: "#/components/schemas/ServerAccessResource" + - $ref: "#/components/schemas/ScopeAccessResource" + - $ref: "#/components/schemas/ArtifactAccessResource" + discriminator: + propertyName: type + mapping: + server: "#/components/schemas/ServerAccessResource" + scope: "#/components/schemas/ScopeAccessResource" + artifact: "#/components/schemas/ArtifactAccessResource" AccessDecision: type: object additionalProperties: false @@ -2259,24 +2432,29 @@ components: $ref: "#/components/schemas/AccessAction" resource_type: $ref: "#/components/schemas/AccessResourceType" + family: {type: string, minLength: 1, maxLength: 128, nullable: true} cursor: {type: string, nullable: true} limit: {type: integer, minimum: 1, maximum: 500, default: 100} AccessResourcePage: type: object additionalProperties: false - required: [items, next_cursor] + required: [items, total, next_cursor] properties: items: type: array maxItems: 500 items: $ref: "#/components/schemas/AccessResource" + total: {type: integer, minimum: 0} next_cursor: {type: string, nullable: true} AccessRole: type: string enum: - handoff.viewer - handoff.receiver + - artifact.viewer + - prompt.user + - skill.publisher - scope.viewer - scope.contributor - scope.reviewer @@ -2295,7 +2473,7 @@ components: AccessRoleDescriptor: type: object additionalProperties: false - required: [role, resource_type, actions] + required: [role, resource_type, actions, artifact_families] properties: role: $ref: "#/components/schemas/AccessRole" @@ -2305,6 +2483,9 @@ components: type: array items: $ref: "#/components/schemas/AccessAction" + artifact_families: + type: array + items: {type: string, minLength: 1, maxLength: 128} AccessRolePage: type: object additionalProperties: false @@ -2407,6 +2588,7 @@ components: type: object additionalProperties: false properties: + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*', nullable: true} after: {type: integer, minimum: 0, nullable: true} limit: {type: integer, minimum: 1, maximum: 500, default: 100} AccessAuditEvent: @@ -3746,6 +3928,50 @@ components: type: array items: $ref: "#/components/schemas/ArtifactReference" + AgentKind: + type: string + enum: [codex, claude_code] + SkillPublicationTarget: + type: object + additionalProperties: false + required: [target_id, agent_kind, installation_scope, capabilities] + properties: + target_id: {type: string, minLength: 1, maxLength: 64} + agent_kind: + $ref: "#/components/schemas/AgentKind" + installation_scope: + $ref: "#/components/schemas/ExternalSkillInstallationScope" + capabilities: + type: array + items: + type: string + enum: [publish] + ListSkillPublicationTargetsResponse: + type: object + additionalProperties: false + required: [artifact, targets] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + targets: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/SkillPublicationTarget" + ManagedSkillPublication: + type: object + additionalProperties: false + required: [artifact, target_id, agent_kind, installation_scope, state, applied_revision] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + target_id: {type: string, minLength: 1, maxLength: 64} + agent_kind: + $ref: "#/components/schemas/AgentKind" + installation_scope: + $ref: "#/components/schemas/ExternalSkillInstallationScope" + state: {type: string, enum: [published]} + applied_revision: {type: integer, minimum: 1} SkillProposal: type: object additionalProperties: false @@ -3965,6 +4191,23 @@ components: pattern: '.*\S.*' artifact: $ref: "#/components/schemas/ArtifactReference" + ListSkillPublicationTargetsRequest: + type: object + additionalProperties: false + required: [scope_id, artifact] + properties: + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + artifact: + $ref: "#/components/schemas/ArtifactReference" + PublishManagedSkillRequest: + type: object + additionalProperties: false + required: [scope_id, artifact, target_id] + properties: + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + artifact: + $ref: "#/components/schemas/ArtifactReference" + target_id: {type: string, minLength: 1, maxLength: 64, pattern: '^[\x21-\x7E]+$'} CreateHandoffReportProjectRequest: type: object additionalProperties: false diff --git a/pyproject.toml b/pyproject.toml index d84445595..1e71766b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,12 +58,14 @@ client = [ server = [ "fastapi>=0.115,<1", "fastmcp>=3.4,<4", + "httpx>=0.28,<1", "jinja2>=3.1,<4", "opentelemetry-api>=1.30,<2", "opentelemetry-sdk>=1.30,<2", "platformdirs>=4,<5", "powercontext[builtin]", "prometheus-client>=0.21,<1", + "pycasbin>=2.8,<3", "pydantic-settings>=2.7,<3", "scalar-fastapi>=1.8.2,<2", "uvicorn>=0.34,<1", diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 0c5f05df4..c45a6303f 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -59,10 +59,10 @@ def __init__(self, subject: str, value: object) -> None: class _AccessRequirement(TypedDict): - action: str - resource: Literal["server", "scope", "handoff"] + action: str | None + resource: Literal["server", "scope", "artifact"] | None scope_id_field: str | None - resolver: Literal["static", "request", "continue_handoff", "acknowledge_handoff"] + resolver: str def generate_sources() -> dict[Path, str]: @@ -216,10 +216,10 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): class AccessRequirement(BaseModel): - action: str - resource: Literal["server", "scope", "handoff"] + action: str | None + resource: Literal["server", "scope", "artifact"] | None scope_id_field: str | None - resolver: Literal["static", "request", "continue_handoff", "acknowledge_handoff"] + resolver: str {rendered_operations} @@ -376,18 +376,33 @@ def _access_requirement(operation: OpenAPIOperation, operation_id: str) -> _Acce return None if not isinstance(value, dict): raise ContractGenerationError(f"{operation_id} x-powercontext-access", value) # noqa: TRY003 + named_resolver = value.get("resolver") + if named_resolver is not None: + if not isinstance(named_resolver, str) or not named_resolver: + raise ContractGenerationError(f"{operation_id} access resolver", named_resolver) # noqa: TRY003 + return { + "action": None, + "resource": None, + "scope_id_field": None, + "resolver": named_resolver, + } action = value.get("action") - resource = value.get("resource") - scope_id_field = value.get("scope_id_field") - resolver = value.get("resolver", "static" if resource == "server" else "request") + resource_value = value.get("resource") + if isinstance(resource_value, dict): + resource = resource_value.get("type") + scope_id_field = resource_value.get("scope-id-from") + else: + # Accept the first implementation's flat shape while downstream branches + # regenerate their contract from the RFC 1396 nested form. + resource = resource_value + scope_id_field = value.get("scope_id_field") + resolver = "static" if resource == "server" else "request" if not isinstance(action, str) or not action: raise ContractGenerationError(f"{operation_id} access action", action) # noqa: TRY003 - if resource not in {"server", "scope", "handoff"}: + if resource not in {"server", "scope", "artifact"}: raise ContractGenerationError(f"{operation_id} access resource", resource) # noqa: TRY003 if scope_id_field is not None and not isinstance(scope_id_field, str): raise ContractGenerationError(f"{operation_id} access scope_id_field", scope_id_field) # noqa: TRY003 - if resolver not in {"static", "request", "continue_handoff", "acknowledge_handoff"}: - raise ContractGenerationError(f"{operation_id} access resolver", resolver) # noqa: TRY003 if resource != "server" and resolver == "request" and not scope_id_field: raise ContractGenerationError(f"{operation_id} access scope_id_field", scope_id_field) # noqa: TRY003 return { diff --git a/src/powercontext/client/__init__.py b/src/powercontext/client/__init__.py index 55ca5bd9e..5bf7caf21 100644 --- a/src/powercontext/client/__init__.py +++ b/src/powercontext/client/__init__.py @@ -15,12 +15,23 @@ """Python Client SDK package for the public PowerContext HTTP API.""" from powercontext.client.client import PowerContextClient -from powercontext.client.errors import ClientError, InvalidResponseError, ServerResponseError, TransportError +from powercontext.client.errors import ( + ClientError, + ForbiddenResponseError, + InvalidResponseError, + ServerResponseError, + TransportError, + UnauthorizedResponseError, + UnavailableResponseError, +) __all__ = [ "ClientError", + "ForbiddenResponseError", "InvalidResponseError", "PowerContextClient", "ServerResponseError", "TransportError", + "UnauthorizedResponseError", + "UnavailableResponseError", ] diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index df1f235bb..ec37bcab3 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -23,7 +23,7 @@ import httpx from pydantic import TypeAdapter, ValidationError -from powercontext.client.errors import InvalidResponseError, ServerResponseError, TransportError +from powercontext.client.errors import InvalidResponseError, TransportError, server_response_error from powercontext.client.tracing import ClientSpan from powercontext.http import ( AccessAuditPage, @@ -33,7 +33,7 @@ AccessCheckBatchResponse, AccessCheckRequest, AccessDecision, - AccessPrincipal, + AccessMeResponse, AccessResourcePage, AccessRolePage, AcknowledgeHandoffRequest, @@ -95,6 +95,9 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListSkillPublicationTargetsRequest, + ListSkillPublicationTargetsResponse, + ManagedSkillPublication, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -106,6 +109,7 @@ ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + PublishManagedSkillRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -176,10 +180,12 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SKILL_PUBLICATION_TARGETS, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, + PUBLISH_MANAGED_SKILL, PURGE_HANDOFF_REPORT_ACTIVITIES, RECORD_HANDOFF_REPORT_ACTIVITY, RECORD_TASK_OUTCOME, @@ -434,7 +440,7 @@ async def _request_handoff_report_content(self, request: GetHandoffReportRequest ) if response.status_code != GET_HANDOFF_REPORT.success_status: error = _decode_error(response.content) - raise ServerResponseError( + raise server_response_error( status_code=response.status_code, request_id=response.headers.get(REQUEST_ID_HEADER), code=None if error is None else error.error.code, @@ -448,8 +454,8 @@ async def capture_content_source(self, request: CaptureContentSourceRequest) -> return await self._request(CAPTURE_CONTENT_SOURCE, request) - async def get_access_principal(self) -> AccessPrincipal: - """Return the opaque Principal established by Server authentication.""" + async def get_access_principal(self) -> AccessMeResponse: + """Return the authenticated Principal and enforceable Access capabilities.""" return await self._request(GET_ACCESS_PRINCIPAL) @@ -613,6 +619,19 @@ async def get_skill(self, request: GetSkillRequest) -> SkillArtifact: return await self._request(GET_SKILL, request) + async def list_skill_publication_targets( + self, + request: ListSkillPublicationTargetsRequest, + ) -> ListSkillPublicationTargetsResponse: + """List safe enabled publication targets for one exact managed Skill.""" + + return await self._request(LIST_SKILL_PUBLICATION_TARGETS, request) + + async def publish_managed_skill(self, request: PublishManagedSkillRequest) -> ManagedSkillPublication: + """Publish one exact managed Skill to an opaque configured target.""" + + return await self._request(PUBLISH_MANAGED_SKILL, request) + async def scan_external_skills(self, request: ScanExternalSkillsRequest) -> ScanExternalSkillsResponse: """Refresh the configured host-local external Skill Registry.""" @@ -707,7 +726,7 @@ async def _request( request_id = response.headers.get(REQUEST_ID_HEADER) if response.status_code != operation.success_status: error = _decode_error(response.content) - raise ServerResponseError( + raise server_response_error( status_code=response.status_code, request_id=request_id, code=None if error is None else error.error.code, diff --git a/src/powercontext/client/errors.py b/src/powercontext/client/errors.py index 24da5781f..c261735f3 100644 --- a/src/powercontext/client/errors.py +++ b/src/powercontext/client/errors.py @@ -61,3 +61,51 @@ def __init__( self.details = details suffix = "" if code is None else f" ({code})" super().__init__(f"PowerContext Server returned HTTP {status_code}{suffix}") + + +class UnauthorizedResponseError(ServerResponseError): + """Raised when the Server cannot authenticate the request (HTTP 401).""" + + +class ForbiddenResponseError(ServerResponseError): + """Raised when the authenticated Principal is not authorized (HTTP 403).""" + + +class UnavailableResponseError(ServerResponseError): + """Raised when a required Server dependency is unavailable (HTTP 503).""" + + +def server_response_error( + *, + status_code: int, + request_id: str | None, + code: str | None = None, + message: str | None = None, + details: dict[str, object] | None = None, +) -> ServerResponseError: + """Build the stable status-specific Client failure for one error response.""" + + error_type = { + 401: UnauthorizedResponseError, + 403: ForbiddenResponseError, + 503: UnavailableResponseError, + }.get(status_code, ServerResponseError) + return error_type( + status_code=status_code, + request_id=request_id, + code=code, + message=message, + details=details, + ) + + +__all__ = ( + "ClientError", + "ForbiddenResponseError", + "InvalidResponseError", + "ServerResponseError", + "TransportError", + "UnauthorizedResponseError", + "UnavailableResponseError", + "server_response_error", +) diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index e749b1367..964dd0f16 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -24,8 +24,13 @@ AccessCheckBatchRequest, AccessCheckBatchResponse, AccessCheckRequest, + AccessControlMode, AccessDecision, + AccessMeResponse, + AccessOperationCapabilities, + AccessOperationCapability, AccessPrincipal, + AccessProviderCapabilities, AccessResource, AccessResourcePage, AccessResourceType, @@ -34,9 +39,12 @@ AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, + AgentKind, ApproveArtifactCandidateRequest, + ArtifactAccessResource, ArtifactCandidate, ArtifactCandidatePage, + ArtifactFamilyAccessCapability, ArtifactInventoryStatistics, ArtifactReference, AttachHandoffReportWorkspaceRequest, @@ -134,8 +142,12 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListSkillPublicationTargetsRequest, + ListSkillPublicationTargetsResponse, + ManagedSkillPublication, MemoryCitation, MemoryEntry, + MemoryEntryAccessSelector, MemoryEntryInventoryStatistics, MemoryEntryState, MemoryInventoryStatistics, @@ -161,6 +173,7 @@ ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + PublishManagedSkillRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -186,13 +199,16 @@ RevokeAccessBindingRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeAccessResource, ScopedStats, SearchMemoryHit, SearchMemoryRequest, SearchMemoryResponse, + ServerAccessResource, SkillArtifact, SkillGenerationOrigin, SkillProposal, + SkillPublicationTarget, SkillValidationItem, SourceInventoryStatistics, SourceReference, @@ -226,8 +242,13 @@ "AccessCheckBatchRequest", "AccessCheckBatchResponse", "AccessCheckRequest", + "AccessControlMode", "AccessDecision", + "AccessMeResponse", + "AccessOperationCapabilities", + "AccessOperationCapability", "AccessPrincipal", + "AccessProviderCapabilities", "AccessResource", "AccessResourcePage", "AccessResourceType", @@ -236,9 +257,12 @@ "AccessRolePage", "AcknowledgeHandoffRequest", "ActivateHandoffRequest", + "AgentKind", "ApproveArtifactCandidateRequest", + "ArtifactAccessResource", "ArtifactCandidate", "ArtifactCandidatePage", + "ArtifactFamilyAccessCapability", "ArtifactInventoryStatistics", "ArtifactReference", "AttachHandoffReportWorkspaceRequest", @@ -336,8 +360,12 @@ "ListMemoryChangesResponse", "ListMemoryEntriesRequest", "ListMemoryEntriesResponse", + "ListSkillPublicationTargetsRequest", + "ListSkillPublicationTargetsResponse", + "ManagedSkillPublication", "MemoryCitation", "MemoryEntry", + "MemoryEntryAccessSelector", "MemoryEntryInventoryStatistics", "MemoryEntryState", "MemoryInventoryStatistics", @@ -363,6 +391,7 @@ "ProjectPage", "ProposeExperienceRequest", "ProposeSkillRequest", + "PublishManagedSkillRequest", "PurgeHandoffReportActivitiesRequest", "PurgeHandoffReportActivitiesResponse", "ReadinessResponse", @@ -388,13 +417,16 @@ "RevokeAccessBindingRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", + "ScopeAccessResource", "ScopedStats", "SearchMemoryHit", "SearchMemoryRequest", "SearchMemoryResponse", + "ServerAccessResource", "SkillArtifact", "SkillGenerationOrigin", "SkillProposal", + "SkillPublicationTarget", "SkillValidationItem", "SourceInventoryStatistics", "SourceReference", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index b22e1f244..75e9fe0c9 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -30,8 +30,40 @@ class AccessPrincipal(BaseModel): id: Annotated[StrictStr, Field(max_length=255, min_length=1)] +class AccessControlMode(StrEnum): + LEGACY_STATIC_ADMIN = "legacy-static-admin" + ENFORCED = "enforced" + + +class AccessProviderCapabilities(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + safe_resource_filtering: StrictBool + multi_requirement_check: StrictBool + relationship_management: StrictBool + + +class ShareUnit(StrEnum): + REVISION = "revision" + MEMORY_ENTRY = "memory_entry" + + +class AccessOperationCapability(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + enabled: StrictBool + + +class AccessOperationCapabilities(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + skill_publication: AccessOperationCapability + + class AccessAction(StrEnum): - ACCESS_SELF = "access.self" SERVER_OBSERVE = "server.observe" SERVER_ADMIN = "server.admin" SCOPE_READ = "scope.read" @@ -39,50 +71,67 @@ class AccessAction(StrEnum): SCOPE_REVIEW = "scope.review" SCOPE_DELEGATE = "scope.delegate" SCOPE_ADMIN = "scope.admin" - HANDOFF_READ = "handoff.read" + ARTIFACT_READ = "artifact.read" HANDOFF_EVIDENCE_READ = "handoff.evidence.read" HANDOFF_ACKNOWLEDGE = "handoff.acknowledge" + PROMPT_USE = "prompt.use" + SKILL_PUBLISH = "skill.publish" class AccessResourceType(StrEnum): SERVER = "server" SCOPE = "scope" - HANDOFF = "handoff" + ARTIFACT = "artifact" -class AccessResource(BaseModel): +class Type(StrEnum): + SERVER = "server" + + +class ServerAccessResource(BaseModel): model_config = ConfigDict( extra="forbid", ) - type: AccessResourceType - scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - family: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] = None - artifact_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - revision: Annotated[StrictInt | None, Field(ge=1)] = None + type: Literal["server"] + deployment_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] -class AccessDecision(BaseModel): +class Type1(StrEnum): + SCOPE = "scope" + + +class ScopeAccessResource(BaseModel): model_config = ConfigDict( extra="forbid", ) - allowed: StrictBool - reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] - policy_revision: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] + type: Literal["scope"] + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] -class AccessCheckRequest(BaseModel): +class Type2(StrEnum): + MEMORY_ENTRY = "memory_entry" + + +class MemoryEntryAccessSelector(BaseModel): model_config = ConfigDict( extra="forbid", ) - action: AccessAction - resource: AccessResource + type: Type2 + entry_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + entry_version_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] -class AccessCheckBatchRequest(BaseModel): +class Type3(StrEnum): + ARTIFACT = "artifact" + + +class AccessDecision(BaseModel): model_config = ConfigDict( extra="forbid", ) - checks: Annotated[list[AccessCheckRequest], Field(max_length=100, min_length=1)] + allowed: StrictBool + reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] + policy_revision: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] class AccessCheckBatchResponse(BaseModel): @@ -98,21 +147,17 @@ class ListAccessResourcesRequest(BaseModel): ) action: AccessAction resource_type: AccessResourceType + family: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] = None cursor: StrictStr | None = None limit: Annotated[StrictInt, Field(ge=1, le=500)] = 100 -class AccessResourcePage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[AccessResource], Field(max_length=500)] - next_cursor: Annotated[StrictStr | None, Field(...)] - - class AccessRole(StrEnum): HANDOFF_VIEWER = "handoff.viewer" HANDOFF_RECEIVER = "handoff.receiver" + ARTIFACT_VIEWER = "artifact.viewer" + PROMPT_USER = "prompt.user" + SKILL_PUBLISHER = "skill.publisher" SCOPE_VIEWER = "scope.viewer" SCOPE_CONTRIBUTOR = "scope.contributor" SCOPE_REVIEWER = "scope.reviewer" @@ -129,6 +174,10 @@ class ListAccessRolesRequest(BaseModel): resource_type: AccessResourceType | None = None +class ArtifactFamily(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=128, min_length=1)] + + class AccessRoleDescriptor(BaseModel): model_config = ConfigDict( extra="forbid", @@ -136,6 +185,7 @@ class AccessRoleDescriptor(BaseModel): role: AccessRole resource_type: AccessResourceType actions: list[AccessAction] + artifact_families: list[ArtifactFamily] class AccessRolePage(BaseModel): @@ -150,54 +200,6 @@ class AccessBindingState(StrEnum): REVOKED = "revoked" -class AccessBinding(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] - subject: AccessPrincipal - resource: AccessResource - role: AccessRole - granted_by: AccessPrincipal - reason: Annotated[StrictStr | None, Field(max_length=1024)] - created_at: AwareDatetime - expires_at: Annotated[AwareDatetime | None, Field(...)] - state: AccessBindingState - version: Annotated[StrictInt, Field(ge=1)] - policy_revision: Annotated[StrictStr, Field(max_length=64, min_length=1)] - idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] - revoked_at: Annotated[AwareDatetime | None, Field(...)] - revoked_by: Annotated[AccessPrincipal | None, Field(...)] - - -class ListAccessBindingsRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - subject: AccessPrincipal | None = None - resource: AccessResource | None = None - include_revoked: StrictBool = False - - -class AccessBindingPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[AccessBinding], Field(max_length=500)] - - -class CreateAccessBindingRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - subject: AccessPrincipal - resource: AccessResource - role: AccessRole - idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] - reason: Annotated[StrictStr | None, Field(max_length=1024)] = None - expires_at: AwareDatetime | None = None - - class RevokeAccessBindingRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -210,39 +212,11 @@ class ListAccessAuditRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) + scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None after: Annotated[StrictInt | None, Field(ge=0)] = None limit: Annotated[StrictInt, Field(ge=1, le=500)] = 100 -class AccessAuditEvent(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - cursor: Annotated[StrictInt, Field(ge=1)] - event_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] - occurred_at: AwareDatetime - request_id: Annotated[StrictStr | None, Field(max_length=128)] - transport: Annotated[StrictStr, Field(max_length=16, min_length=1)] - operation: Annotated[StrictStr, Field(max_length=128, min_length=1)] - principal: AccessPrincipal - action: AccessAction - resource: AccessResource - allowed: StrictBool - reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] - policy_revision: Annotated[StrictStr | None, Field(max_length=64)] - binding_id: Annotated[StrictStr | None, Field(max_length=64)] - target: Annotated[AccessPrincipal | None, Field(...)] - role: Annotated[AccessRole | None, Field(...)] - - -class AccessAuditPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[AccessAuditEvent], Field(max_length=500)] - next_cursor: Annotated[StrictInt | None, Field(ge=1)] - - class ArtifactReference(BaseModel): model_config = ConfigDict( extra="forbid", @@ -541,6 +515,19 @@ class ExperienceProposal(BaseModel): lesson: Annotated[StrictStr, Field(max_length=8000, min_length=1, pattern=".*\\S.*")] +class AgentKind(StrEnum): + CODEX = "codex" + CLAUDE_CODE = "claude_code" + + +class Capability(StrEnum): + PUBLISH = "publish" + + +class State(StrEnum): + PUBLISHED = "published" + + class SkillValidationItem(RootModel[StrictStr]): root: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern="^\\S(?:.*\\S)?$")] @@ -550,11 +537,6 @@ class Provider(StrEnum): CLAUDE_CODE = "claude_code" -class AgentKind(StrEnum): - CODEX = "codex" - CLAUDE_CODE = "claude_code" - - class ErrorDetail(BaseModel): model_config = ConfigDict( extra="forbid", @@ -602,6 +584,23 @@ class GetSkillRequest(BaseModel): artifact: ArtifactReference +class ListSkillPublicationTargetsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact: ArtifactReference + + +class PublishManagedSkillRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact: ArtifactReference + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + + class ListHandoffReportProjectsRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -754,7 +753,7 @@ class Schema4(StrEnum): POWERCONTEXT_WORKSPACE_BINDING_V1 = "powercontext.workspace-binding.v1" -class State(StrEnum): +class State1(StrEnum): CONFIRMED = "confirmed" DETACHED = "detached" @@ -767,7 +766,7 @@ class HandoffReportWorkspaceBinding(BaseModel): workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] repository_ref: HandoffReportRepositoryRef - state: State + state: State1 confirmed_at: AwareDatetime version: Annotated[StrictInt, Field(ge=1)] @@ -1124,6 +1123,144 @@ class PreparedHandoffSchema(StrEnum): POWERCONTEXT_PREPARED_HANDOFF_V1 = "powercontext.prepared-handoff.v1" +class ArtifactFamilyAccessCapability(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Annotated[StrictStr, Field(max_length=128, min_length=1)] + enabled: StrictBool + share_unit: ShareUnit + actions: list[AccessAction] + grantable_roles: list[AccessRole] + + +class AccessMeResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + principal: AccessPrincipal + mode: AccessControlMode + resource_kinds: list[AccessResourceType] + provider_capabilities: AccessProviderCapabilities + artifact_families: list[ArtifactFamilyAccessCapability] + operation_capabilities: AccessOperationCapabilities + + +class ArtifactAccessResource(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + type: Literal["artifact"] + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + reference: ArtifactReference + selector: Annotated[MemoryEntryAccessSelector | None, Field(...)] + + +class AccessResource(RootModel[ServerAccessResource | ScopeAccessResource | ArtifactAccessResource]): + root: Annotated[ServerAccessResource | ScopeAccessResource | ArtifactAccessResource, Field(discriminator="type")] + + +class AccessCheckRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + action: AccessAction + resource: AccessResource + + +class AccessCheckBatchRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + checks: Annotated[list[AccessCheckRequest], Field(max_length=100, min_length=1)] + + +class AccessResourcePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessResource], Field(max_length=500)] + total: Annotated[StrictInt, Field(ge=0)] + next_cursor: Annotated[StrictStr | None, Field(...)] + + +class AccessBinding(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + subject: AccessPrincipal + resource: AccessResource + role: AccessRole + granted_by: AccessPrincipal + reason: Annotated[StrictStr | None, Field(max_length=1024)] + created_at: AwareDatetime + expires_at: Annotated[AwareDatetime | None, Field(...)] + state: AccessBindingState + version: Annotated[StrictInt, Field(ge=1)] + policy_revision: Annotated[StrictStr, Field(max_length=64, min_length=1)] + idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] + revoked_at: Annotated[AwareDatetime | None, Field(...)] + revoked_by: Annotated[AccessPrincipal | None, Field(...)] + + +class ListAccessBindingsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + subject: AccessPrincipal | None = None + resource: AccessResource | None = None + include_revoked: StrictBool = False + + +class AccessBindingPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessBinding], Field(max_length=500)] + + +class CreateAccessBindingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + subject: AccessPrincipal + resource: AccessResource + role: AccessRole + idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] + reason: Annotated[StrictStr | None, Field(max_length=1024)] = None + expires_at: AwareDatetime | None = None + + +class AccessAuditEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + cursor: Annotated[StrictInt, Field(ge=1)] + event_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + occurred_at: AwareDatetime + request_id: Annotated[StrictStr | None, Field(max_length=128)] + transport: Annotated[StrictStr, Field(max_length=16, min_length=1)] + operation: Annotated[StrictStr, Field(max_length=128, min_length=1)] + principal: AccessPrincipal + action: AccessAction + resource: AccessResource + allowed: StrictBool + reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] + policy_revision: Annotated[StrictStr | None, Field(max_length=64)] + binding_id: Annotated[StrictStr | None, Field(max_length=64)] + target: Annotated[AccessPrincipal | None, Field(...)] + role: Annotated[AccessRole | None, Field(...)] + + +class AccessAuditPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessAuditEvent], Field(max_length=500)] + next_cursor: Annotated[StrictInt | None, Field(ge=1)] + + class Capabilities(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1295,6 +1432,36 @@ class ExperienceArtifact(BaseModel): artifact_refs: list[ArtifactReference] +class SkillPublicationTarget(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + agent_kind: AgentKind + installation_scope: ExternalSkillInstallationScope + capabilities: list[Capability] + + +class ListSkillPublicationTargetsResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact: ArtifactReference + targets: Annotated[list[SkillPublicationTarget], Field(max_length=100)] + + +class ManagedSkillPublication(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact: ArtifactReference + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + agent_kind: AgentKind + installation_scope: ExternalSkillInstallationScope + state: State + applied_revision: Annotated[StrictInt, Field(ge=1)] + + class SkillProposal(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 433642e6b..b340e58d1 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -14,7 +14,7 @@ AccessCheckBatchResponse, AccessCheckRequest, AccessDecision, - AccessPrincipal, + AccessMeResponse, AccessResourcePage, AccessRolePage, AcknowledgeHandoffRequest, @@ -75,6 +75,9 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListSkillPublicationTargetsRequest, + ListSkillPublicationTargetsResponse, + ManagedSkillPublication, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -86,6 +89,7 @@ ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + PublishManagedSkillRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -137,10 +141,10 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): class AccessRequirement(BaseModel): - action: str - resource: Literal["server", "scope", "handoff"] + action: str | None + resource: Literal["server", "scope", "artifact"] | None scope_id_field: str | None - resolver: Literal["static", "request", "continue_handoff", "acknowledge_handoff"] + resolver: str GET_LIVENESS = Operation[None, HealthResponse]( @@ -336,9 +340,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field=None, resolver="acknowledge_handoff" - ), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="acknowledge_handoff_access"), ) RECORD_TASK_OUTCOME = Operation[RecordTaskOutcomeRequest, WorkSourceReceipt]( @@ -501,7 +503,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement(action="scope.read", resource="scope", scope_id_field=None, resolver="continue_handoff"), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="continue_handoff_access"), ) FLUSH_MEMORY = Operation[FlushMemoryRequest, FlushMemoryResponse]( @@ -629,7 +631,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="exact_memory_access"), ) REVISE_MEMORY_ENTRY = Operation[ReviseMemoryEntryRequest, MemoryMutationResponse]( @@ -789,7 +791,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="exact_experience_access"), ) PROPOSE_SKILL = Operation[ProposeSkillRequest, ArtifactCandidate]( @@ -868,7 +870,58 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="exact_skill_access"), +) + +LIST_SKILL_PUBLICATION_TARGETS = Operation[ListSkillPublicationTargetsRequest, ListSkillPublicationTargetsResponse]( + method="POST", + path="/v1/skills/publication-targets/list", + operation_id="list_skill_publication_targets", + request_type=ListSkillPublicationTargetsRequest, + request_location="body", + response_type=ListSkillPublicationTargetsResponse, + success_status=200, + summary="List safe publication targets for an exact managed Skill", + tags=("skill",), + responses={ + 200: { + "description": "Enabled publication targets without host paths, locators, or credentials.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="publish_managed_skill_access"), +) + +PUBLISH_MANAGED_SKILL = Operation[PublishManagedSkillRequest, ManagedSkillPublication]( + method="POST", + path="/v1/skills/publish", + operation_id="publish_managed_skill", + request_type=PublishManagedSkillRequest, + request_location="body", + response_type=ManagedSkillPublication, + success_status=200, + summary="Publish an exact managed Skill to one configured target", + tags=("skill",), + responses={ + 200: { + "description": "Safe publication result for the selected exact Revision and opaque target.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="publish_managed_skill_access"), ) SCAN_EXTERNAL_SKILLS = Operation[ScanExternalSkillsRequest, ScanExternalSkillsResponse]( @@ -1515,18 +1568,18 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) -GET_ACCESS_PRINCIPAL = Operation[None, AccessPrincipal]( +GET_ACCESS_PRINCIPAL = Operation[None, AccessMeResponse]( method="GET", path="/v1/access/me", operation_id="get_access_principal", request_type=None, request_location=None, - response_type=AccessPrincipal, + response_type=AccessMeResponse, success_status=200, - summary="Get the authenticated Principal", + summary="Get the authenticated Principal and Access capabilities", tags=("access",), responses={ - 200: {"description": "The opaque Principal established by the authentication adapter."}, + 200: {"description": "The opaque Principal and enforceable deployment Access capabilities."}, 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 503: {"$ref": "#/components/responses/Unavailable"}, @@ -1692,5 +1745,5 @@ class AccessRequirement(BaseModel): 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, }, - access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="access_audit_access"), ) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 617ef3076..1d762d303 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -59,7 +59,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/sources/content": { @@ -93,8 +93,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -122,7 +121,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/work/contracts/create": { @@ -153,8 +155,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -192,8 +193,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -230,11 +230,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": "scope", - "resolver": "acknowledge_handoff", - }, + "x-powercontext-access": {"resolver": "acknowledge_handoff_access"}, } }, "/v1/work/outcomes/record": { @@ -277,8 +273,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -317,8 +312,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -346,8 +340,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -377,8 +370,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -407,8 +399,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -436,7 +427,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "resolver": "continue_handoff"}, + "x-powercontext-access": {"resolver": "continue_handoff_access"}, } }, "/v1/memory/flush": { @@ -465,8 +456,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -499,8 +489,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -529,7 +518,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/memory/entries/list": { @@ -562,7 +554,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/memory/entries/get": { @@ -588,7 +583,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": {"resolver": "exact_memory_access"}, } }, "/v1/memory/entries/revise": { @@ -621,8 +616,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -658,8 +652,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -690,7 +683,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/experience/propose": { @@ -720,8 +716,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -757,8 +752,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -787,7 +781,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": {"resolver": "exact_experience_access"}, } }, "/v1/skill/propose": { @@ -815,8 +809,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -849,8 +842,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -877,7 +869,79 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": {"resolver": "exact_skill_access"}, + } + }, + "/v1/skills/publication-targets/list": { + "post": { + "tags": ["skill"], + "summary": "List safe publication targets for an exact managed Skill", + "description": "Return only enabled " + "opaque host-local " + "targets after the " + "exact Skill read and " + "publish checks both " + "allow.", + "operationId": "list_skill_publication_targets", + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ListSkillPublicationTargetsRequest"} + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Enabled publication targets without host paths, locators, or credentials.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ListSkillPublicationTargetsResponse"} + } + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": {"resolver": "publish_managed_skill_access"}, + } + }, + "/v1/skills/publish": { + "post": { + "tags": ["skill"], + "summary": "Publish an exact managed Skill to one configured target", + "description": "Publish only after artifact.read and " + "skill.publish both allow; target_id is " + "resolved after authorization.", + "operationId": "publish_managed_skill", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/PublishManagedSkillRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Safe publication result for the selected exact Revision and opaque target.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ManagedSkillPublication"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": {"resolver": "publish_managed_skill_access"}, } }, "/v1/external-skills/scan": { @@ -909,7 +973,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, } }, "/v1/external-skills/list": { @@ -949,7 +1013,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/external-skills/resolve": { @@ -982,7 +1046,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/external-skills/import": { @@ -1018,8 +1082,7 @@ }, "x-powercontext-access": { "action": "scope.contribute", - "resource": "scope", - "scope_id_field": "scope_id", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, } }, @@ -1049,7 +1112,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/artifact-candidates/get": { @@ -1077,7 +1143,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/artifact-candidates/approve": { @@ -1106,7 +1175,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.review", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.review", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/artifact-candidates/reject": { @@ -1138,7 +1210,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.review", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.review", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/artifact-candidates/revise": { @@ -1167,7 +1242,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.review", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.review", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/stats": { @@ -1175,7 +1253,10 @@ "tags": ["stats"], "summary": "Get scoped product statistics", "operationId": "get_stats", - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, "parameters": [ { "name": "scope_id", @@ -1235,7 +1316,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/projects/list": { @@ -1262,7 +1343,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/scopes/list-known": { @@ -1291,7 +1372,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/projects/get": { @@ -1317,7 +1398,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/projects/update": { @@ -1346,7 +1427,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/workstreams/register": { @@ -1377,7 +1458,10 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.admin", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.admin", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/handoff-reports/workstreams/list": { @@ -1405,7 +1489,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/workstreams/update": { @@ -1438,8 +1522,7 @@ }, "x-powercontext-access": { "action": "scope.admin", - "resource": "scope", - "scope_id_field": "workstream.scope_id", + "resource": {"type": "scope", "scope-id-from": "workstream.scope_id"}, }, } }, @@ -1489,7 +1572,10 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "scope.read", "resource": "scope", "scope_id_field": "scope_id"}, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, } }, "/v1/handoff-reports/activities/record": { @@ -1520,7 +1606,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/activities/list": { @@ -1550,7 +1636,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/activities/purge": { @@ -1582,7 +1668,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/workspace-bindings/get": { @@ -1614,7 +1700,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.observe", "resource": "server"}, + "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/workspace-bindings/attach": { @@ -1647,7 +1733,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, } }, "/v1/handoff-reports/workspace-bindings/detach": { @@ -1680,24 +1766,24 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, } }, "/v1/access/me": { "get": { "tags": ["access"], - "summary": "Get the authenticated Principal", + "summary": "Get the authenticated Principal and Access capabilities", "operationId": "get_access_principal", "responses": { "200": { - "description": "The opaque Principal established by the authentication adapter.", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessPrincipal"}}}, + "description": "The opaque Principal and enforceable deployment Access capabilities.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessMeResponse"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"action": "access.self", "resource": "server"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, "/v1/access/check": { @@ -1719,7 +1805,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"action": "access.self", "resource": "server"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, "/v1/access/check-batch": { @@ -1745,7 +1831,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"action": "access.self", "resource": "server"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, "/v1/access/resources/list": { @@ -1771,7 +1857,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"action": "access.self", "resource": "server"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, "/v1/access/roles/list": { @@ -1794,7 +1880,7 @@ "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, }, - "x-powercontext-access": {"action": "access.self", "resource": "server"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, "/v1/access/bindings/list": { @@ -1818,7 +1904,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"action": "access.self", "resource": "server"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, "/v1/access/bindings/create": { @@ -1843,7 +1929,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"action": "access.self", "resource": "server"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, "/v1/access/bindings/revoke": { @@ -1868,7 +1954,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"action": "access.self", "resource": "server"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, "/v1/access/audit/list": { @@ -1892,7 +1978,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"action": "server.admin", "resource": "server"}, + "x-powercontext-access": {"resolver": "access_audit_access"}, } }, }, @@ -1908,10 +1994,67 @@ "type": "object", "required": ["type", "issuer", "id"], }, + "AccessControlMode": {"type": "string", "enum": ["legacy-static-admin", "enforced"]}, + "AccessProviderCapabilities": { + "properties": { + "safe_resource_filtering": {"type": "boolean"}, + "multi_requirement_check": {"type": "boolean"}, + "relationship_management": {"type": "boolean"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["safe_resource_filtering", "multi_requirement_check", "relationship_management"], + }, + "ArtifactFamilyAccessCapability": { + "properties": { + "family": {"type": "string", "maxLength": 128, "minLength": 1}, + "enabled": {"type": "boolean"}, + "share_unit": {"type": "string", "enum": ["revision", "memory_entry"]}, + "actions": {"items": {"$ref": "#/components/schemas/AccessAction"}, "type": "array"}, + "grantable_roles": {"items": {"$ref": "#/components/schemas/AccessRole"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "enabled", "share_unit", "actions", "grantable_roles"], + }, + "AccessOperationCapability": { + "properties": {"enabled": {"type": "boolean"}}, + "additionalProperties": False, + "type": "object", + "required": ["enabled"], + }, + "AccessOperationCapabilities": { + "properties": {"skill_publication": {"$ref": "#/components/schemas/AccessOperationCapability"}}, + "additionalProperties": False, + "type": "object", + "required": ["skill_publication"], + }, + "AccessMeResponse": { + "properties": { + "principal": {"$ref": "#/components/schemas/AccessPrincipal"}, + "mode": {"$ref": "#/components/schemas/AccessControlMode"}, + "resource_kinds": {"items": {"$ref": "#/components/schemas/AccessResourceType"}, "type": "array"}, + "provider_capabilities": {"$ref": "#/components/schemas/AccessProviderCapabilities"}, + "artifact_families": { + "items": {"$ref": "#/components/schemas/ArtifactFamilyAccessCapability"}, + "type": "array", + }, + "operation_capabilities": {"$ref": "#/components/schemas/AccessOperationCapabilities"}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "principal", + "mode", + "resource_kinds", + "provider_capabilities", + "artifact_families", + "operation_capabilities", + ], + }, "AccessAction": { "type": "string", "enum": [ - "access.self", "server.observe", "server.admin", "scope.read", @@ -1919,23 +2062,80 @@ "scope.review", "scope.delegate", "scope.admin", - "handoff.read", + "artifact.read", "handoff.evidence.read", "handoff.acknowledge", + "prompt.use", + "skill.publish", ], }, - "AccessResourceType": {"type": "string", "enum": ["server", "scope", "handoff"]}, - "AccessResource": { + "AccessResourceType": {"type": "string", "enum": ["server", "scope", "artifact"]}, + "ServerAccessResource": { "properties": { - "type": {"$ref": "#/components/schemas/AccessResourceType"}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "family": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, - "artifact_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "type": {"type": "string", "enum": ["server"]}, + "deployment_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["type", "deployment_id"], + }, + "ScopeAccessResource": { + "properties": { + "type": {"type": "string", "enum": ["scope"]}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, }, "additionalProperties": False, "type": "object", - "required": ["type"], + "required": ["type", "scope_id"], + }, + "MemoryEntryAccessSelector": { + "properties": { + "type": {"type": "string", "enum": ["memory_entry"]}, + "entry_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "entry_version_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["type", "entry_id", "entry_version_id"], + }, + "ArtifactAccessResource": { + "properties": { + "type": {"type": "string", "enum": ["artifact"]}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "reference": {"$ref": "#/components/schemas/ArtifactReference"}, + "selector": { + "allOf": [{"$ref": "#/components/schemas/MemoryEntryAccessSelector"}], + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["type", "scope_id", "reference", "selector"], + }, + "AccessResource": { + "oneOf": [ + {"$ref": "#/components/schemas/ServerAccessResource"}, + {"$ref": "#/components/schemas/ScopeAccessResource"}, + {"$ref": "#/components/schemas/ArtifactAccessResource"}, + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "server": "#/components/schemas/ServerAccessResource", + "scope": "#/components/schemas/ScopeAccessResource", + "artifact": "#/components/schemas/ArtifactAccessResource", + }, + }, }, "AccessDecision": { "properties": { @@ -1985,6 +2185,7 @@ "properties": { "action": {"$ref": "#/components/schemas/AccessAction"}, "resource_type": {"$ref": "#/components/schemas/AccessResourceType"}, + "family": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, "cursor": {"type": "string", "nullable": True}, "limit": {"type": "integer", "maximum": 500.0, "minimum": 1.0, "default": 100}, }, @@ -1999,17 +2200,21 @@ "type": "array", "maxItems": 500, }, + "total": {"type": "integer", "minimum": 0.0}, "next_cursor": {"type": "string", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["items", "next_cursor"], + "required": ["items", "total", "next_cursor"], }, "AccessRole": { "type": "string", "enum": [ "handoff.viewer", "handoff.receiver", + "artifact.viewer", + "prompt.user", + "skill.publisher", "scope.viewer", "scope.contributor", "scope.reviewer", @@ -2031,10 +2236,14 @@ "role": {"$ref": "#/components/schemas/AccessRole"}, "resource_type": {"$ref": "#/components/schemas/AccessResourceType"}, "actions": {"items": {"$ref": "#/components/schemas/AccessAction"}, "type": "array"}, + "artifact_families": { + "items": {"type": "string", "maxLength": 128, "minLength": 1}, + "type": "array", + }, }, "additionalProperties": False, "type": "object", - "required": ["role", "resource_type", "actions"], + "required": ["role", "resource_type", "actions", "artifact_families"], }, "AccessRolePage": { "properties": { @@ -2126,6 +2335,13 @@ }, "ListAccessAuditRequest": { "properties": { + "scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, "after": {"type": "integer", "minimum": 0.0, "nullable": True}, "limit": {"type": "integer", "maximum": 500.0, "minimum": 1.0, "default": 100}, }, @@ -3183,6 +3399,44 @@ "type": "object", "required": ["artifact", "content", "source_refs", "artifact_refs"], }, + "AgentKind": {"type": "string", "enum": ["codex", "claude_code"]}, + "SkillPublicationTarget": { + "properties": { + "target_id": {"type": "string", "maxLength": 64, "minLength": 1}, + "agent_kind": {"$ref": "#/components/schemas/AgentKind"}, + "installation_scope": {"$ref": "#/components/schemas/ExternalSkillInstallationScope"}, + "capabilities": {"items": {"type": "string", "enum": ["publish"]}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["target_id", "agent_kind", "installation_scope", "capabilities"], + }, + "ListSkillPublicationTargetsResponse": { + "properties": { + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "targets": { + "items": {"$ref": "#/components/schemas/SkillPublicationTarget"}, + "type": "array", + "maxItems": 100, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["artifact", "targets"], + }, + "ManagedSkillPublication": { + "properties": { + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "target_id": {"type": "string", "maxLength": 64, "minLength": 1}, + "agent_kind": {"$ref": "#/components/schemas/AgentKind"}, + "installation_scope": {"$ref": "#/components/schemas/ExternalSkillInstallationScope"}, + "state": {"type": "string", "enum": ["published"]}, + "applied_revision": {"type": "integer", "minimum": 1.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["artifact", "target_id", "agent_kind", "installation_scope", "state", "applied_revision"], + }, "SkillProposal": { "properties": { "name": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^\\S(?:.*\\S)?$"}, @@ -3352,6 +3606,25 @@ "type": "object", "required": ["scope_id", "artifact"], }, + "ListSkillPublicationTargetsRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "artifact"], + }, + "PublishManagedSkillRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "target_id": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "artifact", "target_id"], + }, "CreateHandoffReportProjectRequest": { "properties": { "project_key": {"type": "string", "maxLength": 64, "minLength": 1}, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index a52ddf559..23f1af8fd 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -25,7 +25,7 @@ from datetime import UTC, datetime from functools import wraps from time import perf_counter -from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeVar, cast +from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, TypeVar, cast from uuid import uuid4 from fastapi import Depends, FastAPI, Query, Request, Response, status @@ -47,6 +47,7 @@ InvalidHandoffGenerationError, InvalidHandoffReferenceError, ) +from powercontext.builtin.artifacts.memory import MemoryCitation as RuntimeMemoryCitation from powercontext.builtin.artifacts.memory.errors import ( CapabilityNotSupportedError, InvalidMemoryCandidateError, @@ -56,6 +57,7 @@ MemoryEntryNotFoundError, ) from powercontext.builtin.artifacts.skill import ( + AgentSkillTarget, ExternalSkillNotFoundError, ExternalSkillRegistryUnavailableError, ExternalSkillSnapshotUnavailableError, @@ -64,6 +66,11 @@ from powercontext.builtin.artifacts.skill import ( ExternalSkillResolution as RuntimeExternalSkillResolution, ) +from powercontext.builtin.artifacts.skill.projection import ( + AgentSkillProjectionConflictError, + inspect_skill_projection, + publish_skill_projection, +) from powercontext.builtin.handoff_report import ( HandoffReportApplication, HandoffReportBusyError, @@ -226,13 +233,19 @@ AccessCheckBatchRequest, AccessCheckBatchResponse, AccessCheckRequest, + AccessMeResponse, + AccessOperationCapabilities, + AccessOperationCapability, + AccessProviderCapabilities, AccessResourcePage, AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, + ArtifactAccessResource, ArtifactCandidate, ArtifactCandidatePage, + ArtifactFamilyAccessCapability, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -288,7 +301,11 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListSkillPublicationTargetsRequest, + ListSkillPublicationTargetsResponse, + ManagedSkillPublication, MemoryEntry, + MemoryEntryAccessSelector, MemoryMutationResponse, PrepareContextRequest, PreparedContext, @@ -298,6 +315,7 @@ ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + PublishManagedSkillRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -314,10 +332,13 @@ RevokeAccessBindingRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeAccessResource, ScopedStats, SearchMemoryRequest, SearchMemoryResponse, + ServerAccessResource, SkillArtifact, + SkillPublicationTarget, StoredHandoffReportActivity, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, @@ -349,6 +370,12 @@ from powercontext.http import ( AccessRoleDescriptor as TransportAccessRoleDescriptor, ) +from powercontext.http import ( + AgentKind as TransportAgentKind, +) +from powercontext.http import ( + ExternalSkillInstallationScope as TransportExternalSkillInstallationScope, +) from powercontext.http import ( HandoffActivation as TransportHandoffActivation, ) @@ -361,6 +388,27 @@ from powercontext.http import ( PreparedHandoff as TransportPreparedHandoff, ) +from powercontext.http._generated.models import ( + AccessControlMode as TransportAccessControlMode, +) +from powercontext.http._generated.models import ( + ArtifactFamily as TransportArtifactFamily, +) +from powercontext.http._generated.models import ( + ArtifactReference as TransportArtifactReference, +) +from powercontext.http._generated.models import ( + Capability as TransportSkillPublicationCapability, +) +from powercontext.http._generated.models import ( + ShareUnit as TransportShareUnit, +) +from powercontext.http._generated.models import ( + State as TransportManagedSkillPublicationState, +) +from powercontext.http._generated.models import ( + Type2 as TransportMemoryEntrySelectorType, +) from powercontext.http._generated.operations import ( ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, @@ -408,11 +456,13 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SKILL_PUBLICATION_TARGETS, OPENAPI_VERSION, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, + PUBLISH_MANAGED_SKILL, PURGE_HANDOFF_REPORT_ACTIVITIES, RECORD_HANDOFF_REPORT_ACTIVITY, RECORD_TASK_OUTCOME, @@ -439,6 +489,7 @@ AccessAuditEvent, AccessBinding, AccessConflictError, + AccessControlError, AccessControlService, AccessDecision, AccessDeniedError, @@ -448,10 +499,12 @@ AccessRole, AccessUnavailableError, CreateBinding, + MemoryEntrySelector, PrincipalRef, ResourceRef, ) from powercontext.server.authz.models import ROLE_ACTIONS, ROLE_RESOURCE_TYPES +from powercontext.server.authz.profiles import ARTIFACT_FAMILY_PROFILES, artifact_family_profile from powercontext.server.context import ( bind_request_id, current_principal, @@ -563,6 +616,8 @@ async def continue_from(self, handoff: PreparedHandoff | ArtifactRef, /) -> Hand async def continue_latest(self) -> HandoffResolution: ... + async def revision(self, reference: ArtifactRef, /) -> Handoff: ... + class _HandoffApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedHandoffApplication: ... @@ -630,6 +685,18 @@ class _RuntimeNotReadyError(RuntimeError): """Raised when an application operation is called without a Runtime binding.""" +class _SkillPublicationTargetNotFoundError(PowerContextError): + """Raised after authorization when an opaque target is unknown or disabled.""" + + +class _SkillPublicationConflictError(PowerContextError): + """Raised when a host-local projection cannot be replaced safely.""" + + +class _SkillPublicationFailedError(PowerContextError): + """Raised when an authorized projection fails without exposing host details.""" + + def create_app( *, application: ServerApplication | None = None, @@ -641,6 +708,8 @@ def create_app( tracing: ServerTracing | None = None, handoff_report_enabled: bool = False, access_control: AccessControlService | None = None, + access_mode: Literal["disabled", "legacy-static-admin", "enforced"] | None = None, + agent_skill_targets: Sequence[AgentSkillTarget] = (), ) -> FastAPI: """Build the HTTP adapter around an optional Runtime application binding.""" @@ -658,6 +727,10 @@ def create_app( app.state.capability_provider = capability_provider app.state.readiness_probe = readiness_probe app.state.access_control = access_control + app.state.access_mode = ( + ("disabled" if access_control is None else access_control.mode) if access_mode is None else access_mode + ) + app.state.agent_skill_targets = tuple(target for target in agent_skill_targets if target.allow_managed_publish) app.state.metrics = metrics app.state.tracing = tracing app.state.capabilities = Capabilities( @@ -766,6 +839,8 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, PROPOSE_SKILL, propose_skill) _add_route(app, GENERATE_SKILL, generate_skill) _add_route(app, GET_SKILL, get_skill) + _add_route(app, LIST_SKILL_PUBLICATION_TARGETS, list_skill_publication_targets) + _add_route(app, PUBLISH_MANAGED_SKILL, publish_managed_skill) _add_route(app, SCAN_EXTERNAL_SKILLS, scan_external_skills) _add_route(app, LIST_EXTERNAL_SKILLS, list_external_skills) _add_route(app, RESOLVE_EXTERNAL_SKILL, resolve_external_skill) @@ -820,10 +895,36 @@ async def get_readiness(request: Request) -> JSONResponse: readiness = ( await readiness_probe() if readiness_probe is not None else _runtime_readiness(request.app.state.application) ) - response_status = ( - status.HTTP_503_SERVICE_UNAVAILABLE if readiness.status is ReadinessStatus.NOT_READY else status.HTTP_200_OK + checks = {**readiness.checks, **_access_readiness_checks(request)} + response_status = status.HTTP_200_OK + readiness_status = readiness.status + if readiness.status is ReadinessStatus.NOT_READY or checks["access_provider"] == "not_ready": + readiness_status = ReadinessStatus.NOT_READY + response_status = status.HTTP_503_SERVICE_UNAVAILABLE + response = ReadinessResponse(status=readiness_status, checks=checks) + return JSONResponse(content=response.model_dump(mode="json"), status_code=response_status) + + +def _access_readiness_checks(request: Request) -> dict[str, str]: + mode: str = request.app.state.access_mode + access: AccessControlService | None = request.app.state.access_control + provider = "ready" if access is not None else ("not_ready" if mode == "enforced" else "disabled") + publication = ( + access is not None + and access.provider_capabilities.multi_requirement_check + and bool(request.app.state.agent_skill_targets) ) - return JSONResponse(content=readiness.model_dump(mode="json"), status_code=response_status) + family_capabilities = ",".join( + f"{profile.family}:{'enabled' if profile.enabled else 'disabled'}" + for profile in sorted(ARTIFACT_FAMILY_PROFILES.values(), key=lambda item: item.family) + ) + return { + "access_mode": mode, + "access_provider": provider, + "access_resource_kinds": ",".join(resource_type.value for resource_type in AccessResourceType), + "access_artifact_families": family_capabilities, + "access_skill_publication": "enabled" if publication else "disabled", + } async def get_capabilities(request: Request) -> Capabilities: @@ -833,9 +934,34 @@ async def get_capabilities(request: Request) -> Capabilities: return request.app.state.capabilities -async def get_access_principal(request: Request) -> TransportAccessPrincipal: - _require_access_control(request) - return _access_principal_response(_require_principal()) +async def get_access_principal(request: Request) -> AccessMeResponse: + access = _require_access_control(request) + provider = access.provider_capabilities + return AccessMeResponse( + principal=_access_principal_response(_require_principal()), + mode=TransportAccessControlMode(access.mode), + resource_kinds=[TransportAccessResourceType(resource_type.value) for resource_type in AccessResourceType], + provider_capabilities=AccessProviderCapabilities( + safe_resource_filtering=provider.safe_resource_filtering, + multi_requirement_check=provider.multi_requirement_check, + relationship_management=provider.relationship_management, + ), + artifact_families=[ + ArtifactFamilyAccessCapability( + family=profile.family, + enabled=profile.enabled, + share_unit=TransportShareUnit(profile.share_unit), + actions=[TransportAccessAction(action.value) for action in sorted(profile.actions, key=str)], + grantable_roles=[TransportAccessRole(role.value) for role in sorted(profile.grantable_roles, key=str)], + ) + for profile in ARTIFACT_FAMILY_PROFILES.values() + ], + operation_capabilities=AccessOperationCapabilities( + skill_publication=AccessOperationCapability( + enabled=provider.multi_requirement_check and bool(request.app.state.agent_skill_targets) + ) + ), + ) async def check_access(payload: AccessCheckRequest, request: Request) -> TransportAccessDecision: @@ -866,11 +992,14 @@ async def list_access_resources(payload: ListAccessResourcesRequest, request: Re _require_principal(), action=AccessAction(payload.action.value), resource_type=AccessResourceType(payload.resource_type.value), + family=payload.family, cursor=payload.cursor, limit=payload.limit, + context=_access_audit_context(LIST_ACCESS_RESOURCES.operation_id), ) return AccessResourcePage( items=[_access_resource_response(resource) for resource in page.items], + total=page.total, next_cursor=page.next_cursor, ) @@ -878,13 +1007,30 @@ async def list_access_resources(payload: ListAccessResourcesRequest, request: Re async def list_access_roles(payload: ListAccessRolesRequest, request: Request) -> AccessRolePage: _require_access_control(request) resource_type = None if payload.resource_type is None else AccessResourceType(payload.resource_type.value) - roles = [role for role in AccessRole if resource_type is None or ROLE_RESOURCE_TYPES[role] is resource_type] + roles = [ + role + for role in AccessRole + if (resource_type is None or ROLE_RESOURCE_TYPES[role] is resource_type) + and ( + ROLE_RESOURCE_TYPES[role] is not AccessResourceType.ARTIFACT + or any(profile.enabled and role in profile.grantable_roles for profile in ARTIFACT_FAMILY_PROFILES.values()) + ) + ] return AccessRolePage( items=[ TransportAccessRoleDescriptor( role=TransportAccessRole(role.value), resource_type=TransportAccessResourceType(ROLE_RESOURCE_TYPES[role].value), - actions=[TransportAccessAction(action.value) for action in sorted(ROLE_ACTIONS[role], key=str)], + actions=[ + TransportAccessAction(action.value) + for action in sorted(ROLE_ACTIONS[role], key=str) + if action is not AccessAction.ACCESS_SELF + ], + artifact_families=[ + TransportArtifactFamily(root=profile.family) + for profile in ARTIFACT_FAMILY_PROFILES.values() + if profile.enabled and role in profile.grantable_roles + ], ) for role in roles ] @@ -895,7 +1041,7 @@ async def list_access_bindings(payload: ListAccessBindingsRequest, request: Requ access = _require_access_control(request) principal = _require_principal() resource = None if payload.resource is None else _access_resource(payload.resource) - action, boundary = _binding_administrative_check(resource) + action, boundary = _binding_administrative_check(resource, deployment_id=access.deployment_id) await access.require( principal, action, @@ -924,6 +1070,7 @@ async def create_access_binding(payload: CreateAccessBindingRequest, request: Re expires_at=payload.expires_at, ), context=_access_audit_context(CREATE_ACCESS_BINDING.operation_id), + validate_resource=lambda resource: _validate_shareable_resource(request.app.state.application, resource), ) return _access_binding_response(binding) @@ -941,7 +1088,8 @@ async def revoke_access_binding(payload: RevokeAccessBindingRequest, request: Re async def list_access_audit(payload: ListAccessAuditRequest, request: Request) -> AccessAuditPage: access = _require_access_control(request) - events = await access.list_audit(after=payload.after, limit=payload.limit) + resource = None if payload.scope_id is None else ResourceRef.scope(payload.scope_id) + events = await access.list_audit(resource=resource, after=payload.after, limit=payload.limit) next_cursor = events[-1].cursor if len(events) == payload.limit else None return AccessAuditPage( items=[_access_audit_response(event) for event in events], @@ -1276,7 +1424,16 @@ async def handoff_current_work( async def acknowledge_handoff( request: AcknowledgeHandoffRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> HandoffAcknowledgement: + principal = current_principal() + if ( + http_request.app.state.access_control is not None + and request.status.value == "accepted" + and principal is not None + and request.receiver != principal.id + ): + raise AccessInvalidRequestError("receiver-principal") result = await application.work.for_scope(request.scope_id).acknowledge( mapping.acknowledge_handoff_request(request) ) @@ -1446,6 +1603,117 @@ async def get_skill( return mapping.skill_response(result) +async def list_skill_publication_targets( + request: ListSkillPublicationTargetsRequest, + http_request: Request, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ListSkillPublicationTargetsResponse: + await application.skill.for_scope(request.scope_id).get( + RuntimeGetSkillRequest(artifact=mapping.runtime_artifact_reference(request.artifact)) + ) + targets: tuple[AgentSkillTarget, ...] = http_request.app.state.agent_skill_targets + return ListSkillPublicationTargetsResponse( + artifact=request.artifact, + targets=[ + SkillPublicationTarget( + target_id=target.target_id, + agent_kind=TransportAgentKind(target.agent_kind), + installation_scope=TransportExternalSkillInstallationScope(target.installation_scope), + capabilities=[TransportSkillPublicationCapability.PUBLISH], + ) + for target in targets + ], + ) + + +async def publish_managed_skill( + request: PublishManagedSkillRequest, + http_request: Request, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ManagedSkillPublication: + skill = await application.skill.for_scope(request.scope_id).get( + RuntimeGetSkillRequest(artifact=mapping.runtime_artifact_reference(request.artifact)) + ) + targets: tuple[AgentSkillTarget, ...] = http_request.app.state.agent_skill_targets + target = next((candidate for candidate in targets if candidate.target_id == request.target_id), None) + if target is None: + raise _SkillPublicationTargetNotFoundError + expected = await asyncio.to_thread(inspect_skill_projection, skill.as_ref(), skill.content, target) + try: + published = await asyncio.to_thread( + publish_skill_projection, + skill.as_ref(), + skill.content, + target, + expected=expected, + ) + except AgentSkillProjectionConflictError as error: + raise _SkillPublicationConflictError from error + except (OSError, UnicodeError, ValueError) as error: + raise _SkillPublicationFailedError from error + try: + await application.external_skills.for_scope(request.scope_id).scan() + except Exception: + log_safely( + logger, + logging.WARNING, + "PowerContext external Skill scan failed after publication", + extra={"error_code": "external_skill_scan_failed"}, + ) + return ManagedSkillPublication( + artifact=request.artifact, + target_id=target.target_id, + agent_kind=TransportAgentKind(target.agent_kind), + installation_scope=TransportExternalSkillInstallationScope(target.installation_scope), + state=TransportManagedSkillPublicationState.PUBLISHED, + applied_revision=published.published_artifact.revision + if published.published_artifact is not None + else request.artifact.revision, + ) + + +async def _validate_shareable_resource(application: ServerApplication | None, resource: ResourceRef) -> None: + if resource.type is not AccessResourceType.ARTIFACT: + return + if application is None: + raise _RuntimeNotReadyError + profile = artifact_family_profile(resource) + reference = resource.reference + if reference is None or resource.scope_id is None: + raise AccessInvalidRequestError("artifact-reference") + artifact = ArtifactRef( + family=reference.family, + artifact_id=reference.artifact_id, + revision=reference.revision, + ) + if profile.family == "handoff": + await application.handoff.for_scope(resource.scope_id).revision(artifact) + return + if profile.family == "memory": + selector = resource.selector + if selector is None: + raise AccessInvalidRequestError("memory-entry-selector") + entry = await application.memory.for_scope(resource.scope_id).get( + RuntimeGetMemoryEntryRequest( + citation=RuntimeMemoryCitation( + memory_ref=artifact, + entry_id=selector.entry_id, + entry_version_id=selector.entry_version_id, + ) + ) + ) + if entry.state != "active": + raise AccessInvalidRequestError("artifact-state") + return + if profile.family == "experience": + await application.experience.for_scope(resource.scope_id).get(RuntimeGetExperienceRequest(artifact=artifact)) + return + if profile.family == "skill": + await application.skill.for_scope(resource.scope_id).get(RuntimeGetSkillRequest(artifact=artifact)) + return + raise AccessInvalidRequestError("artifact-family-disabled") + + async def scan_external_skills( request: ScanExternalSkillsRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -1591,27 +1859,57 @@ def _access_principal_response(value: PrincipalRef) -> TransportAccessPrincipal: def _access_resource(value: TransportAccessResource) -> ResourceRef: - resource_type = AccessResourceType(value.type.value) - if resource_type is AccessResourceType.SERVER: - return ResourceRef.server() - if resource_type is AccessResourceType.SCOPE: - return ResourceRef.scope(value.scope_id or "") - return ResourceRef( - type=AccessResourceType.HANDOFF, - scope_id=value.scope_id, - family=value.family, - artifact_id=value.artifact_id, - revision=value.revision, + resource = value.root + if isinstance(resource, ServerAccessResource): + return ResourceRef.server(resource.deployment_id) + if isinstance(resource, ScopeAccessResource): + return ResourceRef.scope(resource.scope_id) + selector = ( + None + if resource.selector is None + else MemoryEntrySelector( + entry_id=resource.selector.entry_id, + entry_version_id=resource.selector.entry_version_id, + ) + ) + return ResourceRef.artifact( + resource.scope_id, + family=resource.reference.family, + artifact_id=resource.reference.artifact_id, + revision=resource.reference.revision, + selector=selector, ) def _access_resource_response(value: ResourceRef) -> TransportAccessResource: + if value.type is AccessResourceType.SERVER: + return TransportAccessResource( + root=ServerAccessResource(type="server", deployment_id=value.deployment_id or "") + ) + if value.type is AccessResourceType.SCOPE: + return TransportAccessResource(root=ScopeAccessResource(type="scope", scope_id=value.scope_id or "")) + if value.reference is None: + raise AccessUnavailableError + selector = value.selector return TransportAccessResource( - type=TransportAccessResourceType(value.type.value), - scope_id=value.scope_id, - family=value.family, - artifact_id=value.artifact_id, - revision=value.revision, + root=ArtifactAccessResource( + type="artifact", + scope_id=value.scope_id or "", + reference=TransportArtifactReference( + family=value.reference.family, + artifact_id=value.reference.artifact_id, + revision=value.reference.revision, + ), + selector=( + None + if selector is None + else MemoryEntryAccessSelector( + type=TransportMemoryEntrySelectorType.MEMORY_ENTRY, + entry_id=selector.entry_id, + entry_version_id=selector.entry_version_id, + ) + ), + ) ) @@ -1664,15 +1962,26 @@ def _access_audit_response(value: AccessAuditEvent) -> TransportAccessAuditEvent ) -def _binding_administrative_check(resource: ResourceRef | None) -> tuple[AccessAction, ResourceRef]: +def _binding_administrative_check( + resource: ResourceRef | None, + *, + deployment_id: str, +) -> tuple[AccessAction, ResourceRef]: if resource is None or resource.type is AccessResourceType.SERVER: - return AccessAction.SERVER_ADMIN, ResourceRef.server() + if resource is not None and resource.deployment_id != deployment_id: + raise AccessInvalidRequestError("deployment") + return AccessAction.SERVER_ADMIN, ResourceRef.server(deployment_id) if resource.type is AccessResourceType.SCOPE: return AccessAction.SCOPE_ADMIN, resource parent = resource.parent_scope if parent is None: - raise AccessInvalidRequestError("handoff-reference") - return AccessAction.SCOPE_DELEGATE, parent + raise AccessInvalidRequestError("artifact-reference") + action = ( + AccessAction.SCOPE_DELEGATE + if artifact_family_profile(resource).family == "handoff" + else AccessAction.SCOPE_ADMIN + ) + return action, parent def _project_descriptor_response(value: DomainProjectDescriptor) -> ProjectDescriptor: @@ -1714,13 +2023,13 @@ async def authorize(request: Request) -> None: access: AccessControlService | None = request.app.state.access_control if access is not None: payload = await _authorization_payload(request, operation) - action, resource = _resolve_access_requirement(requirement, payload) - await access.require( - current_principal(), - action, - resource, - context=_access_audit_context(operation.operation_id), - ) + checks = _resolve_access_requirements(requirement, payload, deployment_id=access.deployment_id) + context = _access_audit_context(operation.operation_id) + if len(checks) == 1: + action, resource = checks[0] + await access.require(current_principal(), action, resource, context=context) + else: + await access.require_all(current_principal(), checks, context=context) return authorize @@ -1736,36 +2045,155 @@ async def _authorization_payload(request: Request, operation: Operation[Any, Any raise AccessInvalidRequestError("resource") from error if not isinstance(value, dict): raise AccessInvalidRequestError("resource") - return value + request_type = operation.request_type + if request_type is None: + return value + try: + validated = request_type.model_validate(value) + except ValueError as error: + raise AccessInvalidRequestError("resource") from error + return cast(Mapping[str, Any], validated.model_dump(mode="json")) -def _resolve_access_requirement( +def _resolve_access_requirements( requirement: AccessRequirement, payload: Mapping[str, Any], -) -> tuple[AccessAction, ResourceRef]: + *, + deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: if requirement.resolver == "static": - return AccessAction(requirement.action), ResourceRef.server() + if requirement.action is None: + raise AccessInvalidRequestError("resource") + return ((AccessAction(requirement.action), ResourceRef.server(deployment_id)),) if requirement.resolver == "request": + if requirement.action is None: + raise AccessInvalidRequestError("resource") scope_id = _nested_request_value(payload, requirement.scope_id_field) - return AccessAction(requirement.action), ResourceRef.scope(scope_id) + return ((AccessAction(requirement.action), ResourceRef.scope(scope_id)),) + resolver = _NAMED_ACCESS_RESOLVERS.get(requirement.resolver) + if resolver is None: + raise AccessInvalidRequestError("resource") + return resolver(payload, deployment_id) + + +def _continue_handoff_access(payload: Mapping[str, Any]) -> tuple[tuple[AccessAction, ResourceRef], ...]: scope_id = _nested_request_value(payload, "scope_id") selection = str(_nested_request_value(payload, "selection")) if selection != "exact": - return AccessAction(requirement.action), ResourceRef.scope(scope_id) - revision = payload.get("revision") - if not isinstance(revision, Mapping): - raise AccessInvalidRequestError("handoff-reference") - resource = ResourceRef( - type=AccessResourceType.HANDOFF, - scope_id=scope_id, - family=_mapping_text(revision, "family"), - artifact_id=_mapping_text(revision, "artifact_id"), - revision=_mapping_revision(revision), + return ((AccessAction.SCOPE_READ, ResourceRef.scope(scope_id)),) + resource = _artifact_resource(payload, "revision", family="handoff") + return ( + (AccessAction.ARTIFACT_READ, resource), + (AccessAction.HANDOFF_EVIDENCE_READ, resource), ) - action = ( - AccessAction.HANDOFF_ACKNOWLEDGE if requirement.resolver == "acknowledge_handoff" else AccessAction.HANDOFF_READ + + +def _acknowledge_handoff_access(payload: Mapping[str, Any]) -> tuple[tuple[AccessAction, ResourceRef], ...]: + scope_id = _nested_request_value(payload, "scope_id") + selection = str(_nested_request_value(payload, "selection")) + if selection != "exact": + return ((AccessAction.SCOPE_CONTRIBUTE, ResourceRef.scope(scope_id)),) + return ((AccessAction.HANDOFF_ACKNOWLEDGE, _artifact_resource(payload, "revision", family="handoff")),) + + +def _artifact_resource(payload: Mapping[str, Any], field: str, *, family: str) -> ResourceRef: + reference = payload.get(field) + if not isinstance(reference, Mapping) or _mapping_text(reference, "family") != family: + raise AccessInvalidRequestError("artifact-reference") + return ResourceRef.artifact( + _nested_request_value(payload, "scope_id"), + family=family, + artifact_id=_mapping_text(reference, "artifact_id"), + revision=_mapping_revision(reference), + ) + + +def _memory_artifact_resource(payload: Mapping[str, Any]) -> ResourceRef: + citation = payload.get("citation") + if not isinstance(citation, Mapping): + raise AccessInvalidRequestError("memory-entry-selector") + reference = citation.get("memory_ref") + if not isinstance(reference, Mapping) or _mapping_text(reference, "family") != "memory": + raise AccessInvalidRequestError("artifact-reference") + return ResourceRef.artifact( + _nested_request_value(payload, "scope_id"), + family="memory", + artifact_id=_mapping_text(reference, "artifact_id"), + revision=_mapping_revision(reference), + selector=MemoryEntrySelector( + entry_id=_mapping_text(citation, "entry_id"), + entry_version_id=_mapping_text(citation, "entry_version_id"), + ), ) - return action, resource + + +def _exact_memory_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return ((AccessAction.ARTIFACT_READ, _memory_artifact_resource(payload)),) + + +def _exact_experience_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return ((AccessAction.ARTIFACT_READ, _artifact_resource(payload, "artifact", family="experience")),) + + +def _exact_skill_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return ((AccessAction.ARTIFACT_READ, _artifact_resource(payload, "artifact", family="skill")),) + + +def _publish_managed_skill_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + resource = _artifact_resource(payload, "artifact", family="skill") + return ((AccessAction.ARTIFACT_READ, resource), (AccessAction.SKILL_PUBLISH, resource)) + + +def _access_audit_access( + payload: Mapping[str, Any], + deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + scope_id = payload.get("scope_id") + if scope_id is None: + return ((AccessAction.SERVER_ADMIN, ResourceRef.server(deployment_id)),) + if not isinstance(scope_id, str) or not scope_id: + raise AccessInvalidRequestError("resource") + return ((AccessAction.SCOPE_ADMIN, ResourceRef.scope(scope_id)),) + + +def _continue_handoff_resolver( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return _continue_handoff_access(payload) + + +def _acknowledge_handoff_resolver( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return _acknowledge_handoff_access(payload) + + +_NAMED_ACCESS_RESOLVERS: dict[ + str, + Callable[[Mapping[str, Any], str], tuple[tuple[AccessAction, ResourceRef], ...]], +] = { + "access_audit_access": _access_audit_access, + "acknowledge_handoff_access": _acknowledge_handoff_resolver, + "continue_handoff_access": _continue_handoff_resolver, + "exact_experience_access": _exact_experience_access, + "exact_memory_access": _exact_memory_access, + "exact_skill_access": _exact_skill_access, + "publish_managed_skill_access": _publish_managed_skill_access, +} def _nested_request_value(payload: Mapping[str, Any], field: str | None) -> str: @@ -1785,14 +2213,14 @@ def _nested_request_value(payload: Mapping[str, Any], field: str | None) -> str: def _mapping_text(value: Mapping[str, Any], field: str) -> str: item = value.get(field) if not isinstance(item, str) or not item: - raise AccessInvalidRequestError("handoff-reference") + raise AccessInvalidRequestError("artifact-reference") return item def _mapping_revision(value: Mapping[str, Any]) -> int: revision = value.get("revision") if not isinstance(revision, int) or isinstance(revision, bool) or revision < 1: - raise AccessInvalidRequestError("handoff-reference") + raise AccessInvalidRequestError("artifact-reference") return revision @@ -1821,16 +2249,17 @@ async def observed_endpoint(*args: Any, **kwargs: Any) -> _ResponseT | Response: except Exception as error: _observe_application(app, operation, "failure", started_at) response_status, error_code, _, _ = _map_error(error) + diagnostic_error = None if _sensitive_operation_error(error) else error _log_operation( logging.ERROR if response_status >= status.HTTP_500_INTERNAL_SERVER_ERROR else logging.WARNING, "PowerContext application operation failed", operation=operation.operation_id, outcome="failure", started_at=started_at, - error=error, + error=diagnostic_error, error_code=error_code, ) - _finish_span(span, "failure", error=error) + _finish_span(span, "failure", error=diagnostic_error) raise outcome = _application_outcome(result) _observe_application(app, operation, outcome, started_at) @@ -1840,6 +2269,18 @@ async def observed_endpoint(*args: Any, **kwargs: Any) -> _ResponseT | Response: return observed_endpoint +def _sensitive_operation_error(error: Exception) -> bool: + return isinstance( + error, + ( + AccessControlError, + _SkillPublicationTargetNotFoundError, + _SkillPublicationConflictError, + _SkillPublicationFailedError, + ), + ) + + def _start_application_span(app: FastAPI, operation: Operation[Any, Any]) -> Any | None: if "health" in operation.tags: return None @@ -1931,8 +2372,9 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: access_error = _map_access_error(error) if access_error is not None: return access_error - if isinstance(error, _RuntimeNotReadyError): - return status.HTTP_503_SERVICE_UNAVAILABLE, "runtime_not_ready", "The Runtime is not ready.", None + publication_error = _map_skill_publication_error(error) + if publication_error is not None: + return publication_error if isinstance(error, ExternalSkillRegistryUnavailableError): return ( status.HTTP_503_SERVICE_UNAVAILABLE, @@ -1968,6 +2410,31 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: return _map_domain_error(error) +def _map_skill_publication_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, _SkillPublicationTargetNotFoundError): + return ( + status.HTTP_404_NOT_FOUND, + "skill_publication_target_not_found", + "The publication target was not found.", + None, + ) + if isinstance(error, _SkillPublicationConflictError): + return ( + status.HTTP_409_CONFLICT, + "skill_publication_conflict", + "The publication target changed or conflicts.", + None, + ) + if isinstance(error, _SkillPublicationFailedError): + return ( + status.HTTP_503_SERVICE_UNAVAILABLE, + "skill_publication_failed", + "Skill publication failed.", + None, + ) + return None + + def _map_access_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: if isinstance(error, AccessIdentityRequiredError): return status.HTTP_401_UNAUTHORIZED, "unauthorized", "An authenticated Principal is required.", None @@ -1978,7 +2445,7 @@ def _map_access_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | if isinstance(error, AccessInvalidRequestError): return status.HTTP_422_UNPROCESSABLE_CONTENT, "invalid_access_request", "The Access request is invalid.", None if isinstance(error, AccessUnavailableError): - return status.HTTP_503_SERVICE_UNAVAILABLE, "access_unavailable", "Access Control is unavailable.", None + return status.HTTP_503_SERVICE_UNAVAILABLE, error.code, "Access Control is unavailable.", None return None diff --git a/src/powercontext/server/authz/__init__.py b/src/powercontext/server/authz/__init__.py index 44cc425ee..810467f07 100644 --- a/src/powercontext/server/authz/__init__.py +++ b/src/powercontext/server/authz/__init__.py @@ -14,21 +14,28 @@ """Server-owned authentication and authorization building blocks.""" +from powercontext.server.authz.authzen import AuthZenAuthorizationProvider +from powercontext.server.authz.casbin import CasbinAuthorizationProvider from powercontext.server.authz.errors import ( AccessConflictError, + AccessControlError, AccessDeniedError, AccessIdentityRequiredError, AccessInvalidRequestError, AccessUnavailableError, ) from powercontext.server.authz.models import ( + DEFAULT_DEPLOYMENT_ID, + PUBLIC_ACCESS_ACTIONS, AccessAction, + AccessArtifactReference, AccessAuditEvent, AccessBinding, AccessBindingState, AccessDecision, AccessResourceType, AccessRole, + MemoryEntrySelector, PrincipalRef, ResourceRef, ) @@ -36,34 +43,49 @@ AccessAuditContext, AccessAuditStore, AccessControlService, + AccessProviderCapabilities, + AccessRequest, AuthorizationProvider, + AuthorizedResourceFilter, AuthorizedResourcePage, BuiltinAuthorizationProvider, CreateBinding, RelationshipWriter, + ResourceSearchRequest, ) __all__ = ( + "DEFAULT_DEPLOYMENT_ID", + "PUBLIC_ACCESS_ACTIONS", "AccessAction", + "AccessArtifactReference", "AccessAuditContext", "AccessAuditEvent", "AccessAuditStore", "AccessBinding", "AccessBindingState", "AccessConflictError", + "AccessControlError", "AccessControlService", "AccessDecision", "AccessDeniedError", "AccessIdentityRequiredError", "AccessInvalidRequestError", + "AccessProviderCapabilities", + "AccessRequest", "AccessResourceType", "AccessRole", "AccessUnavailableError", + "AuthZenAuthorizationProvider", "AuthorizationProvider", + "AuthorizedResourceFilter", "AuthorizedResourcePage", "BuiltinAuthorizationProvider", + "CasbinAuthorizationProvider", "CreateBinding", + "MemoryEntrySelector", "PrincipalRef", "RelationshipWriter", "ResourceRef", + "ResourceSearchRequest", ) diff --git a/src/powercontext/server/authz/authzen.py b/src/powercontext/server/authz/authzen.py new file mode 100644 index 000000000..90e3643a6 --- /dev/null +++ b/src/powercontext/server/authz/authzen.py @@ -0,0 +1,203 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Decision-only OpenID AuthZEN Authorization API 1.0 adapter.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, TypeGuard + +import httpx +from pydantic import SecretStr + +from powercontext.server.authz.errors import AccessUnavailableError +from powercontext.server.authz.models import AccessDecision, MemoryEntrySelector, ResourceRef +from powercontext.server.authz.service import ( + AccessRequest, + AuthorizedResourceFilter, + ResourceSearchRequest, +) +from powercontext.transport import is_plaintext_non_loopback + +_EVALUATION_PATH = "/access/v1/evaluation" +_EVALUATIONS_PATH = "/access/v1/evaluations" + + +class AuthZenAuthorizationProvider: + """Call an AuthZEN PDP without claiming relationship or search capabilities. + + Only the standard decision boolean and a bounded optional ``policy_revision`` context value + cross back into PowerContext. Provider response bodies, URLs, rules, obligations, and errors + never become public reason codes or Access Audit fields. + """ + + def __init__( + self, + base_url: str, + *, + token: SecretStr | None = None, + timeout: float = 10.0, + http_client: httpx.AsyncClient | None = None, + ) -> None: + normalized = _authzen_base_url(base_url) + self._base_url = normalized + self._headers = None if token is None else {"Authorization": f"Bearer {token.get_secret_value()}"} + self._owned_client = None if http_client is not None else httpx.AsyncClient(timeout=timeout) + resolved_client = http_client or self._owned_client + if resolved_client is None: + raise AccessUnavailableError # pragma: no cover - construction guarantees a client. + self._client: httpx.AsyncClient = resolved_client + + async def __aenter__(self) -> AuthZenAuthorizationProvider: + return self + + async def __aexit__(self, *_exc_info: object) -> None: + await self.aclose() + + async def aclose(self) -> None: + if self._owned_client is not None: + await self._owned_client.aclose() + + async def check(self, request: AccessRequest, /) -> AccessDecision: + payload = await self._post(_EVALUATION_PATH, _access_request(request)) + return _decision(payload) + + async def check_batch( + self, + requests: Sequence[AccessRequest], + /, + ) -> tuple[AccessDecision, ...]: + if not requests: + return () + payload = await self._post( + _EVALUATIONS_PATH, + { + "evaluations": [_access_request(request) for request in requests], + "options": {"evaluations_semantic": "execute_all"}, + }, + ) + values = payload.get("evaluations") + if not isinstance(values, list) or len(values) != len(requests): + raise AccessUnavailableError + return tuple(_decision(value) for value in values) + + async def resolve_resource_filter( + self, + request: ResourceSearchRequest, + /, + ) -> AuthorizedResourceFilter: + del request + raise AccessUnavailableError("safe_resource_filtering_unavailable") + + async def _post(self, path: str, payload: Mapping[str, object]) -> Mapping[str, Any]: + try: + response = await self._client.post(f"{self._base_url}{path}", headers=self._headers, json=payload) + response.raise_for_status() + value = response.json() + except (httpx.HTTPError, ValueError, TypeError) as error: + raise AccessUnavailableError from error + if not isinstance(value, Mapping): + raise AccessUnavailableError + return value + + +def _access_request(request: AccessRequest) -> dict[str, object]: + return { + "subject": { + "type": request.subject.type, + "id": request.subject.id, + "properties": {"issuer": request.subject.issuer}, + }, + "action": {"name": request.action.value}, + "resource": _resource(request.resource), + "context": { + "request_id": request.context.request_id, + "transport": request.context.transport, + "operation": request.context.operation, + }, + } + + +def _resource(resource: ResourceRef) -> dict[str, object]: + properties: dict[str, object] = {} + if resource.deployment_id is not None: + properties["deployment_id"] = resource.deployment_id + if resource.scope_id is not None: + properties["scope_id"] = resource.scope_id + if resource.reference is not None: + properties["reference"] = { + "family": resource.reference.family, + "artifact_id": resource.reference.artifact_id, + "revision": resource.reference.revision, + } + if resource.selector is not None: + properties["selector"] = _selector(resource.selector) + return {"type": resource.type.value, "id": resource.key, "properties": properties} + + +def _selector(selector: MemoryEntrySelector) -> dict[str, str]: + return { + "type": selector.type, + "entry_id": selector.entry_id, + "entry_version_id": selector.entry_version_id, + } + + +def _decision(value: object) -> AccessDecision: + if not isinstance(value, Mapping): + raise AccessUnavailableError + decision = value.get("decision") + if type(decision) is not bool: + raise AccessUnavailableError + allowed = decision + context = value.get("context") + policy_revision: str | None = None + if isinstance(context, Mapping): + candidate = context.get("policy_revision") + if candidate is not None: + if not _valid_policy_revision(candidate): + raise AccessUnavailableError + policy_revision = candidate + return AccessDecision( + allowed=allowed, + reason_code="authzen-allow" if allowed else "authzen-deny", + policy_revision=policy_revision, + ) + + +def _valid_policy_revision(value: object) -> TypeGuard[str]: + return ( + isinstance(value, str) + and 0 < len(value) <= 128 + and value[0].isalnum() + and all(character.isascii() and (character.isalnum() or character in "._-") for character in value) + ) + + +def _authzen_base_url(value: str) -> str: + url = httpx.URL(value) + if ( + url.scheme not in {"http", "https"} + or not url.host + or url.userinfo + or url.query + or url.fragment + or is_plaintext_non_loopback(str(url)) + ): + raise ValueError("AuthZEN base URL must be credential-free HTTPS or loopback HTTP") # noqa: TRY003 + return str(url).rstrip("/") + + +__all__ = ("AuthZenAuthorizationProvider",) diff --git a/src/powercontext/server/authz/casbin.py b/src/powercontext/server/authz/casbin.py new file mode 100644 index 000000000..8875f8136 --- /dev/null +++ b/src/powercontext/server/authz/casbin.py @@ -0,0 +1,232 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Embedded Casbin adapter over the canonical PowerContext Binding Store.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from datetime import UTC, datetime + +import casbin + +from powercontext.server.authz.errors import AccessInvalidRequestError +from powercontext.server.authz.models import ( + DEFAULT_DEPLOYMENT_ID, + ROLE_ACTIONS, + AccessAction, + AccessBinding, + AccessDecision, + AccessResourceType, + PrincipalRef, + ResourceRef, +) +from powercontext.server.authz.service import ( + AccessRepository, + AccessRequest, + AuthorizedResourceFilter, + ResourceSearchRequest, +) + +_MODEL = """ +[request_definition] +r = sub, act, obj, scope, deployment + +[policy_definition] +p = sub, act, obj, scope, deployment + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub == p.sub && (p.act == "*" || r.act == p.act) && (p.obj == "*" || r.obj == p.obj) && (p.scope == "*" || r.scope == p.scope) && r.deployment == p.deployment +""" + + +class CasbinAuthorizationProvider: + """Evaluate canonical role bindings with an embedded Casbin policy model. + + The relational Binding Store is the persistent Casbin adapter: each decision materializes only + the current Principal's active, opaque relationships into a short-lived enforcer. This avoids + copying business content or maintaining a second policy shadow while preserving the same CAS, + idempotency, expiry, audit, and safe-list semantics as the built-in reference provider. + """ + + def __init__( + self, + repository: AccessRepository, + *, + bootstrap_administrators: Sequence[PrincipalRef] = (), + deployment_id: str = DEFAULT_DEPLOYMENT_ID, + clock: Callable[[], datetime] | None = None, + ) -> None: + self._repository = repository + self._bootstrap_administrators = frozenset(bootstrap_administrators) + self._deployment_id = deployment_id + self._clock = clock or (lambda: datetime.now(UTC)) + + async def check(self, request: AccessRequest, /) -> AccessDecision: + decisions = await self.check_batch((request,)) + return decisions[0] + + async def check_batch( + self, + requests: Sequence[AccessRequest], + /, + ) -> tuple[AccessDecision, ...]: + revision = await self._repository.policy_revision() + if not requests: + return () + principal = requests[0].subject + if any(request.subject != principal for request in requests): + raise AccessInvalidRequestError("batch-subject") + bindings = await self._repository.active_bindings(principal, now=self._clock()) + enforcer = _enforcer( + principal, + bindings, + bootstrap=principal in self._bootstrap_administrators, + deployment_id=self._deployment_id, + ) + decisions: list[AccessDecision] = [] + for request in requests: + if request.action is AccessAction.ACCESS_SELF: + decisions.append(AccessDecision(True, "authenticated", revision)) + continue + allowed = bool(enforcer.enforce(*_casbin_request(request, self._deployment_id))) + decisions.append( + AccessDecision( + allowed=allowed, + reason_code="casbin-policy" if allowed else "no-matching-policy", + policy_revision=revision, + ) + ) + return tuple(decisions) + + async def resolve_resource_filter( + self, + request: ResourceSearchRequest, + /, + ) -> AuthorizedResourceFilter: + revision = await self._repository.policy_revision() + if request.subject in self._bootstrap_administrators: + return AuthorizedResourceFilter( + exact_resources=(ResourceRef.server(self._deployment_id),) + if request.resource_type is AccessResourceType.SERVER + else (), + parent_constraints=(ResourceRef.server(self._deployment_id),), + policy_revision=revision, + ) + bindings = await self._repository.active_bindings(request.subject, now=self._clock()) + exact: dict[str, ResourceRef] = {} + parents: dict[str, ResourceRef] = {} + for binding in bindings: + if request.action not in ROLE_ACTIONS[binding.role]: + continue + resource = binding.resource + if resource.type is request.resource_type and (request.family is None or resource.family == request.family): + exact[resource.key] = resource + elif _resource_is_parent(resource, request.resource_type): + parents[resource.key] = resource + return AuthorizedResourceFilter( + exact_resources=tuple(exact[key] for key in sorted(exact)), + parent_constraints=tuple(parents[key] for key in sorted(parents)), + policy_revision=revision, + ) + + async def get_binding(self, binding_id: str) -> AccessBinding | None: + return await self._repository.get_binding(binding_id) + + async def list_bindings( + self, + *, + subject: PrincipalRef | None = None, + resource: ResourceRef | None = None, + include_revoked: bool = False, + ) -> tuple[AccessBinding, ...]: + return await self._repository.list_bindings( + subject=subject, + resource=resource, + include_revoked=include_revoked, + ) + + async def create_binding(self, binding: AccessBinding) -> AccessBinding: + return await self._repository.create_binding(binding) + + async def revoke_binding( + self, + binding_id: str, + *, + expected_version: int, + revoked_at: datetime, + revoked_by: PrincipalRef, + ) -> AccessBinding: + return await self._repository.revoke_binding( + binding_id, + expected_version=expected_version, + revoked_at=revoked_at, + revoked_by=revoked_by, + ) + + +def _enforcer( + principal: PrincipalRef, + bindings: Sequence[AccessBinding], + *, + bootstrap: bool, + deployment_id: str, +) -> casbin.Enforcer: + model = casbin.Model() + model.load_model_from_text(_MODEL) + enforcer = casbin.Enforcer(model) + policies: list[list[str]] = [] + if bootstrap: + policies.append([principal.key, "*", "*", "*", deployment_id]) + for binding in bindings: + obj, scope, deployment = _casbin_policy_resource(binding.resource, deployment_id) + policies.extend([principal.key, action.value, obj, scope, deployment] for action in ROLE_ACTIONS[binding.role]) + if policies: + enforcer.add_policies(policies) + return enforcer + + +def _casbin_request(request: AccessRequest, deployment_id: str) -> tuple[str, str, str, str, str]: + resource = request.resource + return ( + request.subject.key, + request.action.value, + resource.key, + resource.scope_id or "", + resource.deployment_id or deployment_id, + ) + + +def _casbin_policy_resource(resource: ResourceRef, deployment_id: str) -> tuple[str, str, str]: + if resource.type is AccessResourceType.SERVER: + return ( + "*" if resource.deployment_id == deployment_id else resource.key, + "*", + resource.deployment_id or deployment_id, + ) + if resource.type is AccessResourceType.SCOPE: + return "*", resource.scope_id or "", deployment_id + return resource.key, resource.scope_id or "", deployment_id + + +def _resource_is_parent(resource: ResourceRef, requested_type: AccessResourceType) -> bool: + return resource.type is AccessResourceType.SERVER or ( + resource.type is AccessResourceType.SCOPE and requested_type is AccessResourceType.ARTIFACT + ) + + +__all__ = ("CasbinAuthorizationProvider",) diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py index 3f4efc385..8dbcaee5b 100644 --- a/src/powercontext/server/authz/composition.py +++ b/src/powercontext/server/authz/composition.py @@ -18,14 +18,16 @@ from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager +from typing import Literal from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.runtime.composition import BuiltinConfigurationError from powercontext.builtin.runtime.config import DatabaseConfig -from powercontext.server.authz.models import PrincipalRef -from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.authz.casbin import CasbinAuthorizationProvider +from powercontext.server.authz.models import DEFAULT_DEPLOYMENT_ID, PrincipalRef +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository, ensure_access_schema from powercontext.server.authz.service import AccessControlService, BuiltinAuthorizationProvider @@ -34,9 +36,57 @@ async def open_builtin_access_control( database: DatabaseConfig, *, bootstrap_administrators: Sequence[PrincipalRef] = (), + deployment_id: str = DEFAULT_DEPLOYMENT_ID, + mode: Literal["legacy-static-admin", "enforced"] = "enforced", ) -> AsyncIterator[AccessControlService]: """Open a Server-owned Access schema without coupling it to Runtime domains.""" + async with _open_access_repository(database, deployment_id=deployment_id) as repository: + provider = BuiltinAuthorizationProvider( + repository, + bootstrap_administrators=bootstrap_administrators, + deployment_id=deployment_id, + ) + yield AccessControlService( + provider, + relationships=repository, + audit=repository, + deployment_id=deployment_id, + mode=mode, + ) + + +@asynccontextmanager +async def open_casbin_access_control( + database: DatabaseConfig, + *, + bootstrap_administrators: Sequence[PrincipalRef] = (), + deployment_id: str = DEFAULT_DEPLOYMENT_ID, + mode: Literal["legacy-static-admin", "enforced"] = "enforced", +) -> AsyncIterator[AccessControlService]: + """Open the writable embedded Casbin adapter over the canonical Access schema.""" + + async with _open_access_repository(database, deployment_id=deployment_id) as repository: + provider = CasbinAuthorizationProvider( + repository, + bootstrap_administrators=bootstrap_administrators, + deployment_id=deployment_id, + ) + yield AccessControlService( + provider, + relationships=provider, + audit=repository, + deployment_id=deployment_id, + mode=mode, + ) + + +@asynccontextmanager +async def _open_access_repository( + database: DatabaseConfig, + *, + deployment_id: str, +) -> AsyncIterator[RelationalAccessRepository]: if isinstance(database, SQLiteConfig): profile_context = SQLiteProfile.open(database, tables=ACCESS_TABLES) elif isinstance(database, OceanBaseConfig): @@ -46,12 +96,9 @@ async def open_builtin_access_control( else: raise BuiltinConfigurationError("database") async with profile_context as profile: - repository = RelationalAccessRepository(profile.database) - provider = BuiltinAuthorizationProvider( - repository, - bootstrap_administrators=bootstrap_administrators, - ) - yield AccessControlService(provider, relationships=repository, audit=repository) + async with profile.database.transaction() as connection: + await ensure_access_schema(connection, deployment_id=deployment_id) + yield RelationalAccessRepository(profile.database) -__all__ = ("open_builtin_access_control",) +__all__ = ("open_builtin_access_control", "open_casbin_access_control") diff --git a/src/powercontext/server/authz/errors.py b/src/powercontext/server/authz/errors.py index 5f48c2301..872c8f40a 100644 --- a/src/powercontext/server/authz/errors.py +++ b/src/powercontext/server/authz/errors.py @@ -38,8 +38,15 @@ def __init__(self) -> None: class AccessUnavailableError(AccessControlError, RuntimeError): """A required authorization dependency is unavailable.""" - def __init__(self) -> None: - super().__init__("the authorization service is unavailable") + def __init__(self, code: str = "access_unavailable") -> None: + self.code = code + messages = { + "access_unavailable": "the authorization service is unavailable", + "multi_requirement_check_unavailable": "multi-requirement Access checks are unavailable", + "relationship_management_unavailable": "Access relationship management is unavailable", + "safe_resource_filtering_unavailable": "safe Access resource filtering is unavailable", + } + super().__init__(messages.get(code, messages["access_unavailable"])) class AccessConflictError(AccessControlError, RuntimeError): @@ -60,11 +67,23 @@ class AccessInvalidRequestError(AccessControlError, ValueError): def __init__(self, code: str) -> None: self.code = code messages = { + "action-resource": "the action is not valid for this Access resource", + "artifact-family": "the Artifact Family is not registered for Access sharing", + "artifact-family-disabled": "the Artifact Family Access Profile is disabled", + "artifact-reference": "an Artifact resource requires one exact ArtifactReference", + "artifact-selector": "the Artifact Family does not accept this selector", + "artifact-state": "the Artifact resource is not in a shareable lifecycle state", "binding-role": "the role cannot be bound to this resource type", "binding-expired": "expires_at must be later than the current Server time", + "cursor": "the Access cursor is invalid", + "deployment": "the Server resource does not identify this deployment", "handoff-reference": "a Handoff resource requires one exact Handoff ArtifactReference", + "idempotency-key": "the Access Binding idempotency key is invalid", + "memory-entry-selector": "a Memory Access resource requires one exact Memory Entry Version selector", "principal": "the Access Principal is invalid", "resource": "the Access resource is invalid", + "receiver-principal": "an accepted Handoff receiver must match the authenticated Principal", + "reason": "the Access Binding reason exceeds its limit", } super().__init__(messages.get(code, f"invalid Access request: {code}")) diff --git a/src/powercontext/server/authz/models.py b/src/powercontext/server/authz/models.py index 65c8f6250..03d920c42 100644 --- a/src/powercontext/server/authz/models.py +++ b/src/powercontext/server/authz/models.py @@ -16,16 +16,20 @@ from __future__ import annotations +import json from dataclasses import dataclass from datetime import datetime from enum import StrEnum from powercontext.server.authz.errors import AccessInvalidRequestError +DEFAULT_DEPLOYMENT_ID = "powercontext" + class AccessAction(StrEnum): """Stable actions checked by Server business operations.""" + # Internal authentication-only requirement used by Access self-service routes. ACCESS_SELF = "access.self" SERVER_OBSERVE = "server.observe" SERVER_ADMIN = "server.admin" @@ -34,17 +38,22 @@ class AccessAction(StrEnum): SCOPE_REVIEW = "scope.review" SCOPE_DELEGATE = "scope.delegate" SCOPE_ADMIN = "scope.admin" - HANDOFF_READ = "handoff.read" + ARTIFACT_READ = "artifact.read" HANDOFF_EVIDENCE_READ = "handoff.evidence.read" HANDOFF_ACKNOWLEDGE = "handoff.acknowledge" + PROMPT_USE = "prompt.use" + SKILL_PUBLISH = "skill.publish" + + +PUBLIC_ACCESS_ACTIONS = tuple(action for action in AccessAction if action is not AccessAction.ACCESS_SELF) class AccessResourceType(StrEnum): - """Resource types understood by the first authorization profile.""" + """Stable Resource Kinds understood by the authorization boundary.""" SERVER = "server" SCOPE = "scope" - HANDOFF = "handoff" + ARTIFACT = "artifact" class AccessRole(StrEnum): @@ -52,6 +61,9 @@ class AccessRole(StrEnum): HANDOFF_VIEWER = "handoff.viewer" HANDOFF_RECEIVER = "handoff.receiver" + ARTIFACT_VIEWER = "artifact.viewer" + PROMPT_USER = "prompt.user" + SKILL_PUBLISHER = "skill.publisher" SCOPE_VIEWER = "scope.viewer" SCOPE_CONTRIBUTOR = "scope.contributor" SCOPE_REVIEWER = "scope.reviewer" @@ -77,12 +89,45 @@ class PrincipalRef: id: str def __post_init__(self) -> None: - if not all(isinstance(value, str) and value and value.strip() for value in (self.type, self.issuer, self.id)): + if not ( + _valid_text(self.type, maximum=64) + and _valid_text(self.issuer, maximum=255) + and _valid_text(self.id, maximum=255) + ): raise AccessInvalidRequestError("principal") @property def key(self) -> str: - return "\x1f".join((self.type, self.issuer, self.id)) + return _canonical_json({"id": self.id, "issuer": self.issuer, "type": self.type}) + + +@dataclass(frozen=True, slots=True) +class AccessArtifactReference: + """Exact immutable Artifact identity used by one Access resource.""" + + family: str + artifact_id: str + revision: int + + def __post_init__(self) -> None: + if not _valid_text(self.family, maximum=128) or not _valid_text(self.artifact_id, maximum=128): + raise AccessInvalidRequestError("artifact-reference") + if isinstance(self.revision, bool) or not isinstance(self.revision, int) or self.revision < 1: + raise AccessInvalidRequestError("artifact-reference") + + +@dataclass(frozen=True, slots=True) +class MemoryEntrySelector: + """Exact Memory Entry Version selected inside one Memory Revision.""" + + entry_id: str + entry_version_id: str + + type: str = "memory_entry" + + def __post_init__(self) -> None: + if not _valid_text(self.entry_id, maximum=128) or not _valid_text(self.entry_version_id, maximum=128): + raise AccessInvalidRequestError("memory-entry-selector") @dataclass(frozen=True, slots=True) @@ -90,63 +135,119 @@ class ResourceRef: """Canonical structured target of one authorization decision.""" type: AccessResourceType + deployment_id: str | None = None scope_id: str | None = None - family: str | None = None - artifact_id: str | None = None - revision: int | None = None + reference: AccessArtifactReference | None = None + selector: MemoryEntrySelector | None = None def __post_init__(self) -> None: if self.type is AccessResourceType.SERVER: - valid = self.scope_id is None and self.family is None and self.artifact_id is None and self.revision is None + valid = ( + _valid_text(self.deployment_id, maximum=128) + and self.scope_id is None + and self.reference is None + and self.selector is None + ) elif self.type is AccessResourceType.SCOPE: - valid = bool(self.scope_id) and self.family is None and self.artifact_id is None and self.revision is None + valid = ( + self.deployment_id is None + and _valid_text(self.scope_id, maximum=256) + and self.reference is None + and self.selector is None + ) else: valid = ( - bool(self.scope_id) - and self.family == "handoff" - and bool(self.artifact_id) - and self.revision is not None - and self.revision > 0 + self.deployment_id is None and _valid_text(self.scope_id, maximum=256) and self.reference is not None ) if not valid: - raise AccessInvalidRequestError( - "handoff-reference" if self.type is AccessResourceType.HANDOFF else "resource" - ) + raise AccessInvalidRequestError("resource") @classmethod - def server(cls) -> ResourceRef: - return cls(type=AccessResourceType.SERVER) + def server(cls, deployment_id: str = DEFAULT_DEPLOYMENT_ID) -> ResourceRef: + return cls(type=AccessResourceType.SERVER, deployment_id=deployment_id) @classmethod def scope(cls, scope_id: str) -> ResourceRef: return cls(type=AccessResourceType.SCOPE, scope_id=scope_id) @classmethod - def handoff( + def artifact( cls, scope_id: str, *, + family: str, artifact_id: str, revision: int, + selector: MemoryEntrySelector | None = None, ) -> ResourceRef: return cls( - type=AccessResourceType.HANDOFF, + type=AccessResourceType.ARTIFACT, scope_id=scope_id, + reference=AccessArtifactReference( + family=family, + artifact_id=artifact_id, + revision=revision, + ), + selector=selector, + ) + + @classmethod + def handoff( + cls, + scope_id: str, + *, + artifact_id: str, + revision: int, + ) -> ResourceRef: + """Build an exact Handoff Artifact resource.""" + + return cls.artifact( + scope_id, family="handoff", artifact_id=artifact_id, revision=revision, ) + @property + def family(self) -> str | None: + return None if self.reference is None else self.reference.family + + @property + def artifact_id(self) -> str | None: + return None if self.reference is None else self.reference.artifact_id + + @property + def revision(self) -> int | None: + return None if self.reference is None else self.reference.revision + @property def key(self) -> str: - values = ( - self.type.value, - self.scope_id or "", - self.family or "", - self.artifact_id or "", - "" if self.revision is None else str(self.revision), - ) - return "\x1f".join(values) + if self.type is AccessResourceType.SERVER: + value: dict[str, object] = {"deployment_id": self.deployment_id, "type": self.type.value} + elif self.type is AccessResourceType.SCOPE: + value = {"scope_id": self.scope_id, "type": self.type.value} + else: + if self.reference is None: + raise AccessInvalidRequestError("artifact-reference") + value = { + "reference": { + "artifact_id": self.reference.artifact_id, + "family": self.reference.family, + "revision": self.reference.revision, + }, + "scope_id": self.scope_id, + "selector": ( + None + if self.selector is None + else { + "entry_id": self.selector.entry_id, + "entry_version_id": self.selector.entry_version_id, + "type": self.selector.type, + } + ), + "type": self.type.value, + } + return _canonical_json(value) @property def parent_scope(self) -> ResourceRef | None: @@ -207,35 +308,42 @@ class AccessAuditEvent: ROLE_ACTIONS: dict[AccessRole, frozenset[AccessAction]] = { - AccessRole.HANDOFF_VIEWER: frozenset({AccessAction.HANDOFF_READ, AccessAction.HANDOFF_EVIDENCE_READ}), + AccessRole.HANDOFF_VIEWER: frozenset({AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_READ}), AccessRole.HANDOFF_RECEIVER: frozenset({ - AccessAction.HANDOFF_READ, + AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_READ, AccessAction.HANDOFF_ACKNOWLEDGE, }), + AccessRole.ARTIFACT_VIEWER: frozenset({AccessAction.ARTIFACT_READ}), + AccessRole.PROMPT_USER: frozenset({AccessAction.ARTIFACT_READ, AccessAction.PROMPT_USE}), + AccessRole.SKILL_PUBLISHER: frozenset({AccessAction.ARTIFACT_READ, AccessAction.SKILL_PUBLISH}), AccessRole.SCOPE_VIEWER: frozenset({ AccessAction.SCOPE_READ, - AccessAction.HANDOFF_READ, + AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.PROMPT_USE, }), AccessRole.SCOPE_CONTRIBUTOR: frozenset({ AccessAction.SCOPE_READ, AccessAction.SCOPE_CONTRIBUTE, - AccessAction.HANDOFF_READ, + AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_READ, AccessAction.HANDOFF_ACKNOWLEDGE, + AccessAction.PROMPT_USE, }), AccessRole.SCOPE_REVIEWER: frozenset({ AccessAction.SCOPE_READ, AccessAction.SCOPE_REVIEW, - AccessAction.HANDOFF_READ, + AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.PROMPT_USE, }), AccessRole.SCOPE_DELEGATOR: frozenset({ AccessAction.SCOPE_READ, AccessAction.SCOPE_DELEGATE, - AccessAction.HANDOFF_READ, + AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.PROMPT_USE, }), AccessRole.SCOPE_ADMIN: frozenset({ AccessAction.SCOPE_READ, @@ -243,17 +351,22 @@ class AccessAuditEvent: AccessAction.SCOPE_REVIEW, AccessAction.SCOPE_DELEGATE, AccessAction.SCOPE_ADMIN, - AccessAction.HANDOFF_READ, + AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_READ, AccessAction.HANDOFF_ACKNOWLEDGE, + AccessAction.PROMPT_USE, + AccessAction.SKILL_PUBLISH, }), AccessRole.SERVER_OBSERVER: frozenset({AccessAction.SERVER_OBSERVE}), AccessRole.SERVER_ADMIN: frozenset(AccessAction), } ROLE_RESOURCE_TYPES: dict[AccessRole, AccessResourceType] = { - AccessRole.HANDOFF_VIEWER: AccessResourceType.HANDOFF, - AccessRole.HANDOFF_RECEIVER: AccessResourceType.HANDOFF, + AccessRole.HANDOFF_VIEWER: AccessResourceType.ARTIFACT, + AccessRole.HANDOFF_RECEIVER: AccessResourceType.ARTIFACT, + AccessRole.ARTIFACT_VIEWER: AccessResourceType.ARTIFACT, + AccessRole.PROMPT_USER: AccessResourceType.ARTIFACT, + AccessRole.SKILL_PUBLISHER: AccessResourceType.ARTIFACT, AccessRole.SCOPE_VIEWER: AccessResourceType.SCOPE, AccessRole.SCOPE_CONTRIBUTOR: AccessResourceType.SCOPE, AccessRole.SCOPE_REVIEWER: AccessResourceType.SCOPE, @@ -264,16 +377,28 @@ class AccessAuditEvent: } +def _valid_text(value: object, *, maximum: int) -> bool: + return isinstance(value, str) and bool(value.strip()) and value == value.strip() and len(value) <= maximum + + +def _canonical_json(value: object) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + __all__ = ( + "DEFAULT_DEPLOYMENT_ID", + "PUBLIC_ACCESS_ACTIONS", "ROLE_ACTIONS", "ROLE_RESOURCE_TYPES", "AccessAction", + "AccessArtifactReference", "AccessAuditEvent", "AccessBinding", "AccessBindingState", "AccessDecision", "AccessResourceType", "AccessRole", + "MemoryEntrySelector", "PrincipalRef", "ResourceRef", ) diff --git a/src/powercontext/server/authz/profiles.py b/src/powercontext/server/authz/profiles.py new file mode 100644 index 000000000..9ad8de179 --- /dev/null +++ b/src/powercontext/server/authz/profiles.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Server-owned Artifact Family Access Profiles.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from powercontext.server.authz.errors import AccessInvalidRequestError +from powercontext.server.authz.models import AccessAction, AccessResourceType, AccessRole, ResourceRef + + +@dataclass(frozen=True, slots=True) +class ArtifactFamilyAccessProfile: + """Fixed authorization semantics for one registered Artifact Family.""" + + family: str + enabled: bool + share_unit: Literal["revision", "memory_entry"] + shareable_states: frozenset[str] + actions: frozenset[AccessAction] + grantable_roles: frozenset[AccessRole] + selector: Literal["forbidden", "memory_entry"] + + +ARTIFACT_FAMILY_PROFILES: dict[str, ArtifactFamilyAccessProfile] = { + "handoff": ArtifactFamilyAccessProfile( + family="handoff", + enabled=True, + share_unit="revision", + shareable_states=frozenset({"committed"}), + actions=frozenset({ + AccessAction.ARTIFACT_READ, + AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_ACKNOWLEDGE, + }), + grantable_roles=frozenset({AccessRole.HANDOFF_VIEWER, AccessRole.HANDOFF_RECEIVER}), + selector="forbidden", + ), + "memory": ArtifactFamilyAccessProfile( + family="memory", + enabled=True, + share_unit="memory_entry", + shareable_states=frozenset({"active"}), + actions=frozenset({AccessAction.ARTIFACT_READ}), + grantable_roles=frozenset({AccessRole.ARTIFACT_VIEWER}), + selector="memory_entry", + ), + "experience": ArtifactFamilyAccessProfile( + family="experience", + enabled=True, + share_unit="revision", + shareable_states=frozenset({"approved"}), + actions=frozenset({AccessAction.ARTIFACT_READ}), + grantable_roles=frozenset({AccessRole.ARTIFACT_VIEWER}), + selector="forbidden", + ), + "skill": ArtifactFamilyAccessProfile( + family="skill", + enabled=True, + share_unit="revision", + shareable_states=frozenset({"approved"}), + actions=frozenset({AccessAction.ARTIFACT_READ, AccessAction.SKILL_PUBLISH}), + grantable_roles=frozenset({AccessRole.ARTIFACT_VIEWER, AccessRole.SKILL_PUBLISHER}), + selector="forbidden", + ), + # Prompt authorization vocabulary is reserved, but this deployment does not yet + # implement an immutable approved Prompt lifecycle or exact get/use operations. + "prompt": ArtifactFamilyAccessProfile( + family="prompt", + enabled=False, + share_unit="revision", + shareable_states=frozenset({"approved"}), + actions=frozenset(), + grantable_roles=frozenset(), + selector="forbidden", + ), +} + + +def artifact_family_profile(resource: ResourceRef) -> ArtifactFamilyAccessProfile: + """Validate an exact Artifact resource and return its enabled profile.""" + + if resource.type is not AccessResourceType.ARTIFACT or resource.reference is None: + raise AccessInvalidRequestError("artifact-reference") + profile = ARTIFACT_FAMILY_PROFILES.get(resource.reference.family) + if profile is None: + raise AccessInvalidRequestError("artifact-family") + if not profile.enabled: + raise AccessInvalidRequestError("artifact-family-disabled") + if profile.selector == "memory_entry" and resource.selector is None: + raise AccessInvalidRequestError("memory-entry-selector") + if profile.selector == "forbidden" and resource.selector is not None: + raise AccessInvalidRequestError("artifact-selector") + return profile + + +def validate_action_resource(action: AccessAction, resource: ResourceRef, *, deployment_id: str) -> None: + """Reject action/resource combinations outside the stable wire contract.""" + + if resource.type is AccessResourceType.SERVER: + if resource.deployment_id != deployment_id: + raise AccessInvalidRequestError("deployment") + if action not in {AccessAction.ACCESS_SELF, AccessAction.SERVER_OBSERVE, AccessAction.SERVER_ADMIN}: + raise AccessInvalidRequestError("action-resource") + return + if resource.type is AccessResourceType.SCOPE: + if action not in { + AccessAction.SCOPE_READ, + AccessAction.SCOPE_CONTRIBUTE, + AccessAction.SCOPE_REVIEW, + AccessAction.SCOPE_DELEGATE, + AccessAction.SCOPE_ADMIN, + }: + raise AccessInvalidRequestError("action-resource") + return + profile = artifact_family_profile(resource) + if action not in profile.actions: + raise AccessInvalidRequestError("action-resource") + + +def validate_binding_role(resource: ResourceRef, role: AccessRole, *, deployment_id: str) -> None: + """Reject role/resource and role/Family mismatches before policy mutation.""" + + if resource.type is AccessResourceType.SERVER: + if resource.deployment_id != deployment_id or role not in {AccessRole.SERVER_OBSERVER, AccessRole.SERVER_ADMIN}: + raise AccessInvalidRequestError("binding-role") + return + if resource.type is AccessResourceType.SCOPE: + if role not in { + AccessRole.SCOPE_VIEWER, + AccessRole.SCOPE_CONTRIBUTOR, + AccessRole.SCOPE_REVIEWER, + AccessRole.SCOPE_DELEGATOR, + AccessRole.SCOPE_ADMIN, + }: + raise AccessInvalidRequestError("binding-role") + return + profile = artifact_family_profile(resource) + if role not in profile.grantable_roles: + raise AccessInvalidRequestError("binding-role") + + +__all__ = ( + "ARTIFACT_FAMILY_PROFILES", + "ArtifactFamilyAccessProfile", + "artifact_family_profile", + "validate_action_resource", + "validate_binding_role", +) diff --git a/src/powercontext/server/authz/repository.py b/src/powercontext/server/authz/repository.py index 944768e09..b466cd360 100644 --- a/src/powercontext/server/authz/repository.py +++ b/src/powercontext/server/authz/repository.py @@ -33,21 +33,25 @@ UniqueConstraint, insert, select, + text, update, ) from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.tables import identity_string from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH from powercontext.server.authz.errors import AccessConflictError, AccessInvalidRequestError from powercontext.server.authz.models import ( + DEFAULT_DEPLOYMENT_ID, AccessAction, AccessAuditEvent, AccessBinding, AccessBindingState, AccessResourceType, AccessRole, + MemoryEntrySelector, PrincipalRef, ResourceRef, ) @@ -70,10 +74,14 @@ Column("subject_issuer", identity_string(255), nullable=False), Column("subject_id", identity_string(255), nullable=False), Column("resource_type", identity_string(16), nullable=False), + Column("deployment_id", identity_string(128)), Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH)), Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), Column("revision", Integer), + Column("selector_type", identity_string(32)), + Column("selector_entry_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), + Column("selector_entry_version_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), Column("role", identity_string(32), nullable=False), Column("granted_by_type", identity_string(64), nullable=False), Column("granted_by_issuer", identity_string(255), nullable=False), @@ -113,10 +121,14 @@ Column("principal_id", identity_string(255), nullable=False), Column("action", identity_string(64), nullable=False), Column("resource_type", identity_string(16), nullable=False), + Column("deployment_id", identity_string(128)), Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH)), Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), Column("revision", Integer), + Column("selector_type", identity_string(32)), + Column("selector_entry_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), + Column("selector_entry_version_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), Column("allowed", Boolean, nullable=False), Column("reason_code", identity_string(64), nullable=False), Column("policy_revision", identity_string(32)), @@ -129,6 +141,76 @@ ACCESS_TABLES = (ACCESS_POLICY_HEADS_TABLE, ACCESS_BINDINGS_TABLE, ACCESS_AUDIT_EVENTS_TABLE) _POLICY_HEAD = "authorization" +_ACCESS_RESOURCE_COLUMNS = { + "deployment_id": 128, + "selector_type": 32, + "selector_entry_id": MAX_ARTIFACT_ID_LENGTH, + "selector_entry_version_id": MAX_ARTIFACT_ID_LENGTH, +} + + +async def ensure_access_schema( + connection: AsyncConnection, + /, + *, + deployment_id: str = DEFAULT_DEPLOYMENT_ID, +) -> None: + """Upgrade the first Handoff-only Access tables to the Artifact resource contract.""" + + dialect = connection.dialect.name + if dialect not in {"sqlite", "mysql"}: + raise ValueError(f"unsupported Access schema migration dialect: {dialect}") # noqa: TRY003 + rehash_binding_idempotency = False + for table_name in (ACCESS_BINDINGS_TABLE.name, ACCESS_AUDIT_EVENTS_TABLE.name): + for column_name, maximum in _ACCESS_RESOURCE_COLUMNS.items(): + if await _column_exists(connection, table_name, column_name): + continue + if table_name == ACCESS_BINDINGS_TABLE.name: + rehash_binding_idempotency = True + column_type = "TEXT" if dialect == "sqlite" else f"VARCHAR({maximum})" + await connection.exec_driver_sql(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type} NULL") + converted = await connection.execute( + text( + f"UPDATE {table_name} SET resource_type = 'artifact' " # noqa: S608 + "WHERE resource_type = 'handoff'" + ) + ) + if table_name == ACCESS_BINDINGS_TABLE.name and converted.rowcount > 0: + rehash_binding_idempotency = True + await connection.execute( + text( + f"UPDATE {table_name} SET deployment_id = :deployment_id " # noqa: S608 + "WHERE resource_type = 'server' AND deployment_id IS NULL" + ), + {"deployment_id": deployment_id}, + ) + await connection.execute( + text("UPDATE pc_access_audit_events SET action = 'artifact.read' WHERE action = 'handoff.read'") + ) + if rehash_binding_idempotency: + rows = (await connection.execute(select(ACCESS_BINDINGS_TABLE))).mappings().all() + for row in rows: + await connection.execute( + update(ACCESS_BINDINGS_TABLE) + .where(ACCESS_BINDINGS_TABLE.c.binding_id == row["binding_id"]) + .values( + idempotency_key_hash=_idempotency_digest( + _decode_resource(row), + str(row["idempotency_key"]), + ) + ) + ) + + +async def _column_exists(connection: AsyncConnection, table_name: str, column_name: str) -> bool: + if connection.dialect.name == "sqlite": + statement = text(f"SELECT COUNT(*) FROM pragma_table_info('{table_name}') WHERE name = :column_name") # noqa: S608 + return bool(await connection.scalar(statement, {"column_name": column_name})) + statement = text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name = :table_name AND column_name = :column_name" + ) + return bool(await connection.scalar(statement, {"table_name": table_name, "column_name": column_name})) class RelationalAccessRepository: @@ -192,7 +274,7 @@ async def list_bindings( ACCESS_BINDINGS_TABLE.c.subject_id == subject.id, ) if resource is not None: - statement = statement.where(*_resource_predicates(resource)) + statement = statement.where(*_binding_resource_predicates(resource)) if not include_revoked: statement = statement.where(ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value) statement = statement.order_by(ACCESS_BINDINGS_TABLE.c.created_at, ACCESS_BINDINGS_TABLE.c.binding_id) @@ -207,7 +289,8 @@ async def create_binding(self, binding: AccessBinding) -> AccessBinding: await connection.execute( select(ACCESS_BINDINGS_TABLE).where( ACCESS_BINDINGS_TABLE.c.grantor_key_hash == _digest(binding.granted_by.key), - ACCESS_BINDINGS_TABLE.c.idempotency_key_hash == _digest(binding.idempotency_key), + ACCESS_BINDINGS_TABLE.c.idempotency_key_hash + == _idempotency_digest(binding.resource, binding.idempotency_key), ) ) ) @@ -291,8 +374,18 @@ async def append_audit(self, event: AccessAuditEvent) -> AccessAuditEvent: ).scalar_one() return replace(event, cursor=int(cursor)) - async def list_audit(self, *, after: int | None = None, limit: int = 100) -> tuple[AccessAuditEvent, ...]: - statement = select(ACCESS_AUDIT_EVENTS_TABLE) + async def list_audit( + self, + *, + resource: ResourceRef | None = None, + after: int | None = None, + limit: int = 100, + ) -> tuple[AccessAuditEvent, ...]: + statement = select(ACCESS_AUDIT_EVENTS_TABLE).where( + ACCESS_AUDIT_EVENTS_TABLE.c.action != AccessAction.ACCESS_SELF.value + ) + if resource is not None: + statement = statement.where(*_audit_resource_predicates(resource)) if after is not None: statement = statement.where(ACCESS_AUDIT_EVENTS_TABLE.c.cursor > after) statement = statement.order_by(ACCESS_AUDIT_EVENTS_TABLE.c.cursor).limit(limit) @@ -326,28 +419,52 @@ async def _increment_policy_revision(connection: Any) -> int: return int(current) + 1 -def _resource_predicates(resource: ResourceRef) -> Sequence[Any]: +def _resource_predicates(table: Table, resource: ResourceRef) -> Sequence[Any]: + selector = resource.selector return ( - ACCESS_BINDINGS_TABLE.c.resource_type == resource.type.value, - ACCESS_BINDINGS_TABLE.c.scope_id == resource.scope_id, - ACCESS_BINDINGS_TABLE.c.family == resource.family, - ACCESS_BINDINGS_TABLE.c.artifact_id == resource.artifact_id, - ACCESS_BINDINGS_TABLE.c.revision == resource.revision, + table.c.resource_type == resource.type.value, + table.c.deployment_id == resource.deployment_id, + table.c.scope_id == resource.scope_id, + table.c.family == resource.family, + table.c.artifact_id == resource.artifact_id, + table.c.revision == resource.revision, + table.c.selector_type == (None if selector is None else selector.type), + table.c.selector_entry_id == (None if selector is None else selector.entry_id), + table.c.selector_entry_version_id == (None if selector is None else selector.entry_version_id), ) +def _binding_resource_predicates(resource: ResourceRef) -> Sequence[Any]: + if resource.type is AccessResourceType.SERVER: + return () + if resource.type is AccessResourceType.SCOPE: + return (ACCESS_BINDINGS_TABLE.c.scope_id == resource.scope_id,) + return _resource_predicates(ACCESS_BINDINGS_TABLE, resource) + + +def _audit_resource_predicates(resource: ResourceRef) -> Sequence[Any]: + if resource.type is AccessResourceType.SCOPE: + return (ACCESS_AUDIT_EVENTS_TABLE.c.scope_id == resource.scope_id,) + return _resource_predicates(ACCESS_AUDIT_EVENTS_TABLE, resource) + + def _binding_row(binding: AccessBinding) -> dict[str, object | None]: revoked_by = binding.revoked_by + selector = binding.resource.selector return { "binding_id": binding.binding_id, "subject_type": binding.subject.type, "subject_issuer": binding.subject.issuer, "subject_id": binding.subject.id, "resource_type": binding.resource.type.value, + "deployment_id": binding.resource.deployment_id, "scope_id": binding.resource.scope_id, "family": binding.resource.family, "artifact_id": binding.resource.artifact_id, "revision": binding.resource.revision, + "selector_type": None if selector is None else selector.type, + "selector_entry_id": None if selector is None else selector.entry_id, + "selector_entry_version_id": None if selector is None else selector.entry_version_id, "role": binding.role.value, "granted_by_type": binding.granted_by.type, "granted_by_issuer": binding.granted_by.issuer, @@ -360,7 +477,7 @@ def _binding_row(binding: AccessBinding) -> dict[str, object | None]: "version": binding.version, "policy_revision": binding.policy_revision, "idempotency_key": binding.idempotency_key, - "idempotency_key_hash": _digest(binding.idempotency_key), + "idempotency_key_hash": _idempotency_digest(binding.resource, binding.idempotency_key), "revoked_at": None if binding.revoked_at is None else _timestamp(binding.revoked_at), "revoked_by_type": None if revoked_by is None else revoked_by.type, "revoked_by_issuer": None if revoked_by is None else revoked_by.issuer, @@ -391,6 +508,7 @@ def _decode_binding(row: Mapping[Any, Any]) -> AccessBinding: def _audit_row(event: AccessAuditEvent) -> dict[str, object | None]: target = event.target + selector = event.resource.selector return { "event_id": event.event_id, "occurred_at": _timestamp(event.occurred_at), @@ -402,10 +520,14 @@ def _audit_row(event: AccessAuditEvent) -> dict[str, object | None]: "principal_id": event.principal.id, "action": event.action.value, "resource_type": event.resource.type.value, + "deployment_id": event.resource.deployment_id, "scope_id": event.resource.scope_id, "family": event.resource.family, "artifact_id": event.resource.artifact_id, "revision": event.resource.revision, + "selector_type": None if selector is None else selector.type, + "selector_entry_id": None if selector is None else selector.entry_id, + "selector_entry_version_id": None if selector is None else selector.entry_version_id, "allowed": event.allowed, "reason_code": event.reason_code, "policy_revision": event.policy_revision, @@ -426,7 +548,7 @@ def _decode_audit(row: Mapping[Any, Any]) -> AccessAuditEvent: transport=str(row["transport"]), operation=str(row["operation"]), principal=_principal(row, "principal"), - action=AccessAction(str(row["action"])), + action=AccessAction.ARTIFACT_READ if str(row["action"]) == "handoff.read" else AccessAction(str(row["action"])), resource=_decode_resource(row), allowed=bool(row["allowed"]), reason_code=str(row["reason_code"]), @@ -438,15 +560,28 @@ def _decode_audit(row: Mapping[Any, Any]) -> AccessAuditEvent: def _decode_resource(row: Mapping[Any, Any]) -> ResourceRef: - resource_type = AccessResourceType(str(row["resource_type"])) + stored_type = str(row["resource_type"]) + resource_type = AccessResourceType.ARTIFACT if stored_type == "handoff" else AccessResourceType(stored_type) if resource_type is AccessResourceType.SERVER: - return ResourceRef.server() + deployment_id = row.get("deployment_id") + return ResourceRef.server() if deployment_id is None else ResourceRef.server(str(deployment_id)) if resource_type is AccessResourceType.SCOPE: return ResourceRef.scope(str(row["scope_id"])) - return ResourceRef.handoff( + selector_type = row.get("selector_type") + selector = ( + None + if selector_type is None + else MemoryEntrySelector( + entry_id=str(row["selector_entry_id"]), + entry_version_id=str(row["selector_entry_version_id"]), + ) + ) + return ResourceRef.artifact( str(row["scope_id"]), + family=str(row["family"]), artifact_id=str(row["artifact_id"]), revision=int(row["revision"]), + selector=selector, ) @@ -486,7 +621,12 @@ def _digest(value: str) -> str: return sha256(value.encode("utf-8")).hexdigest() +def _idempotency_digest(resource: ResourceRef, idempotency_key: str) -> str: + return _digest(f"{resource.key}\0{idempotency_key}") + + __all__ = ( "ACCESS_TABLES", "RelationalAccessRepository", + "ensure_access_schema", ) diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index 51a054414..203aaaa9d 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -16,10 +16,11 @@ from __future__ import annotations +from base64 import b64decode, urlsafe_b64encode from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from datetime import UTC, datetime -from typing import Protocol, TypeVar +from typing import Literal, Protocol, TypeVar from uuid import uuid4 from powercontext.server.authz.errors import ( @@ -30,8 +31,8 @@ AccessUnavailableError, ) from powercontext.server.authz.models import ( + DEFAULT_DEPLOYMENT_ID, ROLE_ACTIONS, - ROLE_RESOURCE_TYPES, AccessAction, AccessAuditEvent, AccessBinding, @@ -42,8 +43,24 @@ PrincipalRef, ResourceRef, ) +from powercontext.server.authz.profiles import ( + ARTIFACT_FAMILY_PROFILES, + artifact_family_profile, + validate_action_resource, + validate_binding_role, +) _T = TypeVar("_T") +_MAX_AUTHORIZED_FILTER_IDENTITIES = 10_000 + + +@dataclass(frozen=True, slots=True) +class AuthorizedResourceFilter: + """Bounded identities and parent constraints authorized before repository access.""" + + exact_resources: tuple[ResourceRef, ...] + parent_constraints: tuple[ResourceRef, ...] + policy_revision: str | None @dataclass(frozen=True, slots=True) @@ -51,19 +68,17 @@ class AuthorizedResourcePage: """One stable, non-discovering page of resources visible to a Principal.""" items: tuple[ResourceRef, ...] + total: int next_cursor: str | None = None @dataclass(frozen=True, slots=True) -class CreateBinding: - """Validated intent to create one immutable Access Binding.""" +class AccessProviderCapabilities: + """Enforcement features that one configured Provider can safely supply.""" - subject: PrincipalRef - resource: ResourceRef - role: AccessRole - idempotency_key: str - reason: str | None = None - expires_at: datetime | None = None + safe_resource_filtering: bool + multi_requirement_check: bool + relationship_management: bool @dataclass(frozen=True, slots=True) @@ -75,31 +90,61 @@ class AccessAuditContext: request_id: str | None = None +@dataclass(frozen=True, slots=True) +class AccessRequest: + """Normalized AuthZEN-shaped point decision request.""" + + subject: PrincipalRef + action: AccessAction + resource: ResourceRef + context: AccessAuditContext + + +@dataclass(frozen=True, slots=True) +class ResourceSearchRequest: + """Normalized request for a safe, provider-owned resource filter.""" + + subject: PrincipalRef + action: AccessAction + resource_type: AccessResourceType + family: str | None + context: AccessAuditContext + + +@dataclass(frozen=True, slots=True) +class CreateBinding: + """Validated intent to create one immutable Access Binding.""" + + subject: PrincipalRef + resource: ResourceRef + role: AccessRole + idempotency_key: str + reason: str | None = None + expires_at: datetime | None = None + + def __post_init__(self) -> None: + if not self.idempotency_key or len(self.idempotency_key) > 255: + raise AccessInvalidRequestError("idempotency-key") + if self.reason is not None and len(self.reason) > 1_024: + raise AccessInvalidRequestError("reason") + + class AuthorizationProvider(Protocol): - """Replaceable decision interface suitable for OpenFGA, Casbin, or Oso adapters.""" + """Replaceable decision interface suitable for embedded or remote PDPs.""" - async def check( - self, - principal: PrincipalRef, - action: AccessAction, - resource: ResourceRef, - ) -> AccessDecision: ... + async def check(self, request: AccessRequest, /) -> AccessDecision: ... async def check_batch( self, - principal: PrincipalRef, - checks: Sequence[tuple[AccessAction, ResourceRef]], + requests: Sequence[AccessRequest], + /, ) -> tuple[AccessDecision, ...]: ... - async def list_resources( + async def resolve_resource_filter( self, - principal: PrincipalRef, - *, - action: AccessAction, - resource_type: AccessResourceType, - cursor: str | None = None, - limit: int = 100, - ) -> AuthorizedResourcePage: ... + request: ResourceSearchRequest, + /, + ) -> AuthorizedResourceFilter: ... class RelationshipWriter(Protocol): @@ -132,7 +177,13 @@ class AccessAuditStore(Protocol): async def append_audit(self, event: AccessAuditEvent) -> AccessAuditEvent: ... - async def list_audit(self, *, after: int | None = None, limit: int = 100) -> tuple[AccessAuditEvent, ...]: ... + async def list_audit( + self, + *, + resource: ResourceRef | None = None, + after: int | None = None, + limit: int = 100, + ) -> tuple[AccessAuditEvent, ...]: ... class AccessRepository(RelationshipWriter, AccessAuditStore, Protocol): @@ -151,63 +202,74 @@ def __init__( repository: AccessRepository, *, bootstrap_administrators: Sequence[PrincipalRef] = (), + deployment_id: str = DEFAULT_DEPLOYMENT_ID, clock: Callable[[], datetime] | None = None, ) -> None: self._repository = repository self._bootstrap_administrators = frozenset(bootstrap_administrators) + self._deployment_id = deployment_id self._clock = clock or (lambda: datetime.now(UTC)) - async def check( - self, - principal: PrincipalRef, - action: AccessAction, - resource: ResourceRef, - ) -> AccessDecision: + async def check(self, request: AccessRequest, /) -> AccessDecision: revision = await self._repository.policy_revision() - if action is AccessAction.ACCESS_SELF: + if request.action is AccessAction.ACCESS_SELF: return AccessDecision(True, "authenticated", revision) - if principal in self._bootstrap_administrators: + if request.subject in self._bootstrap_administrators: return AccessDecision(True, "bootstrap-admin", revision) - bindings = await self._repository.active_bindings(principal, now=self._clock()) - return _binding_decision(bindings, action, resource, policy_revision=revision) + bindings = await self._repository.active_bindings(request.subject, now=self._clock()) + return _binding_decision(bindings, request.action, request.resource, policy_revision=revision) async def check_batch( self, - principal: PrincipalRef, - checks: Sequence[tuple[AccessAction, ResourceRef]], + requests: Sequence[AccessRequest], + /, ) -> tuple[AccessDecision, ...]: revision = await self._repository.policy_revision() + if not requests: + return () + principal = requests[0].subject + if any(request.subject != principal for request in requests): + raise AccessInvalidRequestError("batch-subject") if principal in self._bootstrap_administrators: - return tuple(AccessDecision(True, "bootstrap-admin", revision) for _ in checks) + return tuple(AccessDecision(True, "bootstrap-admin", revision) for _ in requests) bindings = await self._repository.active_bindings(principal, now=self._clock()) return tuple( AccessDecision(True, "authenticated", revision) - if action is AccessAction.ACCESS_SELF - else _binding_decision(bindings, action, resource, policy_revision=revision) - for action, resource in checks + if request.action is AccessAction.ACCESS_SELF + else _binding_decision(bindings, request.action, request.resource, policy_revision=revision) + for request in requests ) - async def list_resources( + async def resolve_resource_filter( self, - principal: PrincipalRef, - *, - action: AccessAction, - resource_type: AccessResourceType, - cursor: str | None = None, - limit: int = 100, - ) -> AuthorizedResourcePage: - if limit < 1 or limit > 500: - raise AccessInvalidRequestError("limit") - if cursor not in {None, ""}: - raise AccessInvalidRequestError("cursor") - bindings = await self._repository.active_bindings(principal, now=self._clock()) - resources = { - binding.resource.key: binding.resource - for binding in bindings - if binding.resource.type is resource_type and action in ROLE_ACTIONS[binding.role] - } - ordered = tuple(resources[key] for key in sorted(resources)) - return AuthorizedResourcePage(items=ordered[:limit]) + request: ResourceSearchRequest, + /, + ) -> AuthorizedResourceFilter: + revision = await self._repository.policy_revision() + if request.subject in self._bootstrap_administrators: + return AuthorizedResourceFilter( + exact_resources=(ResourceRef.server(self._deployment_id),) + if request.resource_type is AccessResourceType.SERVER + else (), + parent_constraints=(ResourceRef.server(self._deployment_id),), + policy_revision=revision, + ) + bindings = await self._repository.active_bindings(request.subject, now=self._clock()) + exact: dict[str, ResourceRef] = {} + parents: dict[str, ResourceRef] = {} + for binding in bindings: + if request.action not in ROLE_ACTIONS[binding.role]: + continue + resource = binding.resource + if resource.type is request.resource_type and (request.family is None or resource.family == request.family): + exact[resource.key] = resource + elif _resource_is_parent(resource, request.resource_type): + parents[resource.key] = resource + return AuthorizedResourceFilter( + exact_resources=tuple(exact[key] for key in sorted(exact)), + parent_constraints=tuple(parents[key] for key in sorted(parents)), + policy_revision=revision, + ) class AccessControlService: @@ -217,13 +279,23 @@ def __init__( self, provider: AuthorizationProvider, *, - relationships: RelationshipWriter, + relationships: RelationshipWriter | None, audit: AccessAuditStore, + deployment_id: str = DEFAULT_DEPLOYMENT_ID, + mode: Literal["legacy-static-admin", "enforced"] = "enforced", + provider_capabilities: AccessProviderCapabilities | None = None, clock: Callable[[], datetime] | None = None, ) -> None: self.provider = provider self.relationships = relationships self.audit = audit + self.deployment_id = deployment_id + self.mode = mode + self.provider_capabilities = provider_capabilities or AccessProviderCapabilities( + safe_resource_filtering=True, + multi_requirement_check=True, + relationship_management=relationships is not None, + ) self._clock = clock or (lambda: datetime.now(UTC)) async def check( @@ -234,10 +306,13 @@ async def check( *, context: AccessAuditContext, ) -> AccessDecision: - if principal is None: - raise AccessIdentityRequiredError - decision = await _access_call(self.provider.check(principal, action, resource)) - await _access_call(self._record_decision(principal, action, resource, decision, context=context)) + actor = _required_principal(principal) + validate_action_resource(action, resource, deployment_id=self.deployment_id) + request = AccessRequest(subject=actor, action=action, resource=resource, context=context) + decision = await _access_call(self.provider.check(request)) + _validate_provider_decision(decision) + if action is not AccessAction.ACCESS_SELF: + await _access_call(self._record_decision(actor, action, resource, decision, context=context)) return decision async def require( @@ -260,13 +335,35 @@ async def check_batch( *, context: AccessAuditContext, ) -> tuple[AccessDecision, ...]: - if principal is None: - raise AccessIdentityRequiredError - decisions = await _access_call(self.provider.check_batch(principal, checks)) + if not self.provider_capabilities.multi_requirement_check: + raise AccessUnavailableError("multi_requirement_check_unavailable") + actor = _required_principal(principal) + for action, resource in checks: + validate_action_resource(action, resource, deployment_id=self.deployment_id) + requests = tuple( + AccessRequest(subject=actor, action=action, resource=resource, context=context) + for action, resource in checks + ) + decisions = await _access_call(self.provider.check_batch(requests)) if len(decisions) != len(checks): raise AccessUnavailableError + for decision in decisions: + _validate_provider_decision(decision) for (action, resource), decision in zip(checks, decisions, strict=True): - await _access_call(self._record_decision(principal, action, resource, decision, context=context)) + if action is not AccessAction.ACCESS_SELF: + await _access_call(self._record_decision(actor, action, resource, decision, context=context)) + return decisions + + async def require_all( + self, + principal: PrincipalRef | None, + checks: Sequence[tuple[AccessAction, ResourceRef]], + *, + context: AccessAuditContext, + ) -> tuple[AccessDecision, ...]: + decisions = await self.check_batch(principal, checks, context=context) + if not all(decision.allowed for decision in decisions): + raise AccessDeniedError return decisions async def list_resources( @@ -275,19 +372,41 @@ async def list_resources( *, action: AccessAction, resource_type: AccessResourceType, + family: str | None = None, cursor: str | None = None, limit: int = 100, + context: AccessAuditContext, ) -> AuthorizedResourcePage: + if not self.provider_capabilities.safe_resource_filtering: + raise AccessUnavailableError("safe_resource_filtering_unavailable") + if limit < 1 or limit > 500: + raise AccessInvalidRequestError("limit") + _validate_resource_list_query(action=action, resource_type=resource_type, family=family) actor = _required_principal(principal) - return await _access_call( - self.provider.list_resources( - actor, - action=action, - resource_type=resource_type, - cursor=cursor, - limit=limit, + authorized_filter = await _access_call( + self.provider.resolve_resource_filter( + ResourceSearchRequest( + subject=actor, + action=action, + resource_type=resource_type, + family=family, + context=context, + ) ) ) + _validate_resource_filter( + authorized_filter, + action=action, + resource_type=resource_type, + family=family, + deployment_id=self.deployment_id, + ) + ordered = tuple(sorted(authorized_filter.exact_resources, key=lambda resource: resource.key)) + after_key = _decode_cursor(cursor) + visible = ordered if after_key is None else tuple(resource for resource in ordered if resource.key > after_key) + items = visible[:limit] + next_cursor = _encode_cursor(items[-1].key) if len(visible) > len(items) else None + return AuthorizedResourcePage(items=items, total=len(ordered), next_cursor=next_cursor) async def list_bindings( self, @@ -296,16 +415,23 @@ async def list_bindings( resource: ResourceRef | None = None, include_revoked: bool = False, ) -> tuple[AccessBinding, ...]: + relationships = self._relationships() return await _access_call( - self.relationships.list_bindings( + relationships.list_bindings( subject=subject, resource=resource, include_revoked=include_revoked, ) ) - async def list_audit(self, *, after: int | None = None, limit: int = 100) -> tuple[AccessAuditEvent, ...]: - return await _access_call(self.audit.list_audit(after=after, limit=limit)) + async def list_audit( + self, + *, + resource: ResourceRef | None = None, + after: int | None = None, + limit: int = 100, + ) -> tuple[AccessAuditEvent, ...]: + return await _access_call(self.audit.list_audit(resource=resource, after=after, limit=limit)) async def create_binding( self, @@ -313,15 +439,17 @@ async def create_binding( request: CreateBinding, *, context: AccessAuditContext, + validate_resource: Callable[[ResourceRef], Awaitable[None]] | None = None, ) -> AccessBinding: - if ROLE_RESOURCE_TYPES[request.role] is not request.resource.type: - raise AccessInvalidRequestError("binding-role") + validate_binding_role(request.resource, request.role, deployment_id=self.deployment_id) now = self._clock() if request.expires_at is not None and request.expires_at <= now: raise AccessInvalidRequestError("binding-expired") action, administrative_resource = _administrative_check(request.resource) actor = _required_principal(principal) await self.require(actor, action, administrative_resource, context=context) + if validate_resource is not None: + await validate_resource(request.resource) candidate = AccessBinding( binding_id=str(uuid4()), subject=request.subject, @@ -336,7 +464,7 @@ async def create_binding( policy_revision="pending", idempotency_key=request.idempotency_key, ) - created = await _access_call(self.relationships.create_binding(candidate)) + created = await _access_call(self._relationships().create_binding(candidate)) await _access_call(self._record_relationship(created, principal=actor, action=action, context=context)) return created @@ -349,13 +477,14 @@ async def revoke_binding( context: AccessAuditContext, ) -> AccessBinding: actor = _required_principal(principal) - binding = await _access_call(self.relationships.get_binding(binding_id)) + relationships = self._relationships() + binding = await _access_call(relationships.get_binding(binding_id)) if binding is None: raise AccessDeniedError action, administrative_resource = _administrative_check(binding.resource) await self.require(actor, action, administrative_resource, context=context) revoked = await _access_call( - self.relationships.revoke_binding( + relationships.revoke_binding( binding_id, expected_version=expected_version, revoked_at=self._clock(), @@ -365,6 +494,11 @@ async def revoke_binding( await _access_call(self._record_relationship(revoked, principal=actor, action=action, context=context)) return revoked + def _relationships(self) -> RelationshipWriter: + if self.relationships is None or not self.provider_capabilities.relationship_management: + raise AccessUnavailableError("relationship_management_unavailable") + return self.relationships + async def _record_decision( self, principal: PrincipalRef, @@ -420,6 +554,41 @@ async def _record_relationship( ) +def _validate_resource_list_query( + *, + action: AccessAction, + resource_type: AccessResourceType, + family: str | None, +) -> None: + if action is AccessAction.ACCESS_SELF or (family is not None and resource_type is not AccessResourceType.ARTIFACT): + raise AccessInvalidRequestError("action-resource") + allowed_actions = { + AccessResourceType.SERVER: {AccessAction.SERVER_OBSERVE, AccessAction.SERVER_ADMIN}, + AccessResourceType.SCOPE: { + AccessAction.SCOPE_READ, + AccessAction.SCOPE_CONTRIBUTE, + AccessAction.SCOPE_REVIEW, + AccessAction.SCOPE_DELEGATE, + AccessAction.SCOPE_ADMIN, + }, + } + if resource_type is not AccessResourceType.ARTIFACT: + if action not in allowed_actions[resource_type]: + raise AccessInvalidRequestError("action-resource") + return + if family is None: + if not any(profile.enabled and action in profile.actions for profile in ARTIFACT_FAMILY_PROFILES.values()): + raise AccessInvalidRequestError("action-resource") + return + profile = ARTIFACT_FAMILY_PROFILES.get(family) + if profile is None: + raise AccessInvalidRequestError("artifact-family") + if not profile.enabled: + raise AccessInvalidRequestError("artifact-family-disabled") + if action not in profile.actions: + raise AccessInvalidRequestError("action-resource") + + def _binding_covers(binding: ResourceRef, requested: ResourceRef) -> bool: if binding == requested: return True @@ -427,7 +596,7 @@ def _binding_covers(binding: ResourceRef, requested: ResourceRef) -> bool: return True return ( binding.type is AccessResourceType.SCOPE - and requested.type is AccessResourceType.HANDOFF + and requested.type is AccessResourceType.ARTIFACT and binding.scope_id == requested.scope_id ) @@ -452,8 +621,52 @@ def _administrative_check(resource: ResourceRef) -> tuple[AccessAction, Resource return AccessAction.SCOPE_ADMIN, resource parent = resource.parent_scope if parent is None: - raise AccessInvalidRequestError("handoff-reference") - return AccessAction.SCOPE_DELEGATE, parent + raise AccessInvalidRequestError("artifact-reference") + profile = artifact_family_profile(resource) + action = AccessAction.SCOPE_DELEGATE if profile.family == "handoff" else AccessAction.SCOPE_ADMIN + return action, parent + + +def _resource_is_parent(resource: ResourceRef, child_type: AccessResourceType) -> bool: + if resource.type is AccessResourceType.SERVER: + return child_type is not AccessResourceType.SERVER + return resource.type is AccessResourceType.SCOPE and child_type is AccessResourceType.ARTIFACT + + +def _validate_resource_filter( + value: AuthorizedResourceFilter, + *, + action: AccessAction, + resource_type: AccessResourceType, + family: str | None, + deployment_id: str, +) -> None: + if len(value.exact_resources) + len(value.parent_constraints) > _MAX_AUTHORIZED_FILTER_IDENTITIES: + raise AccessUnavailableError("safe_resource_filtering_unavailable") + if len({resource.key for resource in value.exact_resources}) != len(value.exact_resources): + raise AccessUnavailableError("safe_resource_filtering_unavailable") + for resource in value.exact_resources: + if resource.type is not resource_type or (family is not None and resource.family != family): + raise AccessUnavailableError("safe_resource_filtering_unavailable") + validate_action_resource(action, resource, deployment_id=deployment_id) + for resource in value.parent_constraints: + if not _resource_is_parent(resource, resource_type): + raise AccessUnavailableError("safe_resource_filtering_unavailable") + + +def _validate_provider_decision(value: object) -> None: + if not isinstance(value, AccessDecision) or not isinstance(value.allowed, bool): + raise AccessUnavailableError + reason = value.reason_code + if ( + not reason + or len(reason) > 64 + or not reason[0].isalnum() + or any(not character.isascii() or not (character.isalnum() or character in "._-") for character in reason) + ): + raise AccessUnavailableError + if value.policy_revision is not None and (not value.policy_revision or len(value.policy_revision) > 128): + raise AccessUnavailableError def _required_principal(principal: PrincipalRef | None) -> PrincipalRef: @@ -462,6 +675,20 @@ def _required_principal(principal: PrincipalRef | None) -> PrincipalRef: return principal +def _encode_cursor(resource_key: str) -> str: + return urlsafe_b64encode(resource_key.encode("utf-8")).decode("ascii").rstrip("=") + + +def _decode_cursor(cursor: str | None) -> str | None: + if cursor is None or cursor == "": + return None + try: + padded = f"{cursor}{'=' * (-len(cursor) % 4)}" + return b64decode(padded.encode("ascii"), altchars=b"-_", validate=True).decode("utf-8") + except (UnicodeDecodeError, ValueError) as error: + raise AccessInvalidRequestError("cursor") from error + + async def _access_call(awaitable: Awaitable[_T]) -> _T: try: return await awaitable @@ -475,9 +702,13 @@ async def _access_call(awaitable: Awaitable[_T]) -> _T: "AccessAuditContext", "AccessAuditStore", "AccessControlService", + "AccessProviderCapabilities", + "AccessRequest", "AuthorizationProvider", + "AuthorizedResourceFilter", "AuthorizedResourcePage", "BuiltinAuthorizationProvider", "CreateBinding", "RelationshipWriter", + "ResourceSearchRequest", ) diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 19bf105cb..8daca04bc 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -22,7 +22,7 @@ from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path -from fastapi import FastAPI, Response +from fastapi import FastAPI, Request, Response from fastapi.routing import APIRoute from starlette.middleware import Middleware @@ -41,8 +41,9 @@ from powercontext.paths import default_scheduler_path from powercontext.server.access import HttpAccessLogMiddleware from powercontext.server.app import create_app -from powercontext.server.authz import AccessControlService, PrincipalRef +from powercontext.server.authz import AccessAction, AccessAuditContext, AccessControlService, PrincipalRef, ResourceRef from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.context import current_principal, current_request_id from powercontext.server.mcp import mount_mcp from powercontext.server.metrics import CONTENT_TYPE_LATEST, HttpMetricsMiddleware, ServerMetrics from powercontext.server.middleware import StaticBearerMiddleware @@ -53,6 +54,26 @@ logger = logging.getLogger(__name__) +class _MetricsEndpoint: + def __init__(self, metrics: ServerMetrics) -> None: + self._metrics = metrics + + async def __call__(self, request: Request) -> Response: + access: AccessControlService | None = request.app.state.access_control + if access is not None: + await access.require( + current_principal(), + AccessAction.SERVER_OBSERVE, + ResourceRef.server(access.deployment_id), + context=AccessAuditContext( + transport="http", + operation="get_metrics", + request_id=current_request_id(), + ), + ) + return Response(self._metrics.render(), media_type=CONTENT_TYPE_LATEST) + + def create_server_app( *, settings: ServerSettings | None = None, @@ -71,6 +92,10 @@ def create_server_app( """Build the Server process and mount MCP when configured.""" resolved = ServerSettings() if settings is None else settings + if resolved.access.mode == "enforced" and not resolved.auth.enabled and access_control is None: + raise ValueError( # noqa: TRY003 + "enforced Access Control requires authentication and an Authorization Provider" + ) config = BuiltinConfig( runtime=resolved.runtime, database=resolved.database, @@ -83,7 +108,11 @@ def create_server_app( if metrics is not None: metrics.set_ready(False) readiness_probe = _ServerReadinessProbe(metrics, tracing=resolved_tracing) - static_principal = PrincipalRef(type="service", issuer="powercontext:static", id="server-token") + static_principal = PrincipalRef( + type="service", + issuer=f"powercontext:{resolved.access.deployment_id}:static", + id="server-token", + ) configured_access_control = None if resolved.access.mode == "disabled" else access_control @asynccontextmanager @@ -115,6 +144,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: open_builtin_access_control( resolved.database, bootstrap_administrators=administrators, + deployment_id=resolved.access.deployment_id, + mode=resolved.access.mode, ) ) readiness_probe.bind(runtime) @@ -162,12 +193,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: tracing=resolved_tracing, handoff_report_enabled=resolved.handoff_report.enabled, access_control=configured_access_control, + access_mode=resolved.access.mode, + agent_skill_targets=config.external_skills.agent_targets, ) _mount_optional_web_ui(app, resolved) if metrics is not None: app.add_api_route( "/metrics", - lambda: Response(metrics.render(), media_type=CONTENT_TYPE_LATEST), + _MetricsEndpoint(metrics), include_in_schema=False, ) operations = _http_operations(app) diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index d5a4cd91c..c04d1fe59 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -113,6 +113,7 @@ class AccessControlConfig(BaseModel): mode: Literal["disabled", "legacy-static-admin", "enforced"] = "legacy-static-admin" bootstrap_static_principal: bool = True + deployment_id: str = Field(default="powercontext", min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$") class DashboardScopeConfig(BaseModel): diff --git a/src/powercontext/server/static/review.js b/src/powercontext/server/static/review.js index ee7a4e665..59d5c8ee9 100644 --- a/src/powercontext/server/static/review.js +++ b/src/powercontext/server/static/review.js @@ -383,7 +383,6 @@ const publicationEmpty = document.getElementById("review-publication-empty"); const publicationContent = document.getElementById("review-publication-content"); const publicationTarget = document.getElementById("review-publication-target"); const publishedRevision = document.getElementById("review-published-revision"); -const publicationDestination = document.getElementById("review-publication-destination"); const publicationDiscovery = document.getElementById("review-publication-discovery"); const createSkillRevisionButton = document.getElementById("review-create-skill-revision"); const publishSkillButton = document.getElementById("review-publish-skill"); @@ -1557,7 +1556,6 @@ function renderPublication() { publishedRevision.textContent = target.published_revision === null ? translate("notProvided") : translate("version", {version: target.published_revision}); - publicationDestination.textContent = target.destination; publicationDiscovery.textContent = translate(discoveryStateKey(target.discovery)); publishSkillButton.textContent = translate(publicationActionKey(target)); const canPublish = canPublishProjection(target); diff --git a/src/powercontext/server/static/skills.js b/src/powercontext/server/static/skills.js index 9d5468f48..e55f1fc1b 100644 --- a/src/powercontext/server/static/skills.js +++ b/src/powercontext/server/static/skills.js @@ -318,7 +318,6 @@ const deliveryContent = document.getElementById("skills-delivery-content"); const deliveryTarget = document.getElementById("skills-delivery-target"); const publishedRevision = document.getElementById("skills-published-revision"); const discovery = document.getElementById("skills-discovery"); -const destination = document.getElementById("skills-destination"); const createRevisionButton = document.getElementById("skills-create-revision"); const publishButton = document.getElementById("skills-publish"); const publishDialog = document.getElementById("skills-publish-dialog"); @@ -889,7 +888,6 @@ function renderDelivery() { ? translate("unavailable") : String(target.published_revision); discovery.textContent = translate(discoveryStateKey(target.discovery)); - destination.textContent = target.destination; publishButton.textContent = translate(publicationActionKey(target)); const canPublish = canPublishProjection(target); publishButton.hidden = !canPublish; diff --git a/src/powercontext/server/templates/pages/review.html b/src/powercontext/server/templates/pages/review.html index 7a33cfaff..3322404fb 100644 --- a/src/powercontext/server/templates/pages/review.html +++ b/src/powercontext/server/templates/pages/review.html @@ -170,10 +170,6 @@

Managed Sk -
- Destination - -
diff --git a/src/powercontext/server/templates/pages/skills.html b/src/powercontext/server/templates/pages/skills.html index 9f8a43b51..d09c89f4a 100644 --- a/src/powercontext/server/templates/pages/skills.html +++ b/src/powercontext/server/templates/pages/skills.html @@ -181,10 +181,6 @@

Delivery

-
- Destination - -
diff --git a/src/powercontext/server/web.py b/src/powercontext/server/web.py index b3f8f023a..1ffb569e5 100644 --- a/src/powercontext/server/web.py +++ b/src/powercontext/server/web.py @@ -42,6 +42,8 @@ from powercontext.builtin.runtime import GetArtifactCandidateRequest, GetSkillRequest, ListExternalSkillsRequest from powercontext.http import ErrorDetail, ErrorResponse from powercontext.limits import MAX_ARTIFACT_ID_LENGTH +from powercontext.server.authz import AccessAction, AccessAuditContext, AccessControlService, ResourceRef +from powercontext.server.context import current_principal, current_request_id logger = logging.getLogger(__name__) @@ -93,10 +95,10 @@ class DashboardSkillProjectionTarget(BaseModel): target_id: str agent_kind: AgentKind installation_scope: Literal["user", "project", "plugin"] - destination: str + capabilities: tuple[Literal["publish"], ...] = ("publish",) state: AgentSkillProjectionState published_revision: int | None = None - reason: str | None = None + reason_code: str | None = None discovery: Literal["available", "unavailable", "not_published"] external_skill_id: str | None = None @@ -121,6 +123,7 @@ async def inspect( request: DashboardSkillProjectionRequest, http_request: Request, ) -> DashboardSkillProjection | JSONResponse: + await _authorize_dashboard_skill(http_request, request, operation="dashboard_skill_projection_status") resolved = await _dashboard_managed_skill(http_request, request, self._scope_ids) if isinstance(resolved, JSONResponse): return resolved @@ -132,6 +135,12 @@ async def publish( request: DashboardSkillPublishRequest, http_request: Request, ) -> DashboardSkillProjection | JSONResponse: + await _authorize_dashboard_skill( + http_request, + request, + operation="dashboard_skill_projection_publish", + publish=True, + ) resolved = await _dashboard_managed_skill(http_request, request, self._scope_ids) if isinstance(resolved, JSONResponse): return resolved @@ -155,22 +164,28 @@ async def publish( 409, "skill_projection_conflict", "The Agent Skill publication target changed or cannot be updated safely.", - details={"state": error.status.state.value, "reason": error.status.reason}, + details={ + "state": error.status.state.value, + "reason_code": _projection_reason_code(error.status.state), + }, ) - except (OSError, UnicodeError, ValueError) as error: + except (OSError, UnicodeError, ValueError): return _web_error( 422, "skill_projection_failed", "The approved managed Skill could not be published to the configured Agent target.", - details={"reason": str(error)}, + details={"reason_code": "projection_failed"}, ) # The publication itself succeeded above; registry bookkeeping failure must not turn the # response into a 500 because _skill_projection_response reports on-disk state anyway. try: await application.external_skills.for_scope(request.scope_id).scan() - except Exception as error: + except Exception: log_safely( - logger, logging.WARNING, "PowerContext external Skill scan failed after publication", exc_info=error + logger, + logging.WARNING, + "PowerContext external Skill scan failed after publication", + extra={"error_code": "external_skill_scan_failed"}, ) return await _skill_projection_response(application, request.scope_id, skill, self._targets) @@ -265,9 +280,9 @@ async def handoff_report_page(request: Request) -> Response: headers=_PAGE_HEADERS, ) - async def list_dashboard_scopes(response: Response) -> tuple[DashboardScope, ...]: + async def list_dashboard_scopes(request: Request, response: Response) -> tuple[DashboardScope, ...]: response.headers["Cache-Control"] = "no-store" - return dashboard_scopes + return await _visible_dashboard_scopes(request, dashboard_scopes) if dashboard_enabled: router.add_api_route( @@ -365,6 +380,55 @@ async def _dashboard_managed_skill( return application, skill +async def _visible_dashboard_scopes( + request: Request, + dashboard_scopes: tuple[DashboardScope, ...], +) -> tuple[DashboardScope, ...]: + access: AccessControlService | None = request.app.state.access_control + if access is None or not dashboard_scopes: + return dashboard_scopes + checks = tuple((AccessAction.SCOPE_READ, ResourceRef.scope(item.scope_id)) for item in dashboard_scopes) + decisions = await access.check_batch( + current_principal(), + checks, + context=_dashboard_access_context("dashboard_scopes"), + ) + return tuple(item for item, decision in zip(dashboard_scopes, decisions, strict=True) if decision.allowed) + + +async def _authorize_dashboard_skill( + request: Request, + selection: DashboardSkillProjectionRequest, + *, + operation: str, + publish: bool = False, +) -> None: + access: AccessControlService | None = request.app.state.access_control + if access is None: + return + resource = ResourceRef.artifact( + selection.scope_id, + family=selection.artifact.family, + artifact_id=selection.artifact.artifact_id, + revision=selection.artifact.revision, + ) + checks = [ + (AccessAction.SERVER_OBSERVE, ResourceRef.server(access.deployment_id)), + (AccessAction.ARTIFACT_READ, resource), + ] + if publish: + checks.append((AccessAction.SKILL_PUBLISH, resource)) + await access.require_all( + current_principal(), + checks, + context=_dashboard_access_context(operation), + ) + + +def _dashboard_access_context(operation: str) -> AccessAuditContext: + return AccessAuditContext(transport="http", operation=operation, request_id=current_request_id()) + + async def _skill_projection_response( application, scope_id: str, @@ -381,8 +445,13 @@ async def _skill_projection_response( registrations = await application.external_skills.for_scope(scope_id).list( ListExternalSkillsRequest(include_unavailable=True) ) - except Exception as error: - log_safely(logger, logging.WARNING, "PowerContext external Skill registry discovery failed", exc_info=error) + except Exception: + log_safely( + logger, + logging.WARNING, + "PowerContext external Skill registry discovery failed", + extra={"error_code": "external_skill_registry_discovery_failed"}, + ) registrations = () targets = [] for target in targets_config: @@ -406,10 +475,9 @@ async def _skill_projection_response( target_id=target.target_id, agent_kind=target.agent_kind, installation_scope=target.installation_scope, - destination=str(status.destination), state=status.state, published_revision=(None if status.published_artifact is None else status.published_artifact.revision), - reason=status.reason, + reason_code=_projection_reason_code(status.state), discovery=discovery, external_skill_id=(None if registration is None else registration.registration.external_skill_id), ) @@ -417,6 +485,14 @@ async def _skill_projection_response( return DashboardSkillProjection(artifact=skill.as_ref(), name=skill.content.name, targets=targets) +def _projection_reason_code(state: AgentSkillProjectionState) -> str | None: + return { + AgentSkillProjectionState.CONFLICT: "projection_conflict", + AgentSkillProjectionState.DRIFTED: "projection_drifted", + AgentSkillProjectionState.INCOMPATIBLE: "projection_incompatible", + }.get(state) + + def _web_error( response_status: int, code: str, diff --git a/tests/e2e/real_experience_skill/test_access_control.py b/tests/e2e/real_experience_skill/test_access_control.py new file mode 100644 index 000000000..009a06454 --- /dev/null +++ b/tests/e2e/real_experience_skill/test_access_control.py @@ -0,0 +1,173 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Explicitly enabled Access Control acceptance against the configured database.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from uuid import uuid4 + +import pytest +from dotenv import load_dotenv +from sqlalchemy import delete, func, select + +from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile +from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.runtime import DatabaseConfig +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessDeniedError, + AccessResourceType, + AccessRole, + CreateBinding, + PrincipalRef, + ResourceRef, +) +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.repository import ACCESS_AUDIT_EVENTS_TABLE, ACCESS_BINDINGS_TABLE +from powercontext.server.settings import ServerSettings + +pytestmark = pytest.mark.real_e2e + + +def test_configured_database_persists_exact_skill_grant_and_revocation(pytestconfig: pytest.Config) -> None: + if pytestconfig.getoption("real_e2e_mode") not in {"configured", "all"}: + pytest.skip("configured Access Control acceptance runs in configured mode") + + load_dotenv(pytestconfig.getoption("real_e2e_env_file"), override=False) + settings = ServerSettings() + suffix = uuid4().hex + scope_id = f"configured-real-access:{suffix}" + deployment_id = f"configured-real-access-{suffix}" + admin = PrincipalRef(type="service", issuer=f"powercontext:{deployment_id}", id="admin") + receiver = PrincipalRef(type="user", issuer=f"powercontext:{deployment_id}", id="receiver") + + async def scenario() -> None: + exact = ResourceRef.artifact( + scope_id, + family="skill", + artifact_id=f"managed-skill-{suffix}", + revision=7, + ) + adjacent = ResourceRef.artifact( + scope_id, + family="skill", + artifact_id=f"managed-skill-{suffix}", + revision=8, + ) + context = AccessAuditContext(transport="test", operation="configured-real-access") + try: + async with open_builtin_access_control( + settings.database, + bootstrap_administrators=(admin,), + deployment_id=deployment_id, + ) as access: + binding = await access.create_binding( + admin, + CreateBinding( + subject=receiver, + resource=exact, + role=AccessRole.SKILL_PUBLISHER, + idempotency_key=f"publish-exact-skill-{suffix}", + ), + context=context, + ) + decisions = await access.require_all( + receiver, + ( + (AccessAction.ARTIFACT_READ, exact), + (AccessAction.SKILL_PUBLISH, exact), + ), + context=context, + ) + assert all(decision.allowed for decision in decisions) + with pytest.raises(AccessDeniedError): + await access.require(receiver, AccessAction.ARTIFACT_READ, adjacent, context=context) + + visible = await access.list_resources( + receiver, + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="skill", + context=context, + ) + assert visible.items == (exact,) + assert visible.total == 1 + + revoked = await access.revoke_binding( + admin, + binding.binding_id, + expected_version=binding.version, + context=context, + ) + assert revoked.version == binding.version + 1 + with pytest.raises(AccessDeniedError): + await access.require(receiver, AccessAction.ARTIFACT_READ, exact, context=context) + assert ( + await access.list_resources( + receiver, + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="skill", + context=context, + ) + ).total == 0 + finally: + remaining = await _purge_scope(settings.database, scope_id) + assert remaining == 0 + + asyncio.run(scenario()) + + +async def _purge_scope(database: DatabaseConfig, scope_id: str) -> int: + async with _profile(database) as profile, profile.database.transaction() as connection: + await connection.execute( + delete(ACCESS_AUDIT_EVENTS_TABLE).where(ACCESS_AUDIT_EVENTS_TABLE.c.scope_id == scope_id) + ) + await connection.execute(delete(ACCESS_BINDINGS_TABLE).where(ACCESS_BINDINGS_TABLE.c.scope_id == scope_id)) + binding_count = int( + await connection.scalar( + select(func.count()) + .select_from(ACCESS_BINDINGS_TABLE) + .where(ACCESS_BINDINGS_TABLE.c.scope_id == scope_id) + ) + or 0 + ) + audit_count = int( + await connection.scalar( + select(func.count()) + .select_from(ACCESS_AUDIT_EVENTS_TABLE) + .where(ACCESS_AUDIT_EVENTS_TABLE.c.scope_id == scope_id) + ) + or 0 + ) + return binding_count + audit_count + + +@asynccontextmanager +async def _profile(database: DatabaseConfig) -> AsyncIterator[OceanBaseProfile | SeekDBProfile | SQLiteProfile]: + if isinstance(database, OceanBaseConfig): + context = OceanBaseProfile.open(database, tables=()) + elif isinstance(database, SeekDBConfig): + context = SeekDBProfile.open(database, tables=()) + else: + assert isinstance(database, SQLiteConfig) + context = SQLiteProfile.open(database, tables=()) + async with context as profile: + yield profile diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py new file mode 100644 index 000000000..0606c336c --- /dev/null +++ b/tests/e2e/test_access_control_http.py @@ -0,0 +1,236 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import httpx +import pytest +from starlette.middleware import Middleware + +from powercontext.builtin.artifacts.handoff import HandoffDraft, HandoffGenerationRequest, HandoffStatement +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.client import ForbiddenResponseError, PowerContextClient +from powercontext.http import ( + AccessAction, + AccessResourceType, + AcknowledgeHandoffRequest, + ActivateHandoffRequest, + CaptureContentSourceRequest, + CommitHandoffRequest, + ContinueHandoffRequest, + CreateAccessBindingRequest, + FinalizeHandoffRequest, + HandoffSelection, + ListAccessResourcesRequest, + RevokeAccessBindingRequest, +) +from powercontext.server.authz import AccessControlService, PrincipalRef +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.factory import create_server_app +from powercontext.server.middleware import StaticBearerMiddleware +from powercontext.server.settings import ( + AccessControlConfig, + DashboardConfig, + McpConfig, + MetricsConfig, + ServerSettings, +) + +ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") +RECEIVER = PrincipalRef(type="user", issuer="https://identity.example", id="bob") +DEPLOYMENT_ID = "access-control-http-e2e" + + +class _DeterministicHandoffPipeline: + async def generate(self, request: HandoffGenerationRequest, /) -> HandoffDraft: + citations = tuple(item.citation for item in request.evidence) + return HandoffDraft( + objective=request.objective, + state=(HandoffStatement(text="The exact Handoff is ready for its receiver.", citations=citations),), + disposition="continuable", + next_action=HandoffStatement(text="Acknowledge only this committed Revision.", citations=citations), + ) + + +def test_exact_handoff_grant_and_revoke_cross_the_public_server_boundary(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}") + async with open_builtin_access_control( + database, + bootstrap_administrators=(ADMIN,), + deployment_id=DEPLOYMENT_ID, + ) as access_control: + async with _client( + _app(database, access_control, ADMIN, "admin-token", tmp_path / "admin-scheduler.db"), + "admin-token", + ) as admin: + captured = await admin.capture_content_source( + CaptureContentSourceRequest( + scope_id="access-e2e", + source_id="handoff-boundary", + content="The receiver must see only one explicitly shared Handoff Revision.", + ) + ) + activation = await admin.activate_handoff( + ActivateHandoffRequest( + scope_id="access-e2e", + boundary_source=captured.source, + objective="Transfer one exact committed Handoff.", + ) + ) + assert activation.draft is not None + prepared = await admin.finalize_handoff( + FinalizeHandoffRequest(scope_id="access-e2e", draft=activation.draft) + ) + committed = await admin.commit_handoff(CommitHandoffRequest(scope_id="access-e2e", handoff=prepared)) + resource = { + "type": "artifact", + "scope_id": "access-e2e", + "reference": committed.reference.model_dump(mode="json"), + "selector": None, + } + binding = await admin.create_access_binding( + CreateAccessBindingRequest.model_validate({ + "subject": { + "type": RECEIVER.type, + "issuer": RECEIVER.issuer, + "id": RECEIVER.id, + }, + "resource": resource, + "role": "handoff.receiver", + "idempotency_key": "share-exact-handoff-with-bob", + }) + ) + + async with _client( + _app(database, access_control, RECEIVER, "receiver-token", tmp_path / "receiver-scheduler.db"), + "receiver-token", + ) as receiver: + exact = await receiver.continue_handoff( + ContinueHandoffRequest( + scope_id="access-e2e", + selection=HandoffSelection.EXACT, + revision=committed.reference, + ) + ) + assert exact.selected_revision == committed.reference + receipt = await receiver.acknowledge_handoff( + AcknowledgeHandoffRequest.model_validate({ + "scope_id": "access-e2e", + "source_id": "receiver-acknowledgement", + "receiver": RECEIVER.id, + "status": "accepted", + "selection": "exact", + "receiver_checks": { + "live_state": "confirmed", + "capability": "confirmed", + "authorization": "confirmed", + }, + "revision": committed.reference, + }) + ) + assert receipt.resolution.selected_revision == committed.reference + + with pytest.raises(ForbiddenResponseError): + await receiver.continue_handoff( + ContinueHandoffRequest(scope_id="access-e2e", selection=HandoffSelection.LATEST) + ) + visible = await receiver.list_access_resources( + ListAccessResourcesRequest( + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="handoff", + ) + ) + assert visible.total == 1 + assert visible.items[0].model_dump(mode="json") == resource + + async with _client( + _app(database, access_control, ADMIN, "admin-token", tmp_path / "revoke-scheduler.db"), + "admin-token", + ) as admin: + revoked = await admin.revoke_access_binding( + RevokeAccessBindingRequest(binding_id=binding.binding_id, expected_version=binding.version) + ) + assert revoked.state == "revoked" + + async with _client( + _app(database, access_control, RECEIVER, "receiver-token", tmp_path / "denied-scheduler.db"), + "receiver-token", + ) as receiver: + with pytest.raises(ForbiddenResponseError): + await receiver.continue_handoff( + ContinueHandoffRequest( + scope_id="access-e2e", + selection=HandoffSelection.EXACT, + revision=committed.reference, + ) + ) + visible = await receiver.list_access_resources( + ListAccessResourcesRequest( + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="handoff", + ) + ) + assert visible.total == 0 + assert visible.items == [] + + asyncio.run(scenario()) + + +def _app( + database: SQLiteConfig, + access_control: AccessControlService, + principal: PrincipalRef, + token: str, + scheduler_path: Path, +): + return create_server_app( + settings=ServerSettings( + database=database, + access=AccessControlConfig( + mode="enforced", + bootstrap_static_principal=False, + deployment_id=DEPLOYMENT_ID, + ), + dashboard=DashboardConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + mcp=McpConfig(enabled=False), + ), + scheduler_path=scheduler_path, + handoff_pipeline=_DeterministicHandoffPipeline(), + access_control=access_control, + middleware=(Middleware(StaticBearerMiddleware, token=token, principal=principal),), + ) + + +@asynccontextmanager +async def _client(app, token: str) -> AsyncIterator[PowerContextClient]: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + PowerContextClient( + "http://testserver", + token=token, + http_client=transport, + trust_transport_security=True, + ) as client, + ): + yield client diff --git a/tests/e2e/test_runtime_server.py b/tests/e2e/test_runtime_server.py index 06de49167..7802c1215 100644 --- a/tests/e2e/test_runtime_server.py +++ b/tests/e2e/test_runtime_server.py @@ -84,6 +84,13 @@ from powercontext.server.settings import McpConfig, ServerSettings OCEANBASE_URL = os.environ.get("POWERCONTEXT_TEST_OCEANBASE_URL") +_ACCESS_READINESS_CHECKS = { + "access_mode": "legacy-static-admin", + "access_provider": "disabled", + "access_resource_kinds": "server,scope,artifact", + "access_artifact_families": "experience:enabled,handoff:enabled,memory:enabled,prompt:disabled,skill:enabled", + "access_skill_publication": "disabled", +} EMBEDDING_PROFILE = EmbeddingProfile( profile_id="database-e2e-v1", model="database-e2e", @@ -206,7 +213,7 @@ async def scenario() -> None: ) entries = await client.list_memory_entries(ListMemoryEntriesRequest(scope_id=scope_id)) - assert readiness.checks == {"runtime": "ready", "database": "ready"} + assert readiness.checks == {"runtime": "ready", "database": "ready", **_ACCESS_READINESS_CHECKS} assert capabilities.source_types == ["content"] assert capabilities.memory_extraction is True assert capabilities.search_modes == ["auto", "fts"] @@ -310,6 +317,7 @@ async def scenario() -> None: "runtime": "ready", "database": "ready", "inference.embedding": "misconfigured", + **_ACCESS_READINESS_CHECKS, } assert captured.position == 1 diff --git a/tests/test_access_adapters.py b/tests/test_access_adapters.py new file mode 100644 index 000000000..759644dc1 --- /dev/null +++ b/tests/test_access_adapters.py @@ -0,0 +1,361 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import json +from datetime import UTC, datetime + +import httpx +import pytest +from pydantic import SecretStr + +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessControlService, + AccessProviderCapabilities, + AccessRequest, + AccessResourceType, + AccessRole, + AccessUnavailableError, + AuthZenAuthorizationProvider, + BuiltinAuthorizationProvider, + CasbinAuthorizationProvider, + CreateBinding, + MemoryEntrySelector, + PrincipalRef, + ResourceRef, + ResourceSearchRequest, +) +from powercontext.server.authz.composition import open_casbin_access_control +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository + +NOW = datetime(2026, 9, 1, 12, tzinfo=UTC) +ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") +BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") +ALICE = PrincipalRef(type="user", issuer="https://identity.example", id="alice") +CAROL = PrincipalRef(type="user", issuer="https://identity.example", id="carol") +AUDIT = AccessAuditContext(transport="http", operation="adapter-conformance", request_id="req-adapter") + + +def test_builtin_and_casbin_adapters_share_the_same_access_semantics() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + builtin_provider = BuiltinAuthorizationProvider( + repository, + bootstrap_administrators=(ADMIN,), + clock=lambda: NOW, + ) + casbin_provider = CasbinAuthorizationProvider( + repository, + bootstrap_administrators=(ADMIN,), + clock=lambda: NOW, + ) + casbin_service = AccessControlService( + casbin_provider, + relationships=casbin_provider, + audit=repository, + clock=lambda: NOW, + ) + exact = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=3) + sibling = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=4) + binding = await casbin_service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=exact, + role=AccessRole.HANDOFF_RECEIVER, + idempotency_key="casbin-handoff-receiver", + ), + context=AUDIT, + ) + await casbin_service.create_binding( + ADMIN, + CreateBinding( + subject=ALICE, + resource=ResourceRef.server(), + role=AccessRole.SERVER_OBSERVER, + idempotency_key="casbin-server-observer", + ), + context=AUDIT, + ) + await casbin_service.create_binding( + ADMIN, + CreateBinding( + subject=CAROL, + resource=ResourceRef.server(), + role=AccessRole.SERVER_ADMIN, + idempotency_key="casbin-server-admin", + ), + context=AUDIT, + ) + + vectors = _handoff_conformance_vectors(exact, sibling) + for action, resource, expected in vectors: + request = AccessRequest(subject=BOB, action=action, resource=resource, context=AUDIT) + builtin = await builtin_provider.check(request) + casbin = await casbin_provider.check(request) + assert builtin.allowed is casbin.allowed is expected + assert builtin.policy_revision == casbin.policy_revision + + administrative_vectors = ( + (ALICE, AccessAction.SERVER_OBSERVE, ResourceRef.server(), True), + (ALICE, AccessAction.SERVER_ADMIN, ResourceRef.server(), False), + (ALICE, AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), False), + (CAROL, AccessAction.SERVER_OBSERVE, ResourceRef.server(), True), + (CAROL, AccessAction.SERVER_ADMIN, ResourceRef.server(), True), + (CAROL, AccessAction.SCOPE_ADMIN, ResourceRef.scope("scope-a"), True), + ( + CAROL, + AccessAction.SKILL_PUBLISH, + ResourceRef.artifact( + "scope-a", + family="skill", + artifact_id="skill-a", + revision=1, + ), + True, + ), + ) + for subject, action, resource, expected in administrative_vectors: + request = AccessRequest(subject=subject, action=action, resource=resource, context=AUDIT) + builtin = await builtin_provider.check(request) + casbin = await casbin_provider.check(request) + assert builtin.allowed is casbin.allowed is expected + + builtin_filter = await builtin_provider.resolve_resource_filter( + _search_request(BOB, AccessAction.ARTIFACT_READ, family="handoff") + ) + casbin_filter = await casbin_provider.resolve_resource_filter( + _search_request(BOB, AccessAction.ARTIFACT_READ, family="handoff") + ) + assert builtin_filter == casbin_filter + + revoked = await casbin_service.revoke_binding( + ADMIN, + binding.binding_id, + expected_version=binding.version, + context=AUDIT, + ) + assert revoked.version == 2 + denied = AccessRequest(subject=BOB, action=AccessAction.ARTIFACT_READ, resource=exact, context=AUDIT) + assert (await builtin_provider.check(denied)).allowed is False + assert (await casbin_provider.check(denied)).allowed is False + + asyncio.run(scenario()) + + +def test_casbin_composition_opens_a_writable_access_service() -> None: + async def scenario() -> None: + async with open_casbin_access_control( + SQLiteConfig(), + bootstrap_administrators=(ADMIN,), + ) as service: + exact = ResourceRef.artifact("scope-a", family="experience", artifact_id="experience-a", revision=1) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=exact, + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key="casbin-composition-viewer", + ), + context=AUDIT, + ) + assert (await service.require(BOB, AccessAction.ARTIFACT_READ, exact, context=AUDIT)).allowed + + asyncio.run(scenario()) + + +def test_authzen_adapter_matches_the_exact_resource_conformance_vector() -> None: + exact = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=3) + sibling = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=4) + vectors = _handoff_conformance_vectors(exact, sibling) + expected = {(action.value, resource.key): allowed for action, resource, allowed in vectors} + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + decisions = [ + { + "decision": expected[ + ( + evaluation["action"]["name"], + evaluation["resource"]["id"], + ) + ] + } + for evaluation in payload["evaluations"] + ] + return httpx.Response(200, json={"evaluations": decisions}) + + async def scenario() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) + requests = tuple( + AccessRequest(subject=BOB, action=action, resource=resource, context=AUDIT) + for action, resource, _expected in vectors + ) + decisions = await provider.check_batch(requests) + assert [decision.allowed for decision in decisions] == [value for _action, _resource, value in vectors] + + asyncio.run(scenario()) + + +def test_authzen_adapter_uses_standard_point_and_boxcar_shapes_and_fails_closed() -> None: + seen: list[dict[str, object]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Authorization"] == "Bearer provider-token" + payload = json.loads(request.content) + seen.append(payload) + if request.url.path.endswith("/evaluation"): + return httpx.Response(200, json={"decision": True, "context": {"policy_revision": "pdp-42"}}) + evaluations = payload["evaluations"] + return httpx.Response( + 200, + json={ + "evaluations": [ + {"decision": evaluation["action"]["name"] == "artifact.read"} for evaluation in evaluations + ] + }, + ) + + async def scenario() -> None: + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + provider = AuthZenAuthorizationProvider( + "http://127.0.0.1:9876", + token=SecretStr("provider-token"), + http_client=client, + ) + resource = ResourceRef.artifact( + "scope-a", + family="memory", + artifact_id="memory-a", + revision=4, + selector=MemoryEntrySelector(entry_id="entry-a", entry_version_id="entry-version-2"), + ) + read = AccessRequest(subject=BOB, action=AccessAction.ARTIFACT_READ, resource=resource, context=AUDIT) + publish = AccessRequest(subject=BOB, action=AccessAction.SKILL_PUBLISH, resource=resource, context=AUDIT) + point = await provider.check(read) + batch = await provider.check_batch((read, publish)) + + assert point.allowed is True + assert point.policy_revision == "pdp-42" + assert [decision.allowed for decision in batch] == [True, False] + assert seen[0] == { + "subject": { + "type": "user", + "id": "bob", + "properties": {"issuer": "https://identity.example"}, + }, + "action": {"name": "artifact.read"}, + "resource": { + "type": "artifact", + "id": resource.key, + "properties": { + "scope_id": "scope-a", + "reference": {"family": "memory", "artifact_id": "memory-a", "revision": 4}, + "selector": { + "type": "memory_entry", + "entry_id": "entry-a", + "entry_version_id": "entry-version-2", + }, + }, + }, + "context": { + "request_id": "req-adapter", + "transport": "http", + "operation": "adapter-conformance", + }, + } + assert seen[1]["options"] == {"evaluations_semantic": "execute_all"} + with pytest.raises(AccessUnavailableError, match="filtering"): + await provider.resolve_resource_filter( + _search_request(BOB, AccessAction.ARTIFACT_READ, family="memory") + ) + + malformed = httpx.MockTransport(lambda _request: httpx.Response(200, json={"decision": "allow"})) + async with httpx.AsyncClient(transport=malformed) as client: + provider = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) + with pytest.raises(AccessUnavailableError): + await provider.check(read) + + asyncio.run(scenario()) + + +def test_authzen_adapter_rejects_credential_urls_and_relationship_claims() -> None: + with pytest.raises(ValueError, match="credential-free"): + AuthZenAuthorizationProvider("https://user:secret@pdp.example") + with pytest.raises(ValueError, match="credential-free"): + AuthZenAuthorizationProvider("http://pdp.example") + + async def scenario() -> None: + repository_profile = SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) + async with repository_profile as profile: + repository = RelationalAccessRepository(profile.database) + transport = httpx.MockTransport(lambda _: httpx.Response(200, json={"decision": True})) + async with httpx.AsyncClient(transport=transport) as client: + provider = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) + service = AccessControlService( + provider, + relationships=None, + audit=repository, + provider_capabilities=AccessProviderCapabilities( + safe_resource_filtering=False, + multi_requirement_check=True, + relationship_management=False, + ), + ) + with pytest.raises(AccessUnavailableError, match="relationship"): + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="unsupported-relationship", + ), + context=AUDIT, + ) + + asyncio.run(scenario()) + + +def _search_request(subject: PrincipalRef, action: AccessAction, *, family: str) -> ResourceSearchRequest: + return ResourceSearchRequest( + subject=subject, + action=action, + resource_type=AccessResourceType.ARTIFACT, + family=family, + context=AUDIT, + ) + + +def _handoff_conformance_vectors( + exact: ResourceRef, + sibling: ResourceRef, +) -> tuple[tuple[AccessAction, ResourceRef, bool], ...]: + return ( + (AccessAction.ARTIFACT_READ, exact, True), + (AccessAction.HANDOFF_EVIDENCE_READ, exact, True), + (AccessAction.HANDOFF_ACKNOWLEDGE, exact, True), + (AccessAction.ARTIFACT_READ, sibling, False), + (AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), False), + (AccessAction.SERVER_OBSERVE, ResourceRef.server(), False), + ) diff --git a/tests/test_access_control.py b/tests/test_access_control.py index 39ce3d3c5..1749b3e9e 100644 --- a/tests/test_access_control.py +++ b/tests/test_access_control.py @@ -18,6 +18,7 @@ from datetime import UTC, datetime, timedelta import pytest +from sqlalchemy import text from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.server.authz import ( @@ -26,14 +27,19 @@ AccessConflictError, AccessControlService, AccessDeniedError, + AccessInvalidRequestError, + AccessProviderCapabilities, + AccessRequest, AccessResourceType, AccessRole, + AccessUnavailableError, BuiltinAuthorizationProvider, CreateBinding, + MemoryEntrySelector, PrincipalRef, ResourceRef, ) -from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository, ensure_access_schema NOW = datetime(2026, 8, 30, 10, tzinfo=UTC) ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") @@ -68,17 +74,19 @@ async def scenario() -> None: with pytest.raises(AccessDeniedError): await service.require( BOB, - AccessAction.HANDOFF_READ, + AccessAction.ARTIFACT_READ, ResourceRef.handoff("scope-a", artifact_id="handoff-b", revision=1), context=AUDIT, ) with pytest.raises(AccessDeniedError): await service.require(BOB, AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), context=AUDIT) - visible = await service.provider.list_resources( + visible = await service.list_resources( BOB, - action=AccessAction.HANDOFF_READ, - resource_type=AccessResourceType.HANDOFF, + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="handoff", + context=AUDIT, ) assert visible.items == (exact,) assert created.policy_revision == "1" @@ -103,7 +111,7 @@ async def scenario() -> None: context=AUDIT, ) handoff = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=1) - assert (await service.require(ALICE, AccessAction.HANDOFF_READ, handoff, context=AUDIT)).allowed + assert (await service.require(ALICE, AccessAction.ARTIFACT_READ, handoff, context=AUDIT)).allowed assert not (await service.check(ALICE, AccessAction.HANDOFF_ACKNOWLEDGE, handoff, context=AUDIT)).allowed expired_provider = BuiltinAuthorizationProvider( @@ -111,7 +119,9 @@ async def scenario() -> None: bootstrap_administrators=(ADMIN,), clock=lambda: NOW + timedelta(hours=2), ) - expired = await expired_provider.check(ALICE, AccessAction.HANDOFF_READ, handoff) + expired = await expired_provider.check( + AccessRequest(subject=ALICE, action=AccessAction.ARTIFACT_READ, resource=handoff, context=AUDIT) + ) assert expired.allowed is False asyncio.run(scenario()) @@ -164,6 +174,36 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_idempotency_key_is_scoped_to_grantor_and_resource() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, repository = _service(profile.database) + first = await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="share-viewer", + ), + context=AUDIT, + ) + second = await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-b"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="share-viewer", + ), + context=AUDIT, + ) + assert first.binding_id != second.binding_id + assert await repository.policy_revision() == "2" + + asyncio.run(scenario()) + + def test_persisted_server_admin_covers_scope_administration() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: @@ -197,6 +237,280 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_artifact_family_profiles_enforce_selector_role_and_delegation_boundaries() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, _ = _service(profile.database) + await service.create_binding( + ADMIN, + CreateBinding( + subject=ALICE, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_DELEGATOR, + idempotency_key="alice-scope-delegator", + ), + context=AUDIT, + ) + handoff = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=1) + delegated = await service.create_binding( + ALICE, + CreateBinding( + subject=BOB, + resource=handoff, + role=AccessRole.HANDOFF_VIEWER, + idempotency_key="bob-handoff-viewer", + ), + context=AUDIT, + ) + assert delegated.granted_by == ALICE + + skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a", revision=1) + with pytest.raises(AccessDeniedError): + await service.create_binding( + ALICE, + CreateBinding( + subject=BOB, + resource=skill, + role=AccessRole.SKILL_PUBLISHER, + idempotency_key="bob-skill-publisher", + ), + context=AUDIT, + ) + with pytest.raises(AccessInvalidRequestError, match="role"): + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=handoff, + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key="invalid-handoff-role", + ), + context=AUDIT, + ) + + memory_without_selector = ResourceRef.artifact( + "scope-a", family="memory", artifact_id="memory-a", revision=1 + ) + with pytest.raises(AccessInvalidRequestError, match="Memory Entry Version"): + await service.check(BOB, AccessAction.ARTIFACT_READ, memory_without_selector, context=AUDIT) + prompt = ResourceRef.artifact("scope-a", family="prompt", artifact_id="prompt-a", revision=1) + with pytest.raises(AccessInvalidRequestError, match="disabled"): + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=prompt, + role=AccessRole.PROMPT_USER, + idempotency_key="disabled-prompt", + ), + context=AUDIT, + ) + + asyncio.run(scenario()) + + +def test_exact_memory_and_skill_grants_do_not_follow_versions_or_collapse_actions() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, _ = _service(profile.database) + memory = ResourceRef.artifact( + "scope-a", + family="memory", + artifact_id="memory-a", + revision=4, + selector=MemoryEntrySelector(entry_id="entry-a", entry_version_id="entry-version-2"), + ) + skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a", revision=7) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=memory, + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key="bob-memory-entry-version", + ), + context=AUDIT, + ) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=skill, + role=AccessRole.SKILL_PUBLISHER, + idempotency_key="bob-skill-publisher", + ), + context=AUDIT, + ) + + assert (await service.require(BOB, AccessAction.ARTIFACT_READ, memory, context=AUDIT)).allowed + future_memory = ResourceRef.artifact( + "scope-a", + family="memory", + artifact_id="memory-a", + revision=5, + selector=MemoryEntrySelector(entry_id="entry-a", entry_version_id="entry-version-3"), + ) + with pytest.raises(AccessDeniedError): + await service.require(BOB, AccessAction.ARTIFACT_READ, future_memory, context=AUDIT) + decisions = await service.require_all( + BOB, + ((AccessAction.ARTIFACT_READ, skill), (AccessAction.SKILL_PUBLISH, skill)), + context=AUDIT, + ) + assert all(decision.allowed for decision in decisions) + with pytest.raises(AccessInvalidRequestError, match="action"): + await service.check(BOB, AccessAction.SKILL_PUBLISH, memory, context=AUDIT) + + asyncio.run(scenario()) + + +def test_safe_listing_is_exact_paginated_and_fails_closed_without_provider_support() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, repository = _service(profile.database) + resources = tuple( + ResourceRef.handoff("scope-a", artifact_id=f"handoff-{index}", revision=1) for index in range(3) + ) + for index, resource in enumerate(resources): + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=resource, + role=AccessRole.HANDOFF_VIEWER, + idempotency_key=f"handoff-{index}-viewer", + ), + context=AUDIT, + ) + first = await service.list_resources( + BOB, + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="handoff", + limit=2, + context=AUDIT, + ) + assert len(first.items) == 2 + assert first.total == 3 + assert first.next_cursor is not None + second = await service.list_resources( + BOB, + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="handoff", + cursor=first.next_cursor, + limit=2, + context=AUDIT, + ) + assert len(second.items) == 1 + assert second.total == 3 + with pytest.raises(AccessInvalidRequestError, match="cursor"): + await service.list_resources( + BOB, + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="handoff", + cursor="not-base64!", + context=AUDIT, + ) + + unavailable = AccessControlService( + service.provider, + relationships=repository, + audit=repository, + provider_capabilities=AccessProviderCapabilities( + safe_resource_filtering=False, + multi_requirement_check=True, + relationship_management=True, + ), + ) + with pytest.raises(AccessUnavailableError, match="filtering"): + await unavailable.list_resources( + BOB, + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="handoff", + context=AUDIT, + ) + no_multi_check = AccessControlService( + service.provider, + relationships=repository, + audit=repository, + provider_capabilities=AccessProviderCapabilities( + safe_resource_filtering=True, + multi_requirement_check=False, + relationship_management=True, + ), + ) + with pytest.raises(AccessUnavailableError, match="multi-requirement"): + await no_multi_check.require_all( + BOB, + ( + (AccessAction.ARTIFACT_READ, resources[0]), + (AccessAction.HANDOFF_EVIDENCE_READ, resources[0]), + ), + context=AUDIT, + ) + + asyncio.run(scenario()) + + +def test_access_self_is_not_exposed_as_a_public_audit_action() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, repository = _service(profile.database) + decision = await service.check(BOB, AccessAction.ACCESS_SELF, ResourceRef.server(), context=AUDIT) + assert decision.allowed is True + assert await repository.list_audit() == () + + asyncio.run(scenario()) + + +def test_handoff_only_schema_is_migrated_without_losing_bindings_or_audit() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + service, repository = _service(profile.database) + handoff = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=1) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=handoff, + role=AccessRole.HANDOFF_VIEWER, + idempotency_key="legacy-handoff-viewer", + ), + context=AUDIT, + ) + await service.require(BOB, AccessAction.ARTIFACT_READ, handoff, context=AUDIT) + async with profile.database.transaction() as connection: + for table_name in ("pc_access_bindings", "pc_access_audit_events"): + for column_name in ( + "deployment_id", + "selector_type", + "selector_entry_id", + "selector_entry_version_id", + ): + await connection.exec_driver_sql(f"ALTER TABLE {table_name} DROP COLUMN {column_name}") + await connection.execute( + text( + f"UPDATE {table_name} SET resource_type = 'handoff' " # noqa: S608 + "WHERE resource_type = 'artifact'" + ) + ) + await connection.execute( + text("UPDATE pc_access_audit_events SET action = 'handoff.read' WHERE action = 'artifact.read'") + ) + await ensure_access_schema(connection) + + bindings = await repository.list_bindings(subject=BOB) + assert len(bindings) == 1 + assert bindings[0].resource == handoff + audit = await repository.list_audit() + assert any(event.action is AccessAction.ARTIFACT_READ and event.resource == handoff for event in audit) + + asyncio.run(scenario()) + + def _service(database) -> tuple[AccessControlService, RelationalAccessRepository]: repository = RelationalAccessRepository(database) provider = BuiltinAuthorizationProvider( diff --git a/tests/test_access_http.py b/tests/test_access_http.py index 196c936fb..b6a0b2a7f 100644 --- a/tests/test_access_http.py +++ b/tests/test_access_http.py @@ -15,18 +15,92 @@ from __future__ import annotations import asyncio +from pathlib import Path +from types import SimpleNamespace +from typing import Self import httpx +import pytest from starlette.middleware import Middleware +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.memory import MemoryEntryVersion +from powercontext.builtin.artifacts.skill import AgentSkillTarget, Skill, SkillContent from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.runtime import MemoryEntryRecord from powercontext.server.app import create_app -from powercontext.server.authz import AccessControlService, BuiltinAuthorizationProvider, PrincipalRef +from powercontext.server.authz import ( + AccessAuditContext, + AccessControlService, + AccessRole, + BuiltinAuthorizationProvider, + CreateBinding, + MemoryEntrySelector, + PrincipalRef, + ResourceRef, +) from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.factory import create_server_app from powercontext.server.middleware import StaticBearerMiddleware +from powercontext.server.settings import AccessControlConfig, ServerSettings +from powercontext.server.web import mount_web_ui ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") +ALICE = PrincipalRef(type="user", issuer="https://identity.example", id="alice") +AUDIT = AccessAuditContext(transport="test", operation="seed") + + +def test_enforced_mode_cannot_silently_start_without_authentication_or_provider() -> None: + with pytest.raises(ValueError, match="enforced Access Control"): + create_server_app(settings=ServerSettings(access=AccessControlConfig(mode="enforced"))) + + +class _HandoffShareability: + def for_scope(self, scope_id: str) -> Self: + del scope_id + return self + + async def revision(self, artifact) -> object: + del artifact + return object() + + +class _MemoryApplication: + def __init__(self, record: MemoryEntryRecord) -> None: + self.record = record + + def for_scope(self, scope_id: str) -> Self: + del scope_id + return self + + async def get(self, request) -> MemoryEntryRecord: + del request + return self.record + + +class _SkillApplication: + def __init__(self, result: object | None = None) -> None: + self.get_calls = 0 + self.result = object() if result is None else result + + def for_scope(self, scope_id: str) -> Self: + del scope_id + return self + + async def get(self, request) -> object: + del request + self.get_calls += 1 + return self.result + + +class _ExternalSkillsApplication: + def for_scope(self, scope_id: str) -> Self: + del scope_id + return self + + async def scan(self) -> object: + return object() def test_access_api_and_handoff_pep_enforce_exact_receiver_visibility() -> None: @@ -38,26 +112,46 @@ async def scenario() -> None: relationships=repository, audit=repository, ) - admin_app = _app(service, principal=ADMIN, token="admin-token") # noqa: S106 - test credential. + admin_app = _app( + service, + principal=ADMIN, + token="admin-token", # noqa: S106 - test credential. + application=SimpleNamespace(handoff=_HandoffShareability()), + ) async with _client(admin_app) as admin: + readiness = await admin.get("/health/ready") + assert readiness.status_code == 200 + readiness_checks = readiness.json()["checks"] + assert readiness_checks["access_mode"] == "enforced" + assert readiness_checks["access_provider"] == "ready" + assert readiness_checks["access_resource_kinds"] == "server,scope,artifact" principal = await admin.get("/v1/access/me", headers=_auth("admin-token")) assert principal.status_code == 200 - assert principal.json() == { + assert principal.json()["principal"] == { "type": "user", "issuer": "https://identity.example", "id": "admin", } + assert principal.json()["mode"] == "enforced" + assert principal.json()["resource_kinds"] == ["server", "scope", "artifact"] + assert { + profile["family"] for profile in principal.json()["artifact_families"] if profile["enabled"] + } == { + "handoff", + "memory", + "experience", + "skill", + } created = await admin.post( "/v1/access/bindings/create", headers=_auth("admin-token"), json={ "subject": {"type": "user", "issuer": "https://identity.example", "id": "bob"}, "resource": { - "type": "handoff", + "type": "artifact", "scope_id": "scope-a", - "family": "handoff", - "artifact_id": "handoff-a", - "revision": 3, + "reference": {"family": "handoff", "artifact_id": "handoff-a", "revision": 3}, + "selector": None, }, "role": "handoff.receiver", "idempotency_key": "handoff-a-to-bob", @@ -69,11 +163,10 @@ async def scenario() -> None: bob_app = _app(service, principal=BOB, token="bob-token") # noqa: S106 - test credential. async with _client(bob_app) as bob: exact = { - "type": "handoff", + "type": "artifact", "scope_id": "scope-a", - "family": "handoff", - "artifact_id": "handoff-a", - "revision": 3, + "reference": {"family": "handoff", "artifact_id": "handoff-a", "revision": 3}, + "selector": None, } decision = await bob.post( "/v1/access/check", @@ -86,10 +179,11 @@ async def scenario() -> None: resources = await bob.post( "/v1/access/resources/list", headers=_auth("bob-token"), - json={"action": "handoff.read", "resource_type": "handoff"}, + json={"action": "artifact.read", "resource_type": "artifact", "family": "handoff"}, ) assert resources.status_code == 200 assert resources.json()["items"] == [exact] + assert resources.json()["total"] == 1 denied = await bob.post( "/v1/handoff/continue", @@ -103,6 +197,13 @@ async def scenario() -> None: assert denied.status_code == 403, denied.json() assert denied.json()["error"]["code"] == "forbidden" + latest = await bob.post( + "/v1/handoff/continue", + headers=_auth("bob-token"), + json={"scope_id": "scope-a", "selection": "latest"}, + ) + assert latest.status_code == 403 + allowed_to_runtime_boundary = await bob.post( "/v1/handoff/continue", headers=_auth("bob-token"), @@ -133,8 +234,241 @@ async def scenario() -> None: asyncio.run(scenario()) -def _app(service: AccessControlService, *, principal: PrincipalRef, token: str): +def test_exact_memory_entry_version_grant_allows_get_but_not_scope_listing() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + service = AccessControlService( + BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + relationships=repository, + audit=repository, + ) + exact = ResourceRef.artifact( + "scope-a", + family="memory", + artifact_id="memory-a", + revision=4, + selector=MemoryEntrySelector(entry_id="entry-a", entry_version_id="entry-version-2"), + ) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=exact, + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key="bob-exact-memory", + ), + context=AUDIT, + ) + memory_ref = ArtifactRef(family="memory", artifact_id="memory-a", revision=4) + record = MemoryEntryRecord( + memory_ref=memory_ref, + state="active", + entry=MemoryEntryVersion( + memory_artifact_id="memory-a", + entry_id="entry-a", + entry_version_id="entry-version-2", + version=2, + previous_version_id="entry-version-1", + kind="decision", + text="Only this exact Memory Entry Version is shared.", + entry_content_hash="a" * 64, + created_in_revision=4, + ), + ) + app = _app( + service, + principal=BOB, + token="bob-token", # noqa: S106 - test credential. + application=SimpleNamespace(memory=_MemoryApplication(record)), + ) + request = { + "scope_id": "scope-a", + "citation": { + "memory_ref": {"family": "memory", "artifact_id": "memory-a", "revision": 4}, + "entry_id": "entry-a", + "entry_version_id": "entry-version-2", + }, + } + async with _client(app) as client: + allowed = await client.post("/v1/memory/entries/get", headers=_auth("bob-token"), json=request) + assert allowed.status_code == 200, allowed.json() + assert allowed.json()["text"] == "Only this exact Memory Entry Version is shared." + + sibling = request | {"citation": request["citation"] | {"entry_version_id": "entry-version-3"}} + denied = await client.post("/v1/memory/entries/get", headers=_auth("bob-token"), json=sibling) + assert denied.status_code == 403 + + aggregate = await client.post( + "/v1/memory/entries/list", + headers=_auth("bob-token"), + json={"scope_id": "scope-a"}, + ) + assert aggregate.status_code == 403 + + asyncio.run(scenario()) + + +def test_skill_publication_requires_read_and_publish_before_target_lookup(tmp_path: Path) -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + service = AccessControlService( + BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + relationships=repository, + audit=repository, + ) + skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a", revision=7) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=skill, + role=AccessRole.SKILL_PUBLISHER, + idempotency_key="bob-skill-publisher", + ), + context=AUDIT, + ) + await service.create_binding( + ADMIN, + CreateBinding( + subject=ALICE, + resource=skill, + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key="alice-skill-viewer", + ), + context=AUDIT, + ) + managed_skill = Skill( + artifact_id="skill-a", + revision=7, + content=SkillContent( + name="safe-publication", + description="Publish one exact managed Skill safely.", + instructions="Use the exact reviewed instructions.", + validation=("The exact revision is preserved.",), + ), + ) + runtime_skill = _SkillApplication(managed_skill) + target_path = tmp_path / "private-host-path" / "skills" + target = AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=target_path, + allow_managed_publish=True, + ) + application = SimpleNamespace(skill=runtime_skill, external_skills=_ExternalSkillsApplication()) + bob_app = create_app( + application=application, + access_control=service, + middleware=(Middleware(StaticBearerMiddleware, token="bob-token", principal=BOB),), # noqa: S106 + agent_skill_targets=(target,), + ) + payload = { + "scope_id": "scope-a", + "artifact": {"family": "skill", "artifact_id": "skill-a", "revision": 7}, + } + async with _client(bob_app) as bob: + targets = await bob.post( + "/v1/skills/publication-targets/list", + headers=_auth("bob-token"), + json=payload, + ) + assert targets.status_code == 200, targets.json() + assert targets.json()["targets"] == [ + { + "target_id": "codex-project", + "agent_kind": "codex", + "installation_scope": "project", + "capabilities": ["publish"], + } + ] + assert str(target_path) not in targets.text + + missing = await bob.post( + "/v1/skills/publish", + headers=_auth("bob-token"), + json=payload | {"target_id": "unknown-target"}, + ) + assert missing.status_code == 404 + assert missing.json()["error"]["code"] == "skill_publication_target_not_found" + assert str(target_path) not in missing.text + + published = await bob.post( + "/v1/skills/publish", + headers=_auth("bob-token"), + json=payload | {"target_id": "codex-project"}, + ) + assert published.status_code == 200, published.json() + assert published.json() == { + "artifact": {"family": "skill", "artifact_id": "skill-a", "revision": 7}, + "target_id": "codex-project", + "agent_kind": "codex", + "installation_scope": "project", + "state": "published", + "applied_revision": 7, + } + assert str(target_path) not in published.text + assert target_path.joinpath("safe-publication", "SKILL.md").is_file() + + alice_app = create_app( + application=application, + access_control=service, + middleware=(Middleware(StaticBearerMiddleware, token="alice-token", principal=ALICE),), # noqa: S106 + agent_skill_targets=(target,), + ) + calls_before = runtime_skill.get_calls + async with _client(alice_app) as alice: + denied = await alice.post( + "/v1/skills/publication-targets/list", + headers=_auth("alice-token"), + json=payload, + ) + assert denied.status_code == 403 + assert runtime_skill.get_calls == calls_before + + asyncio.run(scenario()) + + +def test_dashboard_scope_discovery_uses_the_same_principal_and_filters_before_response() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + service = AccessControlService( + BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + relationships=repository, + audit=repository, + ) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-visible"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="bob-dashboard-scope", + ), + context=AUDIT, + ) + app = _app(service, principal=BOB, token="bob-token") # noqa: S106 - test credential. + mount_web_ui( + app, + scopes={"scope-visible": "Visible", "scope-hidden": "Hidden"}, + dashboard_enabled=True, + authentication_required=True, + ) + async with _client(app) as client: + response = await client.get("/dashboard/scopes", headers=_auth("bob-token")) + assert response.status_code == 200 + assert response.json() == [{"scope_id": "scope-visible", "display_name": "Visible"}] + assert "scope-hidden" not in response.text + + asyncio.run(scenario()) + + +def _app(service: AccessControlService, *, principal: PrincipalRef, token: str, application=None): return create_app( + application=application, access_control=service, middleware=(Middleware(StaticBearerMiddleware, token=token, principal=principal),), ) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index b9410b02f..b54b83989 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -89,10 +89,12 @@ LIST_EXTERNAL_SKILLS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SKILL_PUBLICATION_TARGETS, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, + PUBLISH_MANAGED_SKILL, RECORD_TASK_OUTCOME, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, @@ -216,11 +218,37 @@ def test_memory_search_declares_the_revision_conflict_response() -> None: def test_handoff_access_metadata_preserves_exact_revision_authorization() -> None: assert CONTINUE_HANDOFF.access is not None - assert CONTINUE_HANDOFF.access.action == "scope.read" - assert CONTINUE_HANDOFF.access.resolver == "continue_handoff" + assert CONTINUE_HANDOFF.access.action is None + assert CONTINUE_HANDOFF.access.resolver == "continue_handoff_access" assert ACKNOWLEDGE_HANDOFF.access is not None - assert ACKNOWLEDGE_HANDOFF.access.action == "scope.contribute" - assert ACKNOWLEDGE_HANDOFF.access.resolver == "acknowledge_handoff" + assert ACKNOWLEDGE_HANDOFF.access.action is None + assert ACKNOWLEDGE_HANDOFF.access.resolver == "acknowledge_handoff_access" + + +def test_access_contract_uses_stable_resource_kinds_family_profiles_and_skill_publication() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + schemas = contract["components"]["schemas"] + + assert schemas["AccessResourceType"]["enum"] == ["server", "scope", "artifact"] + assert "access.self" not in schemas["AccessAction"]["enum"] + artifact = schemas["ArtifactAccessResource"] + assert artifact["required"] == ["type", "scope_id", "reference", "selector"] + assert set(artifact["properties"]) == {"type", "scope_id", "reference", "selector"} + selector = schemas["MemoryEntryAccessSelector"] + assert selector["required"] == ["type", "entry_id", "entry_version_id"] + + assert GET_MEMORY_ENTRY.access is not None + assert GET_MEMORY_ENTRY.access.resolver == "exact_memory_access" + assert GET_EXPERIENCE.access is not None + assert GET_EXPERIENCE.access.resolver == "exact_experience_access" + assert GET_SKILL.access is not None + assert GET_SKILL.access.resolver == "exact_skill_access" + assert LIST_SKILL_PUBLICATION_TARGETS.path == "/v1/skills/publication-targets/list" + assert PUBLISH_MANAGED_SKILL.path == "/v1/skills/publish" + assert LIST_SKILL_PUBLICATION_TARGETS.access is not None + assert LIST_SKILL_PUBLICATION_TARGETS.access.resolver == "publish_managed_skill_access" + assert PUBLISH_MANAGED_SKILL.access is not None + assert PUBLISH_MANAGED_SKILL.access.resolver == "publish_managed_skill_access" def test_prepared_context_is_a_generic_typed_operation_outside_the_mcp_memory_tools() -> None: diff --git a/tests/test_client.py b/tests/test_client.py index 7b880f978..7bbbdd48e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,13 +19,22 @@ import pytest from pydantic import ValidationError -from powercontext.client import InvalidResponseError, PowerContextClient, ServerResponseError, TransportError +from powercontext.client import ( + ForbiddenResponseError, + InvalidResponseError, + PowerContextClient, + ServerResponseError, + TransportError, + UnauthorizedResponseError, + UnavailableResponseError, +) from powercontext.client.settings import ClientSettings from powercontext.http import ( AccessAction, AccessCheckRequest, AccessResource, - AccessResourceType, + ArtifactAccessResource, + ArtifactReference, CaptureContentSourceRequest, GetHandoffReportRequest, ) @@ -46,20 +55,21 @@ def respond(request: httpx.Request) -> httpx.Response: client = PowerContextClient("https://memory.example", http_client=http_client) decision = await client.check_access( AccessCheckRequest( - action=AccessAction.HANDOFF_READ, + action=AccessAction.ARTIFACT_READ, resource=AccessResource( - type=AccessResourceType.HANDOFF, - scope_id="scope-a", - family="handoff", - artifact_id="handoff-a", - revision=3, + root=ArtifactAccessResource( + type="artifact", + scope_id="scope-a", + reference=ArtifactReference(family="handoff", artifact_id="handoff-a", revision=3), + selector=None, + ) ), ) ) assert decision.allowed is True assert requests[0].url.path == "/v1/access/check" - assert json.loads(requests[0].content)["resource"]["artifact_id"] == "handoff-a" + assert json.loads(requests[0].content)["resource"]["reference"]["artifact_id"] == "handoff-a" asyncio.run(scenario()) @@ -115,6 +125,32 @@ async def scenario() -> None: asyncio.run(scenario()) +@pytest.mark.parametrize( + ("status_code", "error_type"), + [ + (401, UnauthorizedResponseError), + (403, ForbiddenResponseError), + (503, UnavailableResponseError), + ], +) +def test_client_maps_access_statuses_to_distinct_stable_exceptions( + status_code: int, + error_type: type[ServerResponseError], +) -> None: + async def scenario() -> None: + response = httpx.Response( + status_code, + json={"error": {"code": "access_failure", "message": "Access failed.", "details": None}}, + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda request: response)) as http_client: + client = PowerContextClient("https://memory.example", http_client=http_client) + with pytest.raises(error_type) as caught: + await client.get_readiness() + assert caught.value.status_code == status_code + + asyncio.run(scenario()) + + def test_client_sends_an_explicit_bearer_token() -> None: async def scenario() -> None: requests: list[httpx.Request] = [] diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 41c3efd1d..5252e7383 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -358,6 +358,9 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target assert wrong_revision.json()["error"]["code"] == "skill_projection_not_approved" assert before.status_code == 200 assert before.json()["targets"][0]["state"] == "unpublished" + assert "destination" not in before.json()["targets"][0] + assert str(codex_skill_root) not in before.text + assert before.json()["targets"][0]["capabilities"] == ["publish"] assert [target["agent_kind"] for target in before.json()["targets"]] == ["codex", "claude_code"] assert published.status_code == 200 assert published.json()["targets"][0]["state"] == "current" diff --git a/tests/test_server.py b/tests/test_server.py index cd59422a7..1f7a32225 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -48,6 +48,22 @@ from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings from powercontext.sources import Source +_ACCESS_FAMILIES = "experience:enabled,handoff:enabled,memory:enabled,prompt:disabled,skill:enabled" + + +def _access_readiness_checks( + *, + mode: str = "legacy-static-admin", + provider: str = "disabled", +) -> dict[str, str]: + return { + "access_mode": mode, + "access_provider": provider, + "access_resource_kinds": "server,scope,artifact", + "access_artifact_families": _ACCESS_FAMILIES, + "access_skill_publication": "disabled", + } + class _NoopExperiencePipeline: async def incubate(self, _sources: tuple[Source, ...], /) -> tuple[ExperienceCandidateInput, ...]: @@ -345,11 +361,19 @@ def test_server_factory_maps_static_token_to_bootstrap_principal() -> None: response = client.get("/v1/access/me", headers={"Authorization": "Bearer server-secret"}) assert response.status_code == 200 - assert response.json() == { + payload = response.json() + assert payload["principal"] == { "type": "service", - "issuer": "powercontext:static", + "issuer": "powercontext:powercontext:static", "id": "server-token", } + assert payload["mode"] == "legacy-static-admin" + assert payload["resource_kinds"] == ["server", "scope", "artifact"] + assert payload["provider_capabilities"] == { + "safe_resource_filtering": True, + "multi_requirement_check": True, + "relationship_management": True, + } def test_readiness_reports_unavailable_bindings() -> None: @@ -364,7 +388,7 @@ async def probe() -> ReadinessResponse: assert response.status_code == 503 assert response.json() == { "status": "not_ready", - "checks": {"database": "unavailable"}, + "checks": {"database": "unavailable", **_access_readiness_checks(mode="disabled")}, } assert response.headers["X-PowerContext-Request-ID"] @@ -381,7 +405,7 @@ async def probe() -> ReadinessResponse: assert response.status_code == 200 assert response.json() == { "status": "degraded", - "checks": {"inference.embedding": "unavailable"}, + "checks": {"inference.embedding": "unavailable", **_access_readiness_checks(mode="disabled")}, } @@ -407,6 +431,7 @@ async def fail_ping(_database: AsyncDatabase) -> None: "checks": { "runtime": "ready", "database": "unavailable", + **_access_readiness_checks(), }, } assert "powercontext_server_runtime_ready 0.0" in metrics.text @@ -432,6 +457,7 @@ def test_server_factory_reports_database_and_configured_generation_readiness(tmp "runtime": "ready", "database": "ready", "inference.generation": "ready", + **_access_readiness_checks(), }, } @@ -492,6 +518,7 @@ async def rate_limited(_messages: list[ModelMessage], _info: AgentInfo) -> Model "runtime": "ready", "database": "ready", "inference.generation": "unavailable", + **_access_readiness_checks(), }, } assert "provider response" not in response.text @@ -522,6 +549,7 @@ def test_server_factory_caches_and_redacts_degraded_embedding_readiness(caplog, "runtime": "ready", "database": "ready", "inference.embedding": "misconfigured", + **_access_readiness_checks(), }, } ) @@ -550,6 +578,7 @@ def test_server_factory_reports_a_rejected_embedding_request_with_a_redacted_rea "runtime": "ready", "database": "ready", "inference.embedding": "misconfigured: provider-rejected (HTTP 400)", + **_access_readiness_checks(), }, } @@ -584,6 +613,7 @@ def test_server_factory_reports_transient_embedding_failures_as_degraded( "runtime": "ready", "database": "ready", "inference.embedding": expected_status, + **_access_readiness_checks(), }, } assert "secret" not in response.text @@ -688,6 +718,7 @@ def reject(request: httpx.Request) -> httpx.Response: "runtime": "ready", "database": "ready", "inference.embedding": "misconfigured: provider-rejected (HTTP 404)", + **_access_readiness_checks(), }, } ) diff --git a/uv.lock b/uv.lock index 01d4f1495..b4fce6573 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11, <4.0" resolution-markers = [ "python_full_version >= '3.14'", @@ -188,6 +188,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] +[[package]] +name = "bracex" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, +] + [[package]] name = "cachetools" version = "7.1.4" @@ -2123,11 +2132,13 @@ server = [ { name = "apscheduler" }, { name = "fastapi" }, { name = "fastmcp" }, + { name = "httpx" }, { name = "jinja2" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, { name = "platformdirs" }, { name = "prometheus-client" }, + { name = "pycasbin" }, { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, { name = "pydantic-settings" }, { name = "pyobvector" }, @@ -2171,6 +2182,7 @@ requires-dist = [ { name = "apscheduler", marker = "extra == 'server'", specifier = ">=3.11,<4" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115,<1" }, { name = "fastmcp", marker = "extra == 'server'", specifier = ">=3.4,<4" }, + { name = "httpx", marker = "extra == 'server'", specifier = ">=0.28,<1" }, { name = "httpx", extras = ["socks"], marker = "extra == 'cli'", specifier = ">=0.28,<1" }, { name = "httpx", extras = ["socks"], marker = "extra == 'client'", specifier = ">=0.28,<1" }, { name = "inquirerpy", marker = "extra == 'cli'", specifier = ">=0.3,<1" }, @@ -2183,6 +2195,7 @@ requires-dist = [ { name = "platformdirs", marker = "extra == 'cli'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'server'", specifier = ">=4,<5" }, { name = "prometheus-client", marker = "extra == 'server'", specifier = ">=0.21,<1" }, + { name = "pycasbin", marker = "extra == 'server'", specifier = ">=2.8,<3" }, { name = "pydantic", specifier = ">=2.10,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'seekdb'", specifier = ">=2.27.1,<3" }, @@ -2354,6 +2367,19 @@ memory = [ { name = "cachetools" }, ] +[[package]] +name = "pycasbin" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simpleeval" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/01/bc46b48e0e4576422faea00d8cdeaf7d23e6c65891890175c831ce7c5c6d/pycasbin-2.8.0.tar.gz", hash = "sha256:2615c8940d58caf03c9206246d9499209fd520448c682d2f2ca101c41d9b0aee", size = 426693, upload-time = "2026-02-02T03:34:14.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/0f15da0fb5864a37637820e4bde463a52ba0c052a8edab06aad46b9e578b/pycasbin-2.8.0-py3-none-any.whl", hash = "sha256:1a9e370de553c677c4dff75a5d6f3b0eb354b73b20d7df77ff4ee61a71267a3a", size = 476153, upload-time = "2026-02-02T03:34:12.555Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -3206,6 +3232,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "simpleeval" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/9d/e7c9309940794dd3073cba2e5101df5874d84243595ce63b1e1c8f9b9c76/simpleeval-1.0.7.tar.gz", hash = "sha256:1e10e5f9fec597814444e20c0892ed15162fa214c8a88f434b5b077cf2fef85b", size = 30250, upload-time = "2026-03-16T10:53:03.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2f/f32aa85591882378bb43caa09363f3ed97df399369a5144c7f19f2275bc0/simpleeval-1.0.7-py3-none-any.whl", hash = "sha256:97ac271bfd8f2af9e7b9a36ceea67617f26fa873f9d5ae1922f64d4c1442534b", size = 18792, upload-time = "2026-03-16T10:53:02.103Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -3911,6 +3946,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] +[[package]] +name = "wcmatch" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/43/30e407989e313677dbb9d5f045f966549a7254834571e342eaa4b55cc67b/wcmatch-11.0.1.tar.gz", hash = "sha256:1ea2b4fa678b8ca268253798d5963935df39132d47c3e241c0a0732224005e7d", size = 144662, upload-time = "2026-08-14T15:20:40.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/77/7a02b0f05b3ffcdbef9719ce3ee0b508d6a29b58e95299f1580055671db3/wcmatch-11.0.1-py3-none-any.whl", hash = "sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b", size = 43449, upload-time = "2026-08-14T15:20:39.379Z" }, +] + [[package]] name = "wcwidth" version = "0.8.2" From 86728d42ab5961764c7fb1a0500d0b73897aac9e Mon Sep 17 00:00:00 2001 From: Teingi Date: Tue, 1 Sep 2026 22:06:51 +0800 Subject: [PATCH 04/22] fix(e2e): sync Bub harness lock metadata --- e2e/bub/uv.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock index 435a3197c..b80a2f806 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -1623,6 +1623,7 @@ requires-dist = [ { name = "apscheduler", marker = "extra == 'server'", specifier = ">=3.11,<4" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115,<1" }, { name = "fastmcp", marker = "extra == 'server'", specifier = ">=3.4,<4" }, + { name = "httpx", marker = "extra == 'server'", specifier = ">=0.28,<1" }, { name = "httpx", extras = ["socks"], marker = "extra == 'cli'", specifier = ">=0.28,<1" }, { name = "httpx", extras = ["socks"], marker = "extra == 'client'", specifier = ">=0.28,<1" }, { name = "inquirerpy", marker = "extra == 'cli'", specifier = ">=0.3,<1" }, @@ -1635,6 +1636,7 @@ requires-dist = [ { name = "platformdirs", marker = "extra == 'cli'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'server'", specifier = ">=4,<5" }, { name = "prometheus-client", marker = "extra == 'server'", specifier = ">=0.21,<1" }, + { name = "pycasbin", marker = "extra == 'server'", specifier = ">=2.8,<3" }, { name = "pydantic", specifier = ">=2.10,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'seekdb'", specifier = ">=2.27.1,<3" }, From 31f6734fb180c3e7168e3bc20f8ab395c9e327b2 Mon Sep 17 00:00:00 2001 From: Teingi Date: Tue, 1 Sep 2026 22:38:21 +0800 Subject: [PATCH 05/22] refactor(access): remove Handoff-only compatibility --- src/powercontext/server/authz/composition.py | 10 +-- src/powercontext/server/authz/models.py | 17 ---- src/powercontext/server/authz/repository.py | 85 ++------------------ tests/test_access_adapters.py | 8 +- tests/test_access_control.py | 64 +++------------ 5 files changed, 25 insertions(+), 159 deletions(-) diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py index 8dbcaee5b..dc142fb87 100644 --- a/src/powercontext/server/authz/composition.py +++ b/src/powercontext/server/authz/composition.py @@ -27,7 +27,7 @@ from powercontext.builtin.runtime.config import DatabaseConfig from powercontext.server.authz.casbin import CasbinAuthorizationProvider from powercontext.server.authz.models import DEFAULT_DEPLOYMENT_ID, PrincipalRef -from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository, ensure_access_schema +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository from powercontext.server.authz.service import AccessControlService, BuiltinAuthorizationProvider @@ -41,7 +41,7 @@ async def open_builtin_access_control( ) -> AsyncIterator[AccessControlService]: """Open a Server-owned Access schema without coupling it to Runtime domains.""" - async with _open_access_repository(database, deployment_id=deployment_id) as repository: + async with _open_access_repository(database) as repository: provider = BuiltinAuthorizationProvider( repository, bootstrap_administrators=bootstrap_administrators, @@ -66,7 +66,7 @@ async def open_casbin_access_control( ) -> AsyncIterator[AccessControlService]: """Open the writable embedded Casbin adapter over the canonical Access schema.""" - async with _open_access_repository(database, deployment_id=deployment_id) as repository: + async with _open_access_repository(database) as repository: provider = CasbinAuthorizationProvider( repository, bootstrap_administrators=bootstrap_administrators, @@ -84,8 +84,6 @@ async def open_casbin_access_control( @asynccontextmanager async def _open_access_repository( database: DatabaseConfig, - *, - deployment_id: str, ) -> AsyncIterator[RelationalAccessRepository]: if isinstance(database, SQLiteConfig): profile_context = SQLiteProfile.open(database, tables=ACCESS_TABLES) @@ -96,8 +94,6 @@ async def _open_access_repository( else: raise BuiltinConfigurationError("database") async with profile_context as profile: - async with profile.database.transaction() as connection: - await ensure_access_schema(connection, deployment_id=deployment_id) yield RelationalAccessRepository(profile.database) diff --git a/src/powercontext/server/authz/models.py b/src/powercontext/server/authz/models.py index 03d920c42..1ce822289 100644 --- a/src/powercontext/server/authz/models.py +++ b/src/powercontext/server/authz/models.py @@ -191,23 +191,6 @@ def artifact( selector=selector, ) - @classmethod - def handoff( - cls, - scope_id: str, - *, - artifact_id: str, - revision: int, - ) -> ResourceRef: - """Build an exact Handoff Artifact resource.""" - - return cls.artifact( - scope_id, - family="handoff", - artifact_id=artifact_id, - revision=revision, - ) - @property def family(self) -> str | None: return None if self.reference is None else self.reference.family diff --git a/src/powercontext/server/authz/repository.py b/src/powercontext/server/authz/repository.py index b466cd360..9abddde78 100644 --- a/src/powercontext/server/authz/repository.py +++ b/src/powercontext/server/authz/repository.py @@ -33,18 +33,15 @@ UniqueConstraint, insert, select, - text, update, ) from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.tables import identity_string from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH from powercontext.server.authz.errors import AccessConflictError, AccessInvalidRequestError from powercontext.server.authz.models import ( - DEFAULT_DEPLOYMENT_ID, AccessAction, AccessAuditEvent, AccessBinding, @@ -141,76 +138,6 @@ ACCESS_TABLES = (ACCESS_POLICY_HEADS_TABLE, ACCESS_BINDINGS_TABLE, ACCESS_AUDIT_EVENTS_TABLE) _POLICY_HEAD = "authorization" -_ACCESS_RESOURCE_COLUMNS = { - "deployment_id": 128, - "selector_type": 32, - "selector_entry_id": MAX_ARTIFACT_ID_LENGTH, - "selector_entry_version_id": MAX_ARTIFACT_ID_LENGTH, -} - - -async def ensure_access_schema( - connection: AsyncConnection, - /, - *, - deployment_id: str = DEFAULT_DEPLOYMENT_ID, -) -> None: - """Upgrade the first Handoff-only Access tables to the Artifact resource contract.""" - - dialect = connection.dialect.name - if dialect not in {"sqlite", "mysql"}: - raise ValueError(f"unsupported Access schema migration dialect: {dialect}") # noqa: TRY003 - rehash_binding_idempotency = False - for table_name in (ACCESS_BINDINGS_TABLE.name, ACCESS_AUDIT_EVENTS_TABLE.name): - for column_name, maximum in _ACCESS_RESOURCE_COLUMNS.items(): - if await _column_exists(connection, table_name, column_name): - continue - if table_name == ACCESS_BINDINGS_TABLE.name: - rehash_binding_idempotency = True - column_type = "TEXT" if dialect == "sqlite" else f"VARCHAR({maximum})" - await connection.exec_driver_sql(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type} NULL") - converted = await connection.execute( - text( - f"UPDATE {table_name} SET resource_type = 'artifact' " # noqa: S608 - "WHERE resource_type = 'handoff'" - ) - ) - if table_name == ACCESS_BINDINGS_TABLE.name and converted.rowcount > 0: - rehash_binding_idempotency = True - await connection.execute( - text( - f"UPDATE {table_name} SET deployment_id = :deployment_id " # noqa: S608 - "WHERE resource_type = 'server' AND deployment_id IS NULL" - ), - {"deployment_id": deployment_id}, - ) - await connection.execute( - text("UPDATE pc_access_audit_events SET action = 'artifact.read' WHERE action = 'handoff.read'") - ) - if rehash_binding_idempotency: - rows = (await connection.execute(select(ACCESS_BINDINGS_TABLE))).mappings().all() - for row in rows: - await connection.execute( - update(ACCESS_BINDINGS_TABLE) - .where(ACCESS_BINDINGS_TABLE.c.binding_id == row["binding_id"]) - .values( - idempotency_key_hash=_idempotency_digest( - _decode_resource(row), - str(row["idempotency_key"]), - ) - ) - ) - - -async def _column_exists(connection: AsyncConnection, table_name: str, column_name: str) -> bool: - if connection.dialect.name == "sqlite": - statement = text(f"SELECT COUNT(*) FROM pragma_table_info('{table_name}') WHERE name = :column_name") # noqa: S608 - return bool(await connection.scalar(statement, {"column_name": column_name})) - statement = text( - "SELECT COUNT(*) FROM information_schema.columns " - "WHERE table_schema = DATABASE() AND table_name = :table_name AND column_name = :column_name" - ) - return bool(await connection.scalar(statement, {"table_name": table_name, "column_name": column_name})) class RelationalAccessRepository: @@ -548,7 +475,7 @@ def _decode_audit(row: Mapping[Any, Any]) -> AccessAuditEvent: transport=str(row["transport"]), operation=str(row["operation"]), principal=_principal(row, "principal"), - action=AccessAction.ARTIFACT_READ if str(row["action"]) == "handoff.read" else AccessAction(str(row["action"])), + action=AccessAction(str(row["action"])), resource=_decode_resource(row), allowed=bool(row["allowed"]), reason_code=str(row["reason_code"]), @@ -560,11 +487,12 @@ def _decode_audit(row: Mapping[Any, Any]) -> AccessAuditEvent: def _decode_resource(row: Mapping[Any, Any]) -> ResourceRef: - stored_type = str(row["resource_type"]) - resource_type = AccessResourceType.ARTIFACT if stored_type == "handoff" else AccessResourceType(stored_type) + resource_type = AccessResourceType(str(row["resource_type"])) if resource_type is AccessResourceType.SERVER: - deployment_id = row.get("deployment_id") - return ResourceRef.server() if deployment_id is None else ResourceRef.server(str(deployment_id)) + deployment_id = row["deployment_id"] + if deployment_id is None: + raise AccessInvalidRequestError("resource") + return ResourceRef.server(str(deployment_id)) if resource_type is AccessResourceType.SCOPE: return ResourceRef.scope(str(row["scope_id"])) selector_type = row.get("selector_type") @@ -628,5 +556,4 @@ def _idempotency_digest(resource: ResourceRef, idempotency_key: str) -> str: __all__ = ( "ACCESS_TABLES", "RelationalAccessRepository", - "ensure_access_schema", ) diff --git a/tests/test_access_adapters.py b/tests/test_access_adapters.py index 759644dc1..c4f8b7c2c 100644 --- a/tests/test_access_adapters.py +++ b/tests/test_access_adapters.py @@ -72,8 +72,8 @@ async def scenario() -> None: audit=repository, clock=lambda: NOW, ) - exact = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=3) - sibling = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=4) + exact = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=3) + sibling = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=4) binding = await casbin_service.create_binding( ADMIN, CreateBinding( @@ -183,8 +183,8 @@ async def scenario() -> None: def test_authzen_adapter_matches_the_exact_resource_conformance_vector() -> None: - exact = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=3) - sibling = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=4) + exact = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=3) + sibling = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=4) vectors = _handoff_conformance_vectors(exact, sibling) expected = {(action.value, resource.key): allowed for action, resource, allowed in vectors} diff --git a/tests/test_access_control.py b/tests/test_access_control.py index 1749b3e9e..3cc5b4839 100644 --- a/tests/test_access_control.py +++ b/tests/test_access_control.py @@ -18,7 +18,6 @@ from datetime import UTC, datetime, timedelta import pytest -from sqlalchemy import text from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.server.authz import ( @@ -39,7 +38,7 @@ PrincipalRef, ResourceRef, ) -from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository, ensure_access_schema +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository NOW = datetime(2026, 8, 30, 10, tzinfo=UTC) ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") @@ -52,7 +51,7 @@ def test_exact_handoff_receiver_cannot_discover_other_handoffs_or_scope_data() - async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: service, repository = _service(profile.database) - exact = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=3) + exact = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=3) created = await service.create_binding( ADMIN, CreateBinding( @@ -75,7 +74,7 @@ async def scenario() -> None: await service.require( BOB, AccessAction.ARTIFACT_READ, - ResourceRef.handoff("scope-a", artifact_id="handoff-b", revision=1), + ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-b", revision=1), context=AUDIT, ) with pytest.raises(AccessDeniedError): @@ -110,7 +109,7 @@ async def scenario() -> None: ), context=AUDIT, ) - handoff = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=1) + handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=1) assert (await service.require(ALICE, AccessAction.ARTIFACT_READ, handoff, context=AUDIT)).allowed assert not (await service.check(ALICE, AccessAction.HANDOFF_ACKNOWLEDGE, handoff, context=AUDIT)).allowed @@ -251,7 +250,7 @@ async def scenario() -> None: ), context=AUDIT, ) - handoff = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=1) + handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=1) delegated = await service.create_binding( ALICE, CreateBinding( @@ -369,7 +368,13 @@ async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: service, repository = _service(profile.database) resources = tuple( - ResourceRef.handoff("scope-a", artifact_id=f"handoff-{index}", revision=1) for index in range(3) + ResourceRef.artifact( + "scope-a", + family="handoff", + artifact_id=f"handoff-{index}", + revision=1, + ) + for index in range(3) ) for index, resource in enumerate(resources): await service.create_binding( @@ -466,51 +471,6 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_handoff_only_schema_is_migrated_without_losing_bindings_or_audit() -> None: - async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, repository = _service(profile.database) - handoff = ResourceRef.handoff("scope-a", artifact_id="handoff-a", revision=1) - await service.create_binding( - ADMIN, - CreateBinding( - subject=BOB, - resource=handoff, - role=AccessRole.HANDOFF_VIEWER, - idempotency_key="legacy-handoff-viewer", - ), - context=AUDIT, - ) - await service.require(BOB, AccessAction.ARTIFACT_READ, handoff, context=AUDIT) - async with profile.database.transaction() as connection: - for table_name in ("pc_access_bindings", "pc_access_audit_events"): - for column_name in ( - "deployment_id", - "selector_type", - "selector_entry_id", - "selector_entry_version_id", - ): - await connection.exec_driver_sql(f"ALTER TABLE {table_name} DROP COLUMN {column_name}") - await connection.execute( - text( - f"UPDATE {table_name} SET resource_type = 'handoff' " # noqa: S608 - "WHERE resource_type = 'artifact'" - ) - ) - await connection.execute( - text("UPDATE pc_access_audit_events SET action = 'handoff.read' WHERE action = 'artifact.read'") - ) - await ensure_access_schema(connection) - - bindings = await repository.list_bindings(subject=BOB) - assert len(bindings) == 1 - assert bindings[0].resource == handoff - audit = await repository.list_audit() - assert any(event.action is AccessAction.ARTIFACT_READ and event.resource == handoff for event in audit) - - asyncio.run(scenario()) - - def _service(database) -> tuple[AccessControlService, RelationalAccessRepository]: repository = RelationalAccessRepository(database) provider = BuiltinAuthorizationProvider( From 146f355d668e2fb2a1fa676acd0399d8269e5738 Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 2 Sep 2026 14:33:54 +0800 Subject: [PATCH 06/22] fix(access): fail closed when provider is unavailable --- .../powercontext/openapi/powercontext.yaml | 32 ++++++++ openapi/powercontext.yaml | 32 ++++++++ .../http/_generated/operations.py | 16 ++++ src/powercontext/http/_generated/schema.py | 16 ++++ src/powercontext/limits.py | 1 + src/powercontext/server/app.py | 11 ++- src/powercontext/server/authz/__init__.py | 2 + src/powercontext/server/authz/authzen.py | 3 +- src/powercontext/server/authz/composition.py | 8 +- src/powercontext/server/authz/repository.py | 51 +++++++++++- src/powercontext/server/authz/service.py | 18 ++++- src/powercontext/server/factory.py | 14 +++- src/powercontext/server/web.py | 17 +++- tests/test_access_adapters.py | 25 ++++++ tests/test_access_control.py | 77 ++++++++++++++++++- tests/test_access_http.py | 14 ++++ tests/test_api_contract.py | 12 +++ tests/test_server.py | 48 +++++++++++- 18 files changed, 382 insertions(+), 15 deletions(-) diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index b549d8ffd..7f60543da 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -82,6 +82,8 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/Unavailable" /v1/sources/content: post: tags: [sources] @@ -1431,6 +1433,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/projects/list: @@ -1461,6 +1465,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/scopes/list-known: @@ -1491,6 +1497,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/projects/get: @@ -1523,6 +1531,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/projects/update: @@ -1557,6 +1567,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workstreams/register: @@ -1591,6 +1603,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workstreams/list: @@ -1623,6 +1637,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workstreams/update: @@ -1657,6 +1673,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/get: @@ -1749,6 +1767,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/activities/list: @@ -1781,6 +1801,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/activities/purge: @@ -1813,6 +1835,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workspace-bindings/get: @@ -1845,6 +1869,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workspace-bindings/attach: @@ -1879,6 +1905,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workspace-bindings/detach: @@ -1913,6 +1941,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/access/me: @@ -2040,6 +2070,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" /v1/access/bindings/list: post: tags: [access] diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index b549d8ffd..7f60543da 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -82,6 +82,8 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/Unavailable" /v1/sources/content: post: tags: [sources] @@ -1431,6 +1433,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/projects/list: @@ -1461,6 +1465,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/scopes/list-known: @@ -1491,6 +1497,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/projects/get: @@ -1523,6 +1531,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/projects/update: @@ -1557,6 +1567,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workstreams/register: @@ -1591,6 +1603,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workstreams/list: @@ -1623,6 +1637,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workstreams/update: @@ -1657,6 +1673,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/get: @@ -1749,6 +1767,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/activities/list: @@ -1781,6 +1801,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/activities/purge: @@ -1813,6 +1835,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workspace-bindings/get: @@ -1845,6 +1869,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workspace-bindings/attach: @@ -1879,6 +1905,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/handoff-reports/workspace-bindings/detach: @@ -1913,6 +1941,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" /v1/access/me: @@ -2040,6 +2070,8 @@ paths: $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" /v1/access/bindings/list: post: tags: [access] diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index b340e58d1..ec552abd7 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -206,6 +206,7 @@ class AccessRequirement(BaseModel): }, 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, + 503: {"$ref": "#/components/responses/Unavailable"}, }, access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) @@ -1201,6 +1202,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), @@ -1224,6 +1226,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), @@ -1247,6 +1250,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), @@ -1271,6 +1275,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), @@ -1296,6 +1301,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), @@ -1321,6 +1327,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="scope.admin", resource="scope", scope_id_field="scope_id", resolver="request"), @@ -1345,6 +1352,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), @@ -1370,6 +1378,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement( @@ -1441,6 +1450,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), @@ -1465,6 +1475,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), @@ -1489,6 +1500,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), @@ -1513,6 +1525,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), @@ -1538,6 +1551,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), @@ -1563,6 +1577,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), @@ -1662,6 +1677,7 @@ class AccessRequirement(BaseModel): 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, }, access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), ) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 1d762d303..c90e692c4 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -58,6 +58,7 @@ }, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, + "503": {"$ref": "#/components/responses/Unavailable"}, }, "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } @@ -1314,6 +1315,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, @@ -1341,6 +1343,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, @@ -1370,6 +1373,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, @@ -1396,6 +1400,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, @@ -1425,6 +1430,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, @@ -1456,6 +1462,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": { @@ -1487,6 +1494,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, @@ -1518,6 +1526,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": { @@ -1604,6 +1613,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, @@ -1634,6 +1644,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, @@ -1666,6 +1677,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, @@ -1698,6 +1710,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, @@ -1731,6 +1744,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, @@ -1764,6 +1778,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, @@ -1879,6 +1894,7 @@ "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"$ref": "#/components/responses/Forbidden"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, }, "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } diff --git a/src/powercontext/limits.py b/src/powercontext/limits.py index f71ed0fe2..60becc7cc 100644 --- a/src/powercontext/limits.py +++ b/src/powercontext/limits.py @@ -24,3 +24,4 @@ MAX_EXTERNAL_SKILL_DESCRIPTION_LENGTH = 2_000 MAX_EXTERNAL_SKILL_HOST_ID_LENGTH = 128 MAX_EXTERNAL_SKILL_LOCATOR_LENGTH = 2_000 +MAX_POLICY_REVISION_LENGTH = 64 diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 23f1af8fd..9ef067948 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -502,6 +502,7 @@ MemoryEntrySelector, PrincipalRef, ResourceRef, + access_control_for_mode, ) from powercontext.server.authz.models import ROLE_ACTIONS, ROLE_RESOURCE_TYPES from powercontext.server.authz.profiles import ARTIFACT_FAMILY_PROFILES, artifact_family_profile @@ -1829,7 +1830,10 @@ def _require_handoff_report_application(request: Request) -> HandoffReportApplic def _require_access_control(request: Request) -> AccessControlService: - access: AccessControlService | None = request.app.state.access_control + access = access_control_for_mode( + request.app.state.access_control, + mode=request.app.state.access_mode, + ) if access is None: raise _RuntimeNotReadyError return access @@ -2020,7 +2024,10 @@ def _authorization_dependency( raise AccessInvalidRequestError("resource") async def authorize(request: Request) -> None: - access: AccessControlService | None = request.app.state.access_control + access = access_control_for_mode( + request.app.state.access_control, + mode=request.app.state.access_mode, + ) if access is not None: payload = await _authorization_payload(request, operation) checks = _resolve_access_requirements(requirement, payload, deployment_id=access.deployment_id) diff --git a/src/powercontext/server/authz/__init__.py b/src/powercontext/server/authz/__init__.py index 810467f07..b49ba1e65 100644 --- a/src/powercontext/server/authz/__init__.py +++ b/src/powercontext/server/authz/__init__.py @@ -52,6 +52,7 @@ CreateBinding, RelationshipWriter, ResourceSearchRequest, + access_control_for_mode, ) __all__ = ( @@ -88,4 +89,5 @@ "RelationshipWriter", "ResourceRef", "ResourceSearchRequest", + "access_control_for_mode", ) diff --git a/src/powercontext/server/authz/authzen.py b/src/powercontext/server/authz/authzen.py index 90e3643a6..13e04647e 100644 --- a/src/powercontext/server/authz/authzen.py +++ b/src/powercontext/server/authz/authzen.py @@ -22,6 +22,7 @@ import httpx from pydantic import SecretStr +from powercontext.limits import MAX_POLICY_REVISION_LENGTH from powercontext.server.authz.errors import AccessUnavailableError from powercontext.server.authz.models import AccessDecision, MemoryEntrySelector, ResourceRef from powercontext.server.authz.service import ( @@ -180,7 +181,7 @@ def _decision(value: object) -> AccessDecision: def _valid_policy_revision(value: object) -> TypeGuard[str]: return ( isinstance(value, str) - and 0 < len(value) <= 128 + and 0 < len(value) <= MAX_POLICY_REVISION_LENGTH and value[0].isalnum() and all(character.isascii() and (character.isalnum() or character in "._-") for character in value) ) diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py index dc142fb87..b01b538c6 100644 --- a/src/powercontext/server/authz/composition.py +++ b/src/powercontext/server/authz/composition.py @@ -27,7 +27,11 @@ from powercontext.builtin.runtime.config import DatabaseConfig from powercontext.server.authz.casbin import CasbinAuthorizationProvider from powercontext.server.authz.models import DEFAULT_DEPLOYMENT_ID, PrincipalRef -from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.authz.repository import ( + ACCESS_TABLES, + RelationalAccessRepository, + ensure_access_policy_revision_columns, +) from powercontext.server.authz.service import AccessControlService, BuiltinAuthorizationProvider @@ -94,6 +98,8 @@ async def _open_access_repository( else: raise BuiltinConfigurationError("database") async with profile_context as profile: + async with profile.database.transaction() as connection: + await ensure_access_policy_revision_columns(connection) yield RelationalAccessRepository(profile.database) diff --git a/src/powercontext/server/authz/repository.py b/src/powercontext/server/authz/repository.py index 9abddde78..1c45afb4e 100644 --- a/src/powercontext/server/authz/repository.py +++ b/src/powercontext/server/authz/repository.py @@ -33,13 +33,20 @@ UniqueConstraint, insert, select, + text, update, ) from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.tables import identity_string -from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH +from powercontext.limits import ( + MAX_ARTIFACT_FAMILY_LENGTH, + MAX_ARTIFACT_ID_LENGTH, + MAX_POLICY_REVISION_LENGTH, + MAX_SCOPE_ID_LENGTH, +) from powercontext.server.authz.errors import AccessConflictError, AccessInvalidRequestError from powercontext.server.authz.models import ( AccessAction, @@ -89,7 +96,7 @@ Column("expires_at", identity_string(32)), Column("state", identity_string(16), nullable=False), Column("version", Integer, nullable=False), - Column("policy_revision", identity_string(32), nullable=False), + Column("policy_revision", identity_string(MAX_POLICY_REVISION_LENGTH), nullable=False), Column("idempotency_key", identity_string(255), nullable=False), Column("idempotency_key_hash", identity_string(64), nullable=False), Column("revoked_at", identity_string(32)), @@ -128,7 +135,7 @@ Column("selector_entry_version_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), Column("allowed", Boolean, nullable=False), Column("reason_code", identity_string(64), nullable=False), - Column("policy_revision", identity_string(32)), + Column("policy_revision", identity_string(MAX_POLICY_REVISION_LENGTH)), Column("binding_id", identity_string(64)), Column("target_type", identity_string(64)), Column("target_issuer", identity_string(255)), @@ -138,6 +145,43 @@ ACCESS_TABLES = (ACCESS_POLICY_HEADS_TABLE, ACCESS_BINDINGS_TABLE, ACCESS_AUDIT_EVENTS_TABLE) _POLICY_HEAD = "authorization" +_MYSQL_POLICY_REVISION_LENGTH_SQL = text( + """ + SELECT character_maximum_length + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = 'policy_revision' + """ +) +_MYSQL_POLICY_REVISION_MIGRATIONS = { + "pc_access_bindings": ( + "ALTER TABLE pc_access_bindings MODIFY COLUMN policy_revision " + f"VARCHAR({MAX_POLICY_REVISION_LENGTH}) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL" + ), + "pc_access_audit_events": ( + "ALTER TABLE pc_access_audit_events MODIFY COLUMN policy_revision " + f"VARCHAR({MAX_POLICY_REVISION_LENGTH}) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL" + ), +} + + +async def ensure_access_policy_revision_columns(connection: AsyncConnection, /) -> None: + """Widen legacy MySQL-compatible Access revision columns without shrinking newer schemas.""" + + if connection.dialect.name == "sqlite": + # SQLite's VARCHAR length is descriptive and does not constrain stored text. + return + if connection.dialect.name != "mysql": + raise ValueError(f"unsupported Access schema migration dialect: {connection.dialect.name}") # noqa: TRY003 + + for table_name, migration_sql in _MYSQL_POLICY_REVISION_MIGRATIONS.items(): + current_length = await connection.scalar( + _MYSQL_POLICY_REVISION_LENGTH_SQL, + {"table_name": table_name}, + ) + if current_length is not None and int(current_length) < MAX_POLICY_REVISION_LENGTH: + await connection.exec_driver_sql(migration_sql) class RelationalAccessRepository: @@ -556,4 +600,5 @@ def _idempotency_digest(resource: ResourceRef, idempotency_key: str) -> str: __all__ = ( "ACCESS_TABLES", "RelationalAccessRepository", + "ensure_access_policy_revision_columns", ) diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index 203aaaa9d..50885647a 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -23,6 +23,7 @@ from typing import Literal, Protocol, TypeVar from uuid import uuid4 +from powercontext.limits import MAX_POLICY_REVISION_LENGTH from powercontext.server.authz.errors import ( AccessControlError, AccessDeniedError, @@ -554,6 +555,18 @@ async def _record_relationship( ) +def access_control_for_mode( + access_control: AccessControlService | None, + *, + mode: str, +) -> AccessControlService | None: + """Return the active PDP, failing closed when enforcement requires one.""" + + if access_control is None and mode == "enforced": + raise AccessUnavailableError + return access_control + + def _validate_resource_list_query( *, action: AccessAction, @@ -665,7 +678,9 @@ def _validate_provider_decision(value: object) -> None: or any(not character.isascii() or not (character.isalnum() or character in "._-") for character in reason) ): raise AccessUnavailableError - if value.policy_revision is not None and (not value.policy_revision or len(value.policy_revision) > 128): + if value.policy_revision is not None and ( + not value.policy_revision or len(value.policy_revision) > MAX_POLICY_REVISION_LENGTH + ): raise AccessUnavailableError @@ -711,4 +726,5 @@ async def _access_call(awaitable: Awaitable[_T]) -> _T: "CreateBinding", "RelationshipWriter", "ResourceSearchRequest", + "access_control_for_mode", ) diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 8daca04bc..87db35560 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -41,7 +41,14 @@ from powercontext.paths import default_scheduler_path from powercontext.server.access import HttpAccessLogMiddleware from powercontext.server.app import create_app -from powercontext.server.authz import AccessAction, AccessAuditContext, AccessControlService, PrincipalRef, ResourceRef +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessControlService, + PrincipalRef, + ResourceRef, + access_control_for_mode, +) from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.context import current_principal, current_request_id from powercontext.server.mcp import mount_mcp @@ -59,7 +66,10 @@ def __init__(self, metrics: ServerMetrics) -> None: self._metrics = metrics async def __call__(self, request: Request) -> Response: - access: AccessControlService | None = request.app.state.access_control + access = access_control_for_mode( + request.app.state.access_control, + mode=request.app.state.access_mode, + ) if access is not None: await access.require( current_principal(), diff --git a/src/powercontext/server/web.py b/src/powercontext/server/web.py index 1ffb569e5..f6cb80c82 100644 --- a/src/powercontext/server/web.py +++ b/src/powercontext/server/web.py @@ -42,7 +42,12 @@ from powercontext.builtin.runtime import GetArtifactCandidateRequest, GetSkillRequest, ListExternalSkillsRequest from powercontext.http import ErrorDetail, ErrorResponse from powercontext.limits import MAX_ARTIFACT_ID_LENGTH -from powercontext.server.authz import AccessAction, AccessAuditContext, AccessControlService, ResourceRef +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + ResourceRef, + access_control_for_mode, +) from powercontext.server.context import current_principal, current_request_id logger = logging.getLogger(__name__) @@ -384,7 +389,10 @@ async def _visible_dashboard_scopes( request: Request, dashboard_scopes: tuple[DashboardScope, ...], ) -> tuple[DashboardScope, ...]: - access: AccessControlService | None = request.app.state.access_control + access = access_control_for_mode( + request.app.state.access_control, + mode=request.app.state.access_mode, + ) if access is None or not dashboard_scopes: return dashboard_scopes checks = tuple((AccessAction.SCOPE_READ, ResourceRef.scope(item.scope_id)) for item in dashboard_scopes) @@ -403,7 +411,10 @@ async def _authorize_dashboard_skill( operation: str, publish: bool = False, ) -> None: - access: AccessControlService | None = request.app.state.access_control + access = access_control_for_mode( + request.app.state.access_control, + mode=request.app.state.access_mode, + ) if access is None: return resource = ResourceRef.artifact( diff --git a/tests/test_access_adapters.py b/tests/test_access_adapters.py index c4f8b7c2c..b871b958e 100644 --- a/tests/test_access_adapters.py +++ b/tests/test_access_adapters.py @@ -299,6 +299,31 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_authzen_adapter_enforces_the_policy_revision_contract_boundary() -> None: + request = AccessRequest( + subject=BOB, + action=AccessAction.SERVER_OBSERVE, + resource=ResourceRef.server(), + context=AUDIT, + ) + + async def evaluate(revision: str): + transport = httpx.MockTransport( + lambda _request: httpx.Response( + 200, + json={"decision": True, "context": {"policy_revision": revision}}, + ) + ) + async with httpx.AsyncClient(transport=transport) as client: + provider = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) + return await provider.check(request) + + accepted = asyncio.run(evaluate("r" * 64)) + assert accepted.policy_revision == "r" * 64 + with pytest.raises(AccessUnavailableError): + asyncio.run(evaluate("r" * 65)) + + def test_authzen_adapter_rejects_credential_urls_and_relationship_claims() -> None: with pytest.raises(ValueError, match="credential-free"): AuthZenAuthorizationProvider("https://user:secret@pdp.example") diff --git a/tests/test_access_control.py b/tests/test_access_control.py index 3cc5b4839..80e5409fe 100644 --- a/tests/test_access_control.py +++ b/tests/test_access_control.py @@ -16,15 +16,23 @@ import asyncio from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock import pytest +from sqlalchemy.dialects import mysql +from sqlalchemy.ext.asyncio import AsyncConnection +from sqlalchemy.schema import CreateTable from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.server.authz import ( AccessAction, AccessAuditContext, + AccessAuditStore, AccessConflictError, AccessControlService, + AccessDecision, AccessDeniedError, AccessInvalidRequestError, AccessProviderCapabilities, @@ -32,13 +40,20 @@ AccessResourceType, AccessRole, AccessUnavailableError, + AuthorizationProvider, BuiltinAuthorizationProvider, CreateBinding, MemoryEntrySelector, PrincipalRef, ResourceRef, ) -from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.authz.repository import ( + ACCESS_AUDIT_EVENTS_TABLE, + ACCESS_BINDINGS_TABLE, + ACCESS_TABLES, + RelationalAccessRepository, + ensure_access_policy_revision_columns, +) NOW = datetime(2026, 8, 30, 10, tzinfo=UTC) ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") @@ -471,6 +486,66 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_access_service_enforces_the_policy_revision_contract_boundary() -> None: + provider = SimpleNamespace( + check=AsyncMock(return_value=AccessDecision(True, "provider-allow", "r" * 64)), + ) + audit = SimpleNamespace(append_audit=AsyncMock()) + service = AccessControlService( + cast(AuthorizationProvider, provider), + relationships=None, + audit=cast(AccessAuditStore, audit), + ) + + accepted = asyncio.run( + service.check( + BOB, + AccessAction.SERVER_OBSERVE, + ResourceRef.server(), + context=AUDIT, + ) + ) + assert accepted.policy_revision == "r" * 64 + + provider.check.return_value = AccessDecision(True, "provider-allow", "r" * 65) + with pytest.raises(AccessUnavailableError): + asyncio.run( + service.check( + BOB, + AccessAction.SERVER_OBSERVE, + ResourceRef.server(), + context=AUDIT, + ) + ) + + +def test_access_schema_and_mysql_migration_use_the_policy_revision_contract_limit() -> None: + bindings = str(CreateTable(ACCESS_BINDINGS_TABLE).compile(dialect=mysql.dialect())) + audit = str(CreateTable(ACCESS_AUDIT_EVENTS_TABLE).compile(dialect=mysql.dialect())) + assert "policy_revision VARCHAR(64)" in bindings + assert "policy_revision VARCHAR(64)" in audit + + connection = SimpleNamespace( + dialect=SimpleNamespace(name="mysql"), + scalar=AsyncMock(side_effect=(32, 32)), + exec_driver_sql=AsyncMock(), + ) + asyncio.run(ensure_access_policy_revision_columns(cast(AsyncConnection, connection))) + + migrations = [call.args[0] for call in connection.exec_driver_sql.await_args_list] + assert migrations == [ + "ALTER TABLE pc_access_bindings MODIFY COLUMN policy_revision " + "VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL", + "ALTER TABLE pc_access_audit_events MODIFY COLUMN policy_revision " + "VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL", + ] + + connection.scalar.side_effect = (64, 128) + connection.exec_driver_sql.reset_mock() + asyncio.run(ensure_access_policy_revision_columns(cast(AsyncConnection, connection))) + connection.exec_driver_sql.assert_not_awaited() + + def _service(database) -> tuple[AccessControlService, RelationalAccessRepository]: repository = RelationalAccessRepository(database) provider = BuiltinAuthorizationProvider( diff --git a/tests/test_access_http.py b/tests/test_access_http.py index b6a0b2a7f..ae931d6d4 100644 --- a/tests/test_access_http.py +++ b/tests/test_access_http.py @@ -56,6 +56,20 @@ def test_enforced_mode_cannot_silently_start_without_authentication_or_provider( create_server_app(settings=ServerSettings(access=AccessControlConfig(mode="enforced"))) +def test_low_level_enforced_app_fails_closed_without_an_authorization_provider() -> None: + async def scenario() -> None: + async with _client(create_app(access_mode="enforced")) as client: + readiness = await client.get("/health/ready") + capabilities = await client.get("/v1/capabilities") + + assert readiness.status_code == 503 + assert readiness.json()["checks"]["access_provider"] == "not_ready" + assert capabilities.status_code == 503 + assert capabilities.json()["error"]["code"] == "access_unavailable" + + asyncio.run(scenario()) + + class _HandoffShareability: def for_scope(self, scope_id: str) -> Self: del scope_id diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index b54b83989..e6652f749 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -138,6 +138,15 @@ def test_contract_declares_optional_bearer_authentication() -> None: assert "x-powercontext-access" in operation +def test_every_access_protected_operation_declares_the_unavailable_response() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + + for path_item in contract["paths"].values(): + operation = next(iter(path_item.values())) + if "x-powercontext-access" in operation: + assert operation["responses"]["503"] == {"$ref": "#/components/responses/Unavailable"} + + def test_capabilities_report_semantics_without_runtime_tuning_values() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) schemas = contract["components"]["schemas"] @@ -236,6 +245,9 @@ def test_access_contract_uses_stable_resource_kinds_family_profiles_and_skill_pu assert set(artifact["properties"]) == {"type", "scope_id", "reference", "selector"} selector = schemas["MemoryEntryAccessSelector"] assert selector["required"] == ["type", "entry_id", "entry_version_id"] + assert schemas["AccessDecision"]["properties"]["policy_revision"]["maxLength"] == 64 + assert schemas["AccessBinding"]["properties"]["policy_revision"]["maxLength"] == 64 + assert schemas["AccessAuditEvent"]["properties"]["policy_revision"]["maxLength"] == 64 assert GET_MEMORY_ENTRY.access is not None assert GET_MEMORY_ENTRY.access.resolver == "exact_memory_access" diff --git a/tests/test_server.py b/tests/test_server.py index 1f7a32225..0869ab6d2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,6 +19,7 @@ import shlex from datetime import datetime, timedelta from pathlib import Path +from typing import cast import httpx import pytest @@ -44,8 +45,16 @@ ReadinessStatus, ) from powercontext.server.app import create_app +from powercontext.server.authz import AccessControlService from powercontext.server.factory import create_server_app -from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +from powercontext.server.settings import ( + AccessControlConfig, + BearerAuthConfig, + DashboardConfig, + DashboardScopeConfig, + McpConfig, + ServerSettings, +) from powercontext.sources import Source _ACCESS_FAMILIES = "experience:enabled,handoff:enabled,memory:enabled,prompt:disabled,skill:enabled" @@ -348,6 +357,43 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: assert scalar_reference.status_code == 200 +def test_enforced_mode_fails_closed_if_the_authorization_provider_disappears(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + auth=BearerAuthConfig(enabled=True, token=SecretStr("server-secret")), + access=AccessControlConfig(mode="enforced"), + dashboard=DashboardConfig(scopes=[DashboardScopeConfig(scope_id="scope-a", display_name="Scope A")]), + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), + mcp=McpConfig(enabled=False), + ), + access_control=cast(AccessControlService, object()), + ) + headers = {"Authorization": "Bearer server-secret"} + + with TestClient(app) as client: + app.state.access_control = None + readiness = client.get("/health/ready") + protected = ( + client.get("/v1/capabilities", headers=headers), + client.get("/metrics", headers=headers), + client.get("/dashboard/scopes", headers=headers), + client.post( + "/dashboard/skill-projections/status", + headers=headers, + json={ + "scope_id": "scope-a", + "candidate_id": "candidate-a", + "artifact": {"family": "skill", "artifact_id": "skill-a", "revision": 1}, + }, + ), + ) + + assert readiness.status_code == 503 + assert readiness.json()["checks"]["access_provider"] == "not_ready" + assert all(response.status_code == 503 for response in protected) + assert all(response.json()["error"]["code"] == "access_unavailable" for response in protected) + + def test_server_factory_maps_static_token_to_bootstrap_principal() -> None: app = create_server_app( settings=ServerSettings( From 7dfe91e678247f11687d6dd90c4b3a78ced46424 Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 2 Sep 2026 22:03:36 +0800 Subject: [PATCH 07/22] feat: align access control with terminal handoff RFC --- .env.example | 16 +- docker/README.md | 5 +- .../remote-access-implementation.md | 4 +- docs/en/docs/how-to/configure-claude-code.md | 4 +- docs/en/docs/how-to/configure-codex.md | 4 +- docs/en/docs/how-to/configure-dsh.md | 4 +- docs/en/docs/how-to/configure-openclaw.md | 4 +- docs/en/docs/how-to/configure-pi.md | 4 +- docs/en/docs/how-to/configure-workbuddy.md | 4 +- docs/en/docs/how-to/deploy-server.md | 8 +- docs/en/docs/reference/configuration.md | 50 +- docs/en/docs/reference/http-api.md | 36 +- .../remote-access-implementation.md | 4 +- docs/zh/docs/how-to/configure-claude-code.md | 4 +- docs/zh/docs/how-to/configure-codex.md | 4 +- docs/zh/docs/how-to/configure-dsh.md | 4 +- docs/zh/docs/how-to/configure-openclaw.md | 4 +- docs/zh/docs/how-to/configure-pi.md | 4 +- docs/zh/docs/how-to/configure-workbuddy.md | 4 +- docs/zh/docs/how-to/deploy-server.md | 8 +- docs/zh/docs/reference/configuration.md | 45 +- docs/zh/docs/reference/http-api.md | 31 +- .../dsh/plugins/powercontext/lib/index.js | 8 +- .../powercontext/openapi/powercontext.yaml | 233 +++-- .../powercontext/src/operations.generated.ts | 3 +- .../langgraph/examples/_local_server.py | 9 +- integrations/openclaw/README.md | 4 +- .../powercontext/src/operations.generated.ts | 3 +- .../powercontext/src/operations.generated.ts | 3 +- openapi/powercontext.yaml | 233 +++-- .../builtin/artifacts/handoff/__init__.py | 3 +- .../builtin/artifacts/handoff/service.py | 30 +- .../builtin/runtime/application.py | 36 +- .../builtin/runtime/composition.py | 6 +- src/powercontext/cli/config.py | 3 +- src/powercontext/http/__init__.py | 16 +- src/powercontext/http/_generated/models.py | 357 ++++--- .../http/_generated/operations.py | 53 +- src/powercontext/http/_generated/schema.py | 289 +++-- src/powercontext/server/app.py | 754 ++++++++++++-- src/powercontext/server/authentication.py | 129 +++ src/powercontext/server/authz/__init__.py | 30 +- src/powercontext/server/authz/authzen.py | 12 +- src/powercontext/server/authz/casbin.py | 212 ++-- src/powercontext/server/authz/composition.py | 70 +- src/powercontext/server/authz/errors.py | 26 +- src/powercontext/server/authz/models.py | 221 ++-- src/powercontext/server/authz/profiles.py | 81 +- src/powercontext/server/authz/repository.py | 964 ++++++++++++----- src/powercontext/server/authz/service.py | 985 +++++++++++++++--- src/powercontext/server/cli.py | 4 +- src/powercontext/server/context.py | 23 + src/powercontext/server/factory.py | 215 +++- src/powercontext/server/middleware.py | 142 ++- src/powercontext/server/settings.py | 49 +- src/powercontext/server/web.py | 13 +- .../test_access_control.py | 140 ++- tests/e2e/test_access_control_http.py | 129 ++- tests/e2e/test_claude_code_service_chain.py | 14 +- tests/e2e/test_codex_service_chain.py | 8 +- tests/e2e/test_handoff_runtime.py | 42 + tests/e2e/test_langgraph_chain.py | 8 +- tests/e2e/test_runtime_server.py | 4 +- tests/e2e/test_statistics_flow.py | 6 +- tests/e2e/test_workbuddy_service_chain.py | 8 +- tests/test_access_adapters.py | 334 ++---- tests/test_access_control.py | 632 +++++------ tests/test_access_http.py | 267 ++++- tests/test_access_mcp.py | 37 +- tests/test_api_contract.py | 14 +- tests/test_cli.py | 12 +- tests/test_client.py | 8 +- tests/test_dashboard.py | 30 +- tests/test_env_file.py | 8 +- tests/test_server.py | 54 +- tests/test_transport.py | 10 +- 76 files changed, 5127 insertions(+), 2112 deletions(-) create mode 100644 src/powercontext/server/authentication.py diff --git a/.env.example b/.env.example index e0e27c4af..fe8ef0a06 100644 --- a/.env.example +++ b/.env.example @@ -13,15 +13,17 @@ POWERCONTEXT_SERVER_HTTP_PORT=8000 POWERCONTEXT_SERVER_MCP_ENABLED=true POWERCONTEXT_SERVER_MCP_PATH=/mcp -# Authentication is optional on loopback. Load the real token from a secret manager. -POWERCONTEXT_SERVER_AUTH_ENABLED=false -# POWERCONTEXT_SERVER_AUTH_TOKEN=replace-me - # Access Control -------------------------------------------------------------- -# Use enforced only with an authentication provider that establishes a distinct Principal per caller. -POWERCONTEXT_SERVER_ACCESS_MODE=legacy-static-admin +# ACCESS_MODE is the only security switch. In enforced mode, select both Providers. +POWERCONTEXT_SERVER_ACCESS_MODE=disabled +# POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +# POWERCONTEXT_SERVER_AUTH_TOKEN=replace-me +# POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=powercontext -POWERCONTEXT_SERVER_ACCESS_BOOTSTRAP_STATIC_PRINCIPAL=true +POWERCONTEXT_SERVER_ACCESS_STATIC_PRESET=true +# Multi-user deployments with scheduled jobs must bind an explicit service Principal. +# POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_ID=service:scheduled-processing +# POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION=Scheduled processing # Dashboard ------------------------------------------------------------------- # Every Coding Agent below uses this same Scope ID. diff --git a/docker/README.md b/docker/README.md index bc8f4bbdd..a3db699b9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -41,8 +41,9 @@ PowerContext refuses to start an unauthenticated Server on a non-loopback addres port is reachable, and its network namespace is the controlled boundary that opt-in is meant for, so the image sets it by default and the `docker run` above starts without extra configuration. Access is still governed by which ports you publish (`--publish`) and the surrounding network. For an exposed deployment, put the Server behind a -TLS-terminating proxy and enable bearer authentication with -`POWERCONTEXT_SERVER_AUTH_ENABLED=true` and `POWERCONTEXT_SERVER_AUTH_TOKEN=...`; when authentication is enabled the +TLS-terminating proxy and enable enforced Access Control with +`POWERCONTEXT_SERVER_ACCESS_MODE=enforced`, `POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer`, +`POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin`, and `POWERCONTEXT_SERVER_AUTH_TOKEN=...`; in enforced mode the opt-in is no longer required. The `Build Docker image` GitHub workflow builds downloadable Linux amd64 and arm64 image archives for pull requests, diff --git a/docs/en/development/remote-access-implementation.md b/docs/en/development/remote-access-implementation.md index e847fadc9..e12d44d7f 100644 --- a/docs/en/development/remote-access-implementation.md +++ b/docs/en/development/remote-access-implementation.md @@ -25,7 +25,9 @@ is otherwise controlled. ```bash # Recommended: authenticate the Server, then bind a routable address (put TLS in front in production). -POWERCONTEXT_SERVER_AUTH_ENABLED=true \ +POWERCONTEXT_SERVER_ACCESS_MODE=enforced \ +POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer \ +POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin \ POWERCONTEXT_SERVER_AUTH_TOKEN="replace-with-a-strong-token" \ uv run powercontext server run --host 0.0.0.0 --port 8080 ``` diff --git a/docs/en/docs/how-to/configure-claude-code.md b/docs/en/docs/how-to/configure-claude-code.md index cded5130e..f70ce65a6 100644 --- a/docs/en/docs/how-to/configure-claude-code.md +++ b/docs/en/docs/how-to/configure-claude-code.md @@ -137,7 +137,9 @@ client remains managed by Claude Code. Start the Server with its token loaded from your secret manager: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-codex.md b/docs/en/docs/how-to/configure-codex.md index 57d6b74b1..a808bd54f 100644 --- a/docs/en/docs/how-to/configure-codex.md +++ b/docs/en/docs/how-to/configure-codex.md @@ -95,7 +95,9 @@ This adds inference latency to each prompt and is not the normal interactive set Load one token from your local secret manager, then start the Server with authentication enabled: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-dsh.md b/docs/en/docs/how-to/configure-dsh.md index 2de6affb7..04423a581 100644 --- a/docs/en/docs/how-to/configure-dsh.md +++ b/docs/en/docs/how-to/configure-dsh.md @@ -54,7 +54,9 @@ This adds inference latency to each prompt and is not the normal interactive set ## Connect to an authenticated local Server ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-openclaw.md b/docs/en/docs/how-to/configure-openclaw.md index 2db4fe700..b5ebddb6f 100644 --- a/docs/en/docs/how-to/configure-openclaw.md +++ b/docs/en/docs/how-to/configure-openclaw.md @@ -68,7 +68,9 @@ Project scope is used only when OpenClaw supplies exactly one trusted project id Start an authenticated Server from a protected environment: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-pi.md b/docs/en/docs/how-to/configure-pi.md index 21724dd23..cede680a6 100644 --- a/docs/en/docs/how-to/configure-pi.md +++ b/docs/en/docs/how-to/configure-pi.md @@ -80,7 +80,9 @@ write rather than persisting it silently. `/pc doctor`, `/pc search `, `/ Start an authenticated Server from a protected environment: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-workbuddy.md b/docs/en/docs/how-to/configure-workbuddy.md index 1f531bbd5..3d25699b4 100644 --- a/docs/en/docs/how-to/configure-workbuddy.md +++ b/docs/en/docs/how-to/configure-workbuddy.md @@ -247,7 +247,9 @@ Load one token from your local secret manager, then start the Server with authentication enabled: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/deploy-server.md b/docs/en/docs/how-to/deploy-server.md index 52e8dc669..20285be77 100644 --- a/docs/en/docs/how-to/deploy-server.md +++ b/docs/en/docs/how-to/deploy-server.md @@ -78,7 +78,9 @@ named volume persists the SQLite database and scheduler state after the containe Load a strong token from your secret manager into the Server process environment: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_DEPLOYMENT_TOKEN" powercontext server run ``` @@ -90,7 +92,9 @@ docker run --rm \ --name powercontext-server \ --publish 127.0.0.1:8000:8000 \ --volume powercontext-data:/data \ - --env POWERCONTEXT_SERVER_AUTH_ENABLED=true \ + --env POWERCONTEXT_SERVER_ACCESS_MODE=enforced \ + --env POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer \ + --env POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin \ --env POWERCONTEXT_SERVER_AUTH_TOKEN \ powercontext-server:local ``` diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index 75892a128..1ce1e568d 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -53,11 +53,16 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_HTTP_PORT` | `8000` | Listener port | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | Enable Streamable HTTP MCP | | `POWERCONTEXT_SERVER_MCP_PATH` | `/mcp` | MCP path | -| `POWERCONTEXT_SERVER_AUTH_ENABLED` | `false` | Require one static bearer token for HTTP and MCP | -| `POWERCONTEXT_SERVER_AUTH_TOKEN` | unset | Static bearer token; required when authentication is enabled | -| `POWERCONTEXT_SERVER_ACCESS_MODE` | `legacy-static-admin` | Authorization rollout: `disabled`, `legacy-static-admin`, or `enforced` | -| `POWERCONTEXT_SERVER_ACCESS_BOOTSTRAP_STATIC_PRINCIPAL` | `true` | Treat the deployment-local static-token Principal as a bootstrap Server administrator | -| `POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID` | `powercontext` | Stable deployment identity used by the `server` Access Resource and static Principal issuer | +| `POWERCONTEXT_SERVER_AUTH_PROVIDER` | unset | Authentication Provider: `static-bearer`, `oidc`, or `trusted-header`; required in `enforced` mode | +| `POWERCONTEXT_SERVER_AUTH_TOKEN` | unset | Static bearer token; valid only with `AUTH_PROVIDER=static-bearer` | +| `POWERCONTEXT_SERVER_AUTH_PRINCIPAL_ID` | `server-token` | Deployment-wide unique Principal ID represented by the static token | +| `POWERCONTEXT_SERVER_AUTH_PRINCIPAL_DESCRIPTION` | `PowerContext static bearer` | Optional display-only description for the static Principal | +| `POWERCONTEXT_SERVER_ACCESS_MODE` | `disabled` | Sole security switch: `disabled` or `enforced` | +| `POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER` | unset | Authorization Provider: `builtin`, `casbin`, or `external`; required in `enforced` mode | +| `POWERCONTEXT_SERVER_ACCESS_STATIC_PRESET` | `true` | Materialize the explicit built-in roles needed by a single-Principal static deployment | +| `POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID` | `powercontext` | Stable deployment identity used by the `server` Access Resource | +| `POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_ID` | unset | Explicit service Principal for scheduled jobs in a multi-user enforced deployment | +| `POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION` | unset | Optional display-only description for the scheduled service Principal | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | Opt in to a non-loopback bind while authentication is disabled | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | Enable the Dashboard at the Server root path `/` | | `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | JSON array of selectable Dashboard scopes | @@ -89,27 +94,38 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | unset | Experience incubation interval; unset disables that job | | `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | unset | JSON object containing the host identity and explicit Agent Skill targets | -Static bearer authentication is disabled by default. When enabled, API and MCP requests must include -`Authorization: Bearer `; the liveness and readiness endpoints remain public. Plain HTTP is trusted only on a +Access Control is disabled by default. In `enforced` mode, API and MCP requests must establish a Principal through the +selected Authentication Provider; the liveness and readiness endpoints remain public. The built-in `static-bearer` +Provider accepts `Authorization: Bearer `. Plain HTTP is trusted only on a loopback address (`localhost`, `::1`, or any address in `127.0.0.0/8`). The Server refuses to start when it binds to a non-loopback address while authentication is disabled; either enable authentication, keep the bind on loopback, or, when TLS is terminated upstream or the network is otherwise controlled, set `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` to opt in explicitly. Use TLS before exposing an authenticated Server over a network. -Authentication establishes a Principal; Access Control decides what that Principal may do. The built-in static token -always represents one deployment-local service Principal, so it cannot distinguish user A from user B. The default -`legacy-static-admin` mode maps that Principal to a bootstrap Server administrator and preserves the single-user local -deployment. `enforced` enables the same policy enforcement point and persistent Binding/audit store for an injected -multi-user authentication and Authorization Provider. Set `bootstrap_static_principal=false` after another -administrator relationship is available. `disabled` bypasses authorization decisions and is intended only for an -explicit compatibility rollback inside an already trusted network boundary. +`POWERCONTEXT_SERVER_ACCESS_MODE` is the only switch. `disabled` rejects authentication and authorization Provider +configuration and bypasses authorization decisions inside the trusted local boundary. `enforced` requires both +`AUTH_PROVIDER` and `AUTHORIZATION_PROVIDER`; it enables one policy enforcement point and the configured Provider's +Binding and audit behavior. + +Authentication establishes a Principal; Access Control decides what that Principal may do. Principal IDs are +deployment-wide unique, non-reused identifiers; `description` is display metadata and is not part of identity. The +built-in static token always represents one service Principal, so it cannot distinguish user A from user B. With the +built-in Authorization Provider, `ACCESS_STATIC_PRESET=true` materializes explicit Server and per-scope roles for +that Principal. Use `oidc` or `trusted-header` with a deployment-supplied Authentication Provider and an appropriate +Authorization Provider when different users or groups need different access. + +Scheduled Source processing and Experience incubation run as the fixed static Principal, or as the service Principal +selected by `ACCESS_BACKGROUND_PRINCIPAL_ID`. That Principal must have `scope.contribute` for each processed scope; +new Memory entries and Candidates retain it as their direct proposed owner. An enforced multi-user deployment that +configures a schedule without this explicit Principal fails at startup. Remote, multi-user, and shared-Dashboard deployments must use `enforced`. In that mode, HTTP, MCP, Dashboard data routes, and metrics share one Server PEP. Configured Dashboard scopes are filtered by the current Principal's `scope.read` decision before they are returned. `/v1/access/me` reports the `server`/`scope`/`artifact` Resource Kinds, -Provider batch/list/relationship capabilities, Artifact Family profiles, and whether this deployment has a managed -Skill publication operation protected by both required actions. +Provider batch/list/relationship capabilities and Artifact Family profiles. Managed Skill export and installation do +not introduce separate Access actions: the recipient first needs `artifact.read` on the logical Skill identity, then +chooses whether and how to install an exact Revision. The built-in Access schema uses the configured SQLite, seekDB, or OceanBase backend, but remains Server-owned rather than becoming a Runtime domain. A custom deployment can inject an `AccessControlService` into `create_server_app`. @@ -137,7 +153,7 @@ The Dashboard is enabled by default and shares the Server listener and port with configured, the page shows an empty state. Dashboard initialization failures are logged with their direct cause and do not prevent the Server HTTP API, MCP, or health checks from starting. -When bearer authentication is enabled, the HTML shells at `/`, `/skills`, `/reviews`, and `/handoff-reports`, plus +When `AUTH_PROVIDER=static-bearer` is enforced, the HTML shells at `/`, `/skills`, `/reviews`, and `/handoff-reports`, plus their static assets, remain public so the browser can render the sign-in form. Data requests stay protected. Enter the Server token in that form; the browser keeps it only in the current tab's session storage. Disable both Dashboard and Handoff Report if even these sign-in pages must not be exposed. diff --git a/docs/en/docs/reference/http-api.md b/docs/en/docs/reference/http-api.md index 79602b656..9d7a31892 100644 --- a/docs/en/docs/reference/http-api.md +++ b/docs/en/docs/reference/http-api.md @@ -94,9 +94,9 @@ curl --fail \ "$POWERCONTEXT_URL/v1/memory/search" ``` -## Grant one exact Handoff to a receiver +## Grant one logical Handoff to a receiver -`scope_id` never grants access by itself. An administrator delegates one exact committed Handoff by creating a +`scope_id` never grants access by itself. The Handoff owner or an authorized delegator assigns one logical committed Handoff by creating a Binding for the receiver's authenticated Principal: ```bash @@ -105,36 +105,38 @@ curl --fail \ --header 'Content-Type: application/json' \ --header "$POWERCONTEXT_AUTH_HEADER" \ --data '{ - "subject": {"type": "user", "issuer": "https://id.example", "id": "user-b"}, + "subject": {"type": "user", "id": "idp:user-b", "description": "User B"}, "resource": { "type": "artifact", "scope_id": "project:example", - "reference": {"family": "handoff", "artifact_id": "handoff-42", "revision": 3}, + "identity": {"family": "handoff", "artifact_id": "handoff-42"}, "selector": null }, "role": "handoff.receiver", - "idempotency_key": "handoff-42-r3-to-user-b" + "idempotency_key": "handoff-42-to-user-b" }' \ "$POWERCONTEXT_URL/v1/access/bindings/create" ``` -The receiver can read evidence and acknowledge only that Revision. It cannot use latest-Handoff discovery, read -another Handoff, or access Memory in the parent scope unless a separate scope role allows it. Use `/v1/access/me` to +The receiver can read and acknowledge the Handoff's history, current Revision, and future Revisions. It cannot use +scope-wide latest-Handoff discovery, read another Handoff, or access Memory in the parent scope unless a separate +scope role allows it. Use `/v1/access/me` to verify which Principal the deployment established, `/v1/access/check` for one decision, and `/v1/access/resources/list` for a non-discovering list of already visible resources. Creation is idempotent per grantor and key; revocation uses `binding_id` plus `expected_version`. Relationship and decision events are available to Server administrators through `/v1/access/audit/list`. -The Access wire contract has only three Resource Kinds: `server`, `scope`, and `artifact`. An Artifact `reference` -must identify one exact Revision. Memory also requires a complete `memory_entry` selector containing `entry_id` and -`entry_version_id`. Unknown Families, `prompt` when no Prompt lifecycle is implemented, mismatched selectors or roles, -and `latest` never create a Binding. `/v1/access/me` reports the current mode, Provider capabilities, and each Artifact -Family's enabled state. +The Access wire contract has only three Resource Kinds: `server`, `scope`, and `artifact`. An Artifact Resource uses +the logical identity `{family, artifact_id}` and deliberately contains no Revision. Memory can narrow a grant with a +`memory_entry` selector containing only `entry_id`. Unknown Families, `prompt` when no Prompt lifecycle is implemented, +and mismatched selectors or roles never create a Binding. `/v1/access/me` reports the current mode, Provider +capabilities, and each Artifact Family's enabled state. -Reading a managed Skill and publishing it are separate permissions. Both `/v1/skills/publication-targets/list` and -`/v1/skills/publish` require `artifact.read` plus `skill.publish` on the same exact Skill Revision. Requests submit only -an opaque `target_id`; public responses and errors omit host paths, Agent homes, credentials, and locators. Detailed -Dashboard publication status is separately protected by `server.observe`. +Managed Skill export and installation do not have separate sharing permissions. Both +`/v1/skills/publication-targets/list` and `/v1/skills/publish` require `artifact.read` on the logical Skill identity; +the receiving Principal decides whether and how to install an exact Revision. Requests submit only an opaque +`target_id`; public responses and errors omit host paths, Agent homes, credentials, and locators. Detailed Dashboard +publication status is separately protected by `server.observe`. The built-in static token represents one local administrator and cannot model different A/B users. A real multi-user deployment must authenticate each caller to a different Principal and inject an Authorization Provider. HTTP and MCP @@ -150,7 +152,7 @@ use the same policy enforcement point; MCP tool visibility is not permission. | Work continuity | `/v1/work/*` | Create work contracts, prepare or acknowledge Handoffs, and record outcomes | | Low-level Handoff | `/v1/handoff/*` | Activate, prepare, finalize, commit, or continue a Handoff | | Memory | `/v1/memory/*` | Flush, remember, search, list, get, revise, retire, and inspect changes | -| Experience and Skill | `/v1/experience/*`, `/v1/skill/*`, `/v1/skills/*` | Propose, generate, read Artifact revisions, and publish managed Skills under dual authorization | +| Experience and Skill | `/v1/experience/*`, `/v1/skill/*`, `/v1/skills/*` | Propose, generate, read Artifact revisions, and export readable managed Skills | | Review | `/v1/artifact-candidates/*` | List, inspect, revise, approve, or reject pending Candidates | | External Skills | `/v1/external-skills/*` | Scan configured targets and resolve or import packages | | Handoff Reports | `/v1/handoff-reports/*` | Manage Projects, Workstreams, activities, reports, and workspace bindings | diff --git a/docs/zh/development/remote-access-implementation.md b/docs/zh/development/remote-access-implementation.md index d3f1b867e..2a8452756 100644 --- a/docs/zh/development/remote-access-implementation.md +++ b/docs/zh/development/remote-access-implementation.md @@ -22,7 +22,9 @@ uv run powercontext server run ```bash # 推荐:先为 Server 启用认证,再绑定可路由地址(生产环境在前面加 TLS)。 -POWERCONTEXT_SERVER_AUTH_ENABLED=true \ +POWERCONTEXT_SERVER_ACCESS_MODE=enforced \ +POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer \ +POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin \ POWERCONTEXT_SERVER_AUTH_TOKEN="replace-with-a-strong-token" \ uv run powercontext server run --host 0.0.0.0 --port 8080 ``` diff --git a/docs/zh/docs/how-to/configure-claude-code.md b/docs/zh/docs/how-to/configure-claude-code.md index 133ecdd10..b6aa1d286 100644 --- a/docs/zh/docs/how-to/configure-claude-code.md +++ b/docs/zh/docs/how-to/configure-claude-code.md @@ -127,7 +127,9 @@ timeout 和 flush 控制项见[配置参考](../reference/configuration.md)。 从 secret manager 加载 token,再启动 Server: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-codex.md b/docs/zh/docs/how-to/configure-codex.md index bae409c11..6e5eaaeb8 100644 --- a/docs/zh/docs/how-to/configure-codex.md +++ b/docs/zh/docs/how-to/configure-codex.md @@ -87,7 +87,9 @@ export POWERCONTEXT_CODEX_FLUSH_ON_CAPTURE=true 从本地 secret manager 加载一个 token,然后启用鉴权并启动 Server: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-dsh.md b/docs/zh/docs/how-to/configure-dsh.md index f01d24e39..88bb08351 100644 --- a/docs/zh/docs/how-to/configure-dsh.md +++ b/docs/zh/docs/how-to/configure-dsh.md @@ -54,7 +54,9 @@ export POWERCONTEXT_DSH_FLUSH_ON_CAPTURE=true ## 连接启用鉴权的本地 Server ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-openclaw.md b/docs/zh/docs/how-to/configure-openclaw.md index d1a9468c9..338555205 100644 --- a/docs/zh/docs/how-to/configure-openclaw.md +++ b/docs/zh/docs/how-to/configure-openclaw.md @@ -66,7 +66,9 @@ project scope 仅在 OpenClaw 为一次 turn 提供唯一可信项目身份时 在受保护环境中启动启用鉴权的 Server: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-pi.md b/docs/zh/docs/how-to/configure-pi.md index e795a10a9..f45252061 100644 --- a/docs/zh/docs/how-to/configure-pi.md +++ b/docs/zh/docs/how-to/configure-pi.md @@ -77,7 +77,9 @@ flush。 在受保护环境中启动启用鉴权的 Server: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-workbuddy.md b/docs/zh/docs/how-to/configure-workbuddy.md index a02a53304..f9597c4ff 100644 --- a/docs/zh/docs/how-to/configure-workbuddy.md +++ b/docs/zh/docs/how-to/configure-workbuddy.md @@ -215,7 +215,9 @@ WorkBuddy 按以下顺序解析 scope: 从本地 secret manager 加载一个 token,然后启用鉴权并启动 Server: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/deploy-server.md b/docs/zh/docs/how-to/deploy-server.md index 892e58565..c4a4e7d20 100644 --- a/docs/zh/docs/how-to/deploy-server.md +++ b/docs/zh/docs/how-to/deploy-server.md @@ -76,7 +76,9 @@ SQLite 数据库和 scheduler 状态。 从 secret manager 把强 token 加载到 Server 进程环境: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_DEPLOYMENT_TOKEN" powercontext server run ``` @@ -88,7 +90,9 @@ docker run --rm \ --name powercontext-server \ --publish 127.0.0.1:8000:8000 \ --volume powercontext-data:/data \ - --env POWERCONTEXT_SERVER_AUTH_ENABLED=true \ + --env POWERCONTEXT_SERVER_ACCESS_MODE=enforced \ + --env POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer \ + --env POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin \ --env POWERCONTEXT_SERVER_AUTH_TOKEN \ powercontext-server:local ``` diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index bf4902aca..b0308ab28 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -50,11 +50,16 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_HTTP_PORT` | `8000` | 监听端口 | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | 启用 Streamable HTTP MCP | | `POWERCONTEXT_SERVER_MCP_PATH` | `/mcp` | MCP 路径 | -| `POWERCONTEXT_SERVER_AUTH_ENABLED` | `false` | HTTP 和 MCP 是否要求一个静态 Bearer token | -| `POWERCONTEXT_SERVER_AUTH_TOKEN` | 未设置 | 静态 Bearer token;启用鉴权时必须设置 | -| `POWERCONTEXT_SERVER_ACCESS_MODE` | `legacy-static-admin` | 权限启用模式:`disabled`、`legacy-static-admin` 或 `enforced` | -| `POWERCONTEXT_SERVER_ACCESS_BOOTSTRAP_STATIC_PRINCIPAL` | `true` | 是否把部署本地静态 token 的 Principal 作为初始 Server 管理员 | -| `POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID` | `powercontext` | `server` Access Resource 与静态 Principal issuer 使用的稳定部署标识 | +| `POWERCONTEXT_SERVER_AUTH_PROVIDER` | 未设置 | Authentication Provider:`static-bearer`、`oidc` 或 `trusted-header`;`enforced` 模式必须设置 | +| `POWERCONTEXT_SERVER_AUTH_TOKEN` | 未设置 | 静态 Bearer token;仅可与 `AUTH_PROVIDER=static-bearer` 一起使用 | +| `POWERCONTEXT_SERVER_AUTH_PRINCIPAL_ID` | `server-token` | 静态 token 所代表的部署内全局唯一 Principal ID | +| `POWERCONTEXT_SERVER_AUTH_PRINCIPAL_DESCRIPTION` | `PowerContext static bearer` | 静态 Principal 的可选展示描述,不参与身份判定 | +| `POWERCONTEXT_SERVER_ACCESS_MODE` | `disabled` | 唯一安全开关:`disabled` 或 `enforced` | +| `POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER` | 未设置 | Authorization Provider:`builtin`、`casbin` 或 `external`;`enforced` 模式必须设置 | +| `POWERCONTEXT_SERVER_ACCESS_STATIC_PRESET` | `true` | 为单 Principal 静态部署显式写入所需的内置 role | +| `POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID` | `powercontext` | `server` Access Resource 使用的稳定部署标识 | +| `POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_ID` | 未设置 | 多用户 enforced 部署中供定时任务使用的显式 service Principal | +| `POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION` | 未设置 | 定时 service Principal 的可选展示描述 | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | 在鉴权关闭时显式允许绑定非 loopback 地址 | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | 在 Server 根路径 `/` 启用 Dashboard | | `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | Dashboard 可选择的 scope JSON 数组 | @@ -86,23 +91,33 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | 未设置 | Experience 孵化间隔;未设置即不启用该 job | | `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | 未设置 | 包含 host identity 和显式 Agent Skill targets 的 JSON object | -静态 Bearer 鉴权默认关闭。启用后,API 和 MCP 请求必须携带 `Authorization: Bearer `;liveness 和 -readiness endpoint 仍然公开。明文 HTTP 仅在 loopback 地址(`localhost`、`::1` 及 `127.0.0.0/8` 网段内的任意 +Access Control 默认关闭。在 `enforced` 模式下,API 和 MCP 请求必须通过所选 Authentication Provider 建立 Principal; +liveness 和 readiness endpoint 仍然公开。内置 `static-bearer` Provider 接受 +`Authorization: Bearer `。明文 HTTP 仅在 loopback 地址(`localhost`、`::1` 及 `127.0.0.0/8` 网段内的任意 地址)上受信任。当 Server 绑定到非 loopback 地址且鉴权关闭时会拒绝启动;此时应启用鉴权、改回绑定 loopback,或在 TLS 由上游终止或网络本身受控的场景下, 显式设置 `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` 主动选择接受。通过网络暴露启用鉴权的 Server 前必须配置 TLS。 -Authentication 负责建立 Principal,Access Control 负责判断该 Principal 能做什么。内置静态 token 始终只代表一个 -部署本地 service Principal,因此不能区分用户 A 和用户 B。默认 `legacy-static-admin` 会把该 Principal 映射为初始 -Server 管理员,以保持单用户本地部署的兼容行为。`enforced` 使用同一个策略执行点和持久化 Binding/审计存储,供注入的 -多用户 authentication 与 Authorization Provider 使用。在已有其他管理员关系后,可设置 -`bootstrap_static_principal=false`。`disabled` 会跳过授权决策,只应作为可信网络边界内的显式兼容回退。 +`POWERCONTEXT_SERVER_ACCESS_MODE` 是唯一开关。`disabled` 会拒绝 Authentication/Authorization Provider 配置,并在 +可信本地边界内跳过授权决策。`enforced` 必须同时设置 `AUTH_PROVIDER` 和 `AUTHORIZATION_PROVIDER`,启用统一策略执行点, +并使用所选 Provider 的 Binding 与审计能力。 + +Authentication 负责建立 Principal,Access Control 负责判断该 Principal 能做什么。Principal ID 是部署内全局唯一且不复用 +的标识;`description` 只用于展示,不参与身份判定。内置静态 token 始终只代表一个 service Principal,因此不能区分 +用户 A 和用户 B。使用内置 Authorization Provider 时,`ACCESS_STATIC_PRESET=true` 会为这个 Principal 显式写入 Server +与各 scope 所需的 role。需要让不同用户或 group 获得不同权限时,应使用 `oidc` 或 `trusted-header`,并注入部署侧 +Authentication Provider 与合适的 Authorization Provider。 + +定时 Source 处理和 Experience 孵化使用固定静态 Principal,或 `ACCESS_BACKGROUND_PRINCIPAL_ID` 指定的 service Principal。 +该 Principal 必须在每个被处理的 scope 上拥有 `scope.contribute`;新 Memory Entry 和 Candidate 会保留它作为直接 owner 或 +`proposed_owner`。多用户 enforced 部署配置了 schedule 却未显式指定该 Principal 时,Server 会拒绝启动。 远程、多用户或共享 Dashboard 必须使用 `enforced`。此模式下,HTTP、MCP、Dashboard 数据路由和 metrics 共用同一个 Server PEP;Dashboard 配置的 scope 会在返回前按当前 Principal 的 `scope.read` 判定过滤。`/v1/access/me` 返回 -`server`/`scope`/`artifact` Resource Kind、Provider 的 batch/list/relationship 能力、Family profile,以及当前部署是否 -具备受双重授权保护的 managed Skill publication operation。 +`server`/`scope`/`artifact` Resource Kind、Provider 的 batch/list/relationship 能力与 Family profile。Managed Skill 的 +导出和安装不再引入单独的 Access action:接收者先获得逻辑 Skill identity 上的 `artifact.read`,再自行决定是否以及如何 +安装一个精确 Revision。 内置 Access schema 使用配置好的 SQLite、seekDB 或 OceanBase,但由 Server 独立持有,不进入 Runtime 领域。自定义部署 可以向 `create_server_app` 注入 `AccessControlService`。内置的可写外部 adapter `CasbinAuthorizationProvider` 使用 @@ -126,7 +141,7 @@ Python Client 和 CLI 对出站请求应用相同规则:配置的明文 `http: Dashboard 默认启用,并与 HTTP API、MCP 共用监听地址和端口。默认未配置 scope,页面会显示空状态;Dashboard 初始化失败只记录包含直接原因的 warning,不影响 Server 的 HTTP API、MCP 和健康检查启动。 -启用 Bearer 鉴权后,`/`、`/skills`、`/reviews`、`/handoff-reports` 的 HTML 外壳及其静态资源仍保持公开,以便 +在 `AUTH_PROVIDER=static-bearer` 且 `enforced` 时,`/`、`/skills`、`/reviews`、`/handoff-reports` 的 HTML 外壳及其静态资源仍保持公开,以便 浏览器渲染登录表单;数据请求仍受鉴权保护。在表单中输入 Server token 后,浏览器只把它保存在当前标签页的 session storage 中。如果连这些登录页也不能暴露,应同时关闭 Dashboard 和 Handoff Report。 diff --git a/docs/zh/docs/reference/http-api.md b/docs/zh/docs/reference/http-api.md index 7f8004c52..9ef701e0e 100644 --- a/docs/zh/docs/reference/http-api.md +++ b/docs/zh/docs/reference/http-api.md @@ -87,9 +87,9 @@ curl --fail \ "$POWERCONTEXT_URL/v1/memory/search" ``` -## 把一个精确 Handoff 授予接收者 +## 把一个逻辑 Handoff 授予接收者 -`scope_id` 本身从不授予权限。管理员通过创建 Binding,把一个精确的 committed Handoff 授予接收者已经认证的 +`scope_id` 本身从不授予权限。Handoff owner 或获授权的 delegator 通过创建 Binding,把一个逻辑 committed Handoff 授予接收者已经认证的 Principal: ```bash @@ -98,32 +98,33 @@ curl --fail \ --header 'Content-Type: application/json' \ --header "$POWERCONTEXT_AUTH_HEADER" \ --data '{ - "subject": {"type": "user", "issuer": "https://id.example", "id": "user-b"}, + "subject": {"type": "user", "id": "idp:user-b", "description": "用户 B"}, "resource": { "type": "artifact", "scope_id": "project:example", - "reference": {"family": "handoff", "artifact_id": "handoff-42", "revision": 3}, + "identity": {"family": "handoff", "artifact_id": "handoff-42"}, "selector": null }, "role": "handoff.receiver", - "idempotency_key": "handoff-42-r3-to-user-b" + "idempotency_key": "handoff-42-to-user-b" }' \ "$POWERCONTEXT_URL/v1/access/bindings/create" ``` -接收者只能读取证据并确认这个 Revision;除非另有 scope role,否则不能发现 latest Handoff、读取其他 Handoff, -也不能访问父 scope 的 Memory。用 `/v1/access/me` 确认部署建立的 Principal,用 `/v1/access/check` 检查一个决策, +接收者可以读取和确认这个 Handoff 的历史、当前及未来 Revision;除非另有 scope role,否则不能在 scope 范围发现 +latest Handoff、读取其他 Handoff,也不能访问父 scope 的 Memory。用 `/v1/access/me` 确认部署建立的 Principal,用 `/v1/access/check` 检查一个决策, 用 `/v1/access/resources/list` 非发现式地列出已经可见的资源。创建操作按授权者与幂等键保证幂等;撤销时必须提交 `binding_id` 和 `expected_version`。Server 管理员可通过 `/v1/access/audit/list` 查看关系变更与决策事件。 -Access wire contract 只使用 `server`、`scope` 和 `artifact` 三种 Resource Kind。Artifact 的 `reference` 必须指向精确 -Revision;Memory 还必须提供完整 `memory_entry` selector(`entry_id` 和 `entry_version_id`)。未知 Family、未实现 -Prompt lifecycle 的 `prompt`、不匹配的 selector/role 或 `latest` 都不会创建 Binding。`/v1/access/me` 会报告当前 mode、 -Provider 能力和每个 Artifact Family 的启用状态。 +Access wire contract 只使用 `server`、`scope` 和 `artifact` 三种 Resource Kind。Artifact Resource 使用逻辑 identity +`{family, artifact_id}`,刻意不包含 Revision;Memory 可使用仅含 `entry_id` 的 `memory_entry` selector 缩小授权单位。 +未知 Family、未实现 Prompt lifecycle 的 `prompt` 或不匹配的 selector/role 都不会创建 Binding。`/v1/access/me` 会报告 +当前 mode、Provider 能力和每个 Artifact Family 的启用状态。 -读取一个 managed Skill 与发布它是两项权限。`/v1/skills/publication-targets/list` 和 `/v1/skills/publish` 都要求同一个 -精确 Skill Revision 上的 `artifact.read` 与 `skill.publish`。请求只提交不透明的 `target_id`;公共响应和错误不返回 -host path、Agent home、credential 或 locator。详细 Dashboard publication status 另由 `server.observe` 保护。 +Managed Skill 的导出和安装不使用单独的分享权限。`/v1/skills/publication-targets/list` 和 `/v1/skills/publish` 都只要求 +逻辑 Skill identity 上的 `artifact.read`;接收者自行决定是否以及如何安装一个精确 Revision。请求只提交不透明的 +`target_id`;公共响应和错误不返回 host path、Agent home、credential 或 locator。详细 Dashboard publication status +另由 `server.observe` 保护。 内置静态 token 只代表一个本地管理员,无法表达不同的 A/B 用户。真正的多用户部署必须把每个调用者认证为不同的 Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策略执行点;MCP tool 可见不等于有权限。 @@ -138,7 +139,7 @@ Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策 | 工作连续性 | `/v1/work/*` | 创建 Work Contract、准备或确认 Handoff、记录 Outcome | | 底层 Handoff | `/v1/handoff/*` | activate、prepare、finalize、commit 或 continue Handoff | | Memory | `/v1/memory/*` | flush、remember、search、list、get、revise、retire 和查看变更 | -| Experience 与 Skill | `/v1/experience/*`、`/v1/skill/*`、`/v1/skills/*` | propose、generate、读取 Artifact Revision 和受控发布 managed Skill | +| Experience 与 Skill | `/v1/experience/*`、`/v1/skill/*`、`/v1/skills/*` | propose、generate、读取 Artifact Revision 和导出可读的 managed Skill | | 审核 | `/v1/artifact-candidates/*` | 列出、检查、修订、批准或拒绝 pending Candidate | | 外部 Skill | `/v1/external-skills/*` | 扫描已配置 target,解析或导入 package | | Handoff Report | `/v1/handoff-reports/*` | 管理 Project、Workstream、activity、report 和 workspace binding | diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index e6ac53a21..962f8e35d 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -461,11 +461,17 @@ const OPERATIONS = { location: "body", scope: false }, + reassign_handoff_receiver_binding: { + method: "POST", + path: "/v1/access/bindings/reassign-handoff-receiver", + location: "body", + scope: false + }, list_access_audit: { method: "POST", path: "/v1/access/audit/list", location: "body", - scope: true + scope: false } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index 7f60543da..8dfaac031 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -409,7 +409,7 @@ paths: tags: [handoff] summary: Commit an explicit Handoff milestone operationId: commit_handoff - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: commit_handoff_access} requestBody: required: true content: @@ -656,7 +656,7 @@ paths: summary: Revise an exact Memory entry description: Replace active entry content against an explicit current Memory Revision. operationId: revise_memory_entry - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: exact_memory_write_access} requestBody: required: true content: @@ -693,7 +693,7 @@ paths: summary: Retire an exact Memory entry description: Deactivate an entry against an explicit current Memory Revision without deleting history. operationId: retire_memory_entry - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: exact_memory_write_access} requestBody: required: true content: @@ -765,7 +765,7 @@ paths: summary: Propose Experience content description: Persist a pending Experience Candidate without creating an Artifact Revision. operationId: propose_experience - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: experience_candidate_write_access} requestBody: required: true content: @@ -800,7 +800,7 @@ paths: summary: Generate an Experience Candidate description: Use the configured model and caller-selected exact evidence; persist only a schema-valid pending Candidate. operationId: generate_experience - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: experience_candidate_write_access} requestBody: required: true content: @@ -870,7 +870,7 @@ paths: summary: Propose managed Skill content description: Persist a pending managed Skill Candidate without creating an Artifact Revision. operationId: propose_skill - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: skill_candidate_write_access} requestBody: required: true content: @@ -905,7 +905,7 @@ paths: summary: Generate a managed Skill Candidate description: Use the configured model with an explicit provenance shape; persist only a schema-valid pending Candidate. operationId: generate_skill - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: skill_candidate_write_access} requestBody: required: true content: @@ -973,9 +973,9 @@ paths: post: tags: [skill] summary: List safe publication targets for an exact managed Skill - description: Return only enabled opaque host-local targets after the exact Skill read and publish checks both allow. + description: Return only enabled opaque host-local targets after artifact.read allows the logical Skill identity. operationId: list_skill_publication_targets - x-powercontext-access: {resolver: publish_managed_skill_access} + x-powercontext-access: {resolver: exact_skill_access} requestBody: required: true content: @@ -1008,9 +1008,9 @@ paths: post: tags: [skill] summary: Publish an exact managed Skill to one configured target - description: Publish only after artifact.read and skill.publish both allow; target_id is resolved after authorization. + description: Publish an exact Revision only after artifact.read allows its logical Skill identity; target_id is resolved after authorization. operationId: publish_managed_skill - x-powercontext-access: {resolver: publish_managed_skill_access} + x-powercontext-access: {resolver: exact_skill_access} requestBody: required: true content: @@ -2157,12 +2157,41 @@ paths: $ref: "#/components/responses/InvalidRequest" "503": $ref: "#/components/responses/Unavailable" + /v1/access/bindings/reassign-handoff-receiver: + post: + tags: [access] + summary: Atomically reassign the single Handoff receiver + operationId: reassign_handoff_receiver_binding + x-powercontext-access: {action: access.self, resource: {type: server}} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReassignHandoffReceiverRequest" + responses: + "200": + description: The revoked previous receiver and active replacement Binding. + content: + application/json: + schema: + $ref: "#/components/schemas/HandoffReceiverReassignment" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" /v1/access/audit/list: post: tags: [access] summary: List data-minimized Access audit events operationId: list_access_audit - x-powercontext-access: {resolver: access_audit_access} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -2279,22 +2308,49 @@ components: AccessPrincipal: type: object additionalProperties: false - required: [type, issuer, id] + required: [type, id] properties: - type: {type: string, minLength: 1, maxLength: 64} - issuer: {type: string, minLength: 1, maxLength: 255} + type: {type: string, enum: [user, service]} id: {type: string, minLength: 1, maxLength: 255} + description: {type: string, minLength: 1, maxLength: 255, nullable: true} + AccessGroup: + type: object + additionalProperties: false + required: [type, id] + properties: + type: {type: string, enum: [group]} + id: {type: string, minLength: 1, maxLength: 255} + description: {type: string, minLength: 1, maxLength: 255, nullable: true} + AccessSubject: + oneOf: + - $ref: "#/components/schemas/AccessPrincipal" + - $ref: "#/components/schemas/AccessGroup" + discriminator: + propertyName: type + mapping: + user: "#/components/schemas/AccessPrincipal" + service: "#/components/schemas/AccessPrincipal" + group: "#/components/schemas/AccessGroup" AccessControlMode: type: string - enum: [legacy-static-admin, enforced] + enum: [disabled, enforced] AccessProviderCapabilities: type: object additionalProperties: false - required: [safe_resource_filtering, multi_requirement_check, relationship_management] + required: + - safe_resource_filtering + - multi_requirement_check + - relationship_management + - group_subjects + - multi_principal + - max_direct_resource_keys properties: safe_resource_filtering: {type: boolean} multi_requirement_check: {type: boolean} relationship_management: {type: boolean} + group_subjects: {type: boolean} + multi_principal: {type: boolean} + max_direct_resource_keys: {type: integer, minimum: 1, maximum: 10000} ArtifactFamilyAccessCapability: type: object additionalProperties: false @@ -2304,7 +2360,7 @@ components: enabled: {type: boolean} share_unit: type: string - enum: [revision, memory_entry] + enum: [artifact, memory_entry] actions: type: array items: @@ -2313,19 +2369,6 @@ components: type: array items: $ref: "#/components/schemas/AccessRole" - AccessOperationCapability: - type: object - additionalProperties: false - required: [enabled] - properties: - enabled: {type: boolean} - AccessOperationCapabilities: - type: object - additionalProperties: false - required: [skill_publication] - properties: - skill_publication: - $ref: "#/components/schemas/AccessOperationCapability" AccessMeResponse: type: object additionalProperties: false @@ -2335,7 +2378,6 @@ components: - resource_kinds - provider_capabilities - artifact_families - - operation_capabilities properties: principal: $ref: "#/components/schemas/AccessPrincipal" @@ -2351,8 +2393,6 @@ components: type: array items: $ref: "#/components/schemas/ArtifactFamilyAccessCapability" - operation_capabilities: - $ref: "#/components/schemas/AccessOperationCapabilities" AccessAction: type: string enum: @@ -2364,10 +2404,11 @@ components: - scope.delegate - scope.admin - artifact.read - - handoff.evidence.read + - artifact.write + - artifact.share + - handoff.evidence.inspect - handoff.acknowledge - prompt.use - - skill.publish AccessResourceType: type: string enum: [server, scope, artifact] @@ -2388,20 +2429,26 @@ components: MemoryEntryAccessSelector: type: object additionalProperties: false - required: [type, entry_id, entry_version_id] + required: [type, entry_id] properties: type: {type: string, enum: [memory_entry]} entry_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} - entry_version_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + AccessArtifactIdentity: + type: object + additionalProperties: false + required: [family, artifact_id] + properties: + family: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + artifact_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} ArtifactAccessResource: type: object additionalProperties: false - required: [type, scope_id, reference, selector] + required: [type, scope_id, identity] properties: type: {type: string, enum: [artifact]} scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} - reference: - $ref: "#/components/schemas/ArtifactReference" + identity: + $ref: "#/components/schemas/AccessArtifactIdentity" selector: allOf: - $ref: "#/components/schemas/MemoryEntryAccessSelector" @@ -2420,11 +2467,10 @@ components: AccessDecision: type: object additionalProperties: false - required: [allowed, reason_code, policy_revision] + required: [allowed, reason_code] properties: allowed: {type: boolean} reason_code: {type: string, minLength: 1, maxLength: 64} - policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} AccessCheckRequest: type: object additionalProperties: false @@ -2486,7 +2532,7 @@ components: - handoff.receiver - artifact.viewer - prompt.user - - skill.publisher + - artifact.owner - scope.viewer - scope.contributor - scope.reviewer @@ -2502,10 +2548,11 @@ components: allOf: - $ref: "#/components/schemas/AccessResourceType" nullable: true + family: {type: string, minLength: 1, maxLength: 128, nullable: true} AccessRoleDescriptor: type: object additionalProperties: false - required: [role, resource_type, actions, artifact_families] + required: [role, resource_type, actions, artifact_families, assignable_subject_types, system_managed] properties: role: $ref: "#/components/schemas/AccessRole" @@ -2518,6 +2565,10 @@ components: artifact_families: type: array items: {type: string, minLength: 1, maxLength: 128} + assignable_subject_types: + type: array + items: {type: string, enum: [user, service, group]} + system_managed: {type: boolean} AccessRolePage: type: object additionalProperties: false @@ -2552,7 +2603,7 @@ components: properties: binding_id: {type: string, minLength: 1, maxLength: 64} subject: - $ref: "#/components/schemas/AccessPrincipal" + $ref: "#/components/schemas/AccessSubject" resource: $ref: "#/components/schemas/AccessResource" role: @@ -2575,33 +2626,42 @@ components: ListAccessBindingsRequest: type: object additionalProperties: false + required: [management_resource] properties: + management_resource: + $ref: "#/components/schemas/AccessResource" subject: allOf: - - $ref: "#/components/schemas/AccessPrincipal" + - $ref: "#/components/schemas/AccessSubject" nullable: true - resource: + role: + allOf: + - $ref: "#/components/schemas/AccessRole" + nullable: true + state: allOf: - - $ref: "#/components/schemas/AccessResource" + - $ref: "#/components/schemas/AccessBindingState" nullable: true - include_revoked: {type: boolean, default: false} + cursor: {type: string, maxLength: 2048, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} AccessBindingPage: type: object additionalProperties: false - required: [items] + required: [items, next_cursor] properties: items: type: array maxItems: 500 items: $ref: "#/components/schemas/AccessBinding" + next_cursor: {type: string, maxLength: 2048, nullable: true} CreateAccessBindingRequest: type: object additionalProperties: false required: [subject, resource, role, idempotency_key] properties: subject: - $ref: "#/components/schemas/AccessPrincipal" + $ref: "#/components/schemas/AccessSubject" resource: $ref: "#/components/schemas/AccessResource" role: @@ -2612,17 +2672,65 @@ components: RevokeAccessBindingRequest: type: object additionalProperties: false - required: [binding_id, expected_version] + required: [binding_id, expected_version, idempotency_key] properties: binding_id: {type: string, minLength: 1, maxLength: 64} expected_version: {type: integer, minimum: 1} + idempotency_key: {type: string, minLength: 1, maxLength: 255} + ReassignHandoffReceiverRequest: + type: object + additionalProperties: false + required: [binding_id, expected_version, subject, idempotency_key] + properties: + binding_id: {type: string, minLength: 1, maxLength: 64} + expected_version: {type: integer, minimum: 1} + subject: + $ref: "#/components/schemas/AccessPrincipal" + expires_at: {type: string, format: date-time, nullable: true} + reason: {type: string, maxLength: 1024, nullable: true} + idempotency_key: {type: string, minLength: 1, maxLength: 255} + HandoffReceiverReassignment: + type: object + additionalProperties: false + required: [revoked_binding, created_binding] + properties: + revoked_binding: + $ref: "#/components/schemas/AccessBinding" + created_binding: + $ref: "#/components/schemas/AccessBinding" ListAccessAuditRequest: type: object additionalProperties: false + required: [resource] properties: - scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*', nullable: true} - after: {type: integer, minimum: 0, nullable: true} + resource: + oneOf: + - $ref: "#/components/schemas/ServerAccessResource" + - $ref: "#/components/schemas/ScopeAccessResource" + discriminator: + propertyName: type + action: + allOf: + - $ref: "#/components/schemas/AccessAction" + nullable: true + subject: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true + result: {type: string, enum: [allowed, denied], nullable: true} + time_range: + allOf: + - $ref: "#/components/schemas/AccessAuditTimeRange" + nullable: true + cursor: {type: string, maxLength: 2048, nullable: true} limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessAuditTimeRange: + type: object + additionalProperties: false + required: [start, end] + properties: + start: {type: string, format: date-time} + end: {type: string, format: date-time} AccessAuditEvent: type: object additionalProperties: false @@ -2639,9 +2747,12 @@ components: - allowed - reason_code - policy_revision + - matched_subject - binding_id - target - role + - expected_version + - result_version properties: cursor: {type: integer, minimum: 1} event_id: {type: string, minLength: 1, maxLength: 64} @@ -2657,16 +2768,22 @@ components: $ref: "#/components/schemas/AccessResource" allowed: {type: boolean} reason_code: {type: string, minLength: 1, maxLength: 64} - policy_revision: {type: string, maxLength: 64, nullable: true} + policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} + matched_subject: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true binding_id: {type: string, maxLength: 64, nullable: true} target: allOf: - - $ref: "#/components/schemas/AccessPrincipal" + - $ref: "#/components/schemas/AccessSubject" nullable: true role: allOf: - $ref: "#/components/schemas/AccessRole" nullable: true + expected_version: {type: integer, minimum: 1, nullable: true} + result_version: {type: integer, minimum: 1, nullable: true} AccessAuditPage: type: object additionalProperties: false @@ -2677,7 +2794,7 @@ components: maxItems: 500 items: $ref: "#/components/schemas/AccessAuditEvent" - next_cursor: {type: integer, minimum: 1, nullable: true} + next_cursor: {type: string, maxLength: 2048, nullable: true} ActivateHandoffRequest: type: object additionalProperties: false diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index b813ffd1d..8d32d3053 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -80,7 +80,8 @@ export const OPERATIONS = { list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: true }, + reassign_handoff_receiver_binding: { method: 'POST', path: '/v1/access/bindings/reassign-handoff-receiver', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/langgraph/examples/_local_server.py b/integrations/langgraph/examples/_local_server.py index 0875d320f..1134e5e1f 100644 --- a/integrations/langgraph/examples/_local_server.py +++ b/integrations/langgraph/examples/_local_server.py @@ -36,7 +36,7 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import InferenceConfig from powercontext.server.factory import create_server_app -from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings @contextmanager @@ -48,7 +48,12 @@ def local_powercontext_server(*, token: str | None = None) -> Iterator[str]: with TemporaryDirectory() as db_dir: settings = ServerSettings( - auth=BearerAuthConfig(enabled=token is not None, token=SecretStr(token or "")), + auth=AuthenticationConfig( + provider="static-bearer" if token is not None else None, + token=None if token is None else SecretStr(token), + ), + access=AccessControlConfig(mode="enforced" if token is not None else "disabled"), + authorization_provider="builtin" if token is not None else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{db_dir}/memory.db"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=False), diff --git a/integrations/openclaw/README.md b/integrations/openclaw/README.md index c5b14bcd8..9faafa6f5 100644 --- a/integrations/openclaw/README.md +++ b/integrations/openclaw/README.md @@ -80,7 +80,9 @@ memory must be shared across agents in the same project or isolated differently. Start an authenticated Server from a protected environment: ```bash -export POWERCONTEXT_SERVER_AUTH_ENABLED=true +export POWERCONTEXT_SERVER_ACCESS_MODE=enforced +export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index b813ffd1d..8d32d3053 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -80,7 +80,8 @@ export const OPERATIONS = { list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: true }, + reassign_handoff_receiver_binding: { method: 'POST', path: '/v1/access/bindings/reassign-handoff-receiver', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index b813ffd1d..8d32d3053 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -80,7 +80,8 @@ export const OPERATIONS = { list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: true }, + reassign_handoff_receiver_binding: { method: 'POST', path: '/v1/access/bindings/reassign-handoff-receiver', location: "body", scope: false }, + list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 7f60543da..8dfaac031 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -409,7 +409,7 @@ paths: tags: [handoff] summary: Commit an explicit Handoff milestone operationId: commit_handoff - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: commit_handoff_access} requestBody: required: true content: @@ -656,7 +656,7 @@ paths: summary: Revise an exact Memory entry description: Replace active entry content against an explicit current Memory Revision. operationId: revise_memory_entry - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: exact_memory_write_access} requestBody: required: true content: @@ -693,7 +693,7 @@ paths: summary: Retire an exact Memory entry description: Deactivate an entry against an explicit current Memory Revision without deleting history. operationId: retire_memory_entry - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: exact_memory_write_access} requestBody: required: true content: @@ -765,7 +765,7 @@ paths: summary: Propose Experience content description: Persist a pending Experience Candidate without creating an Artifact Revision. operationId: propose_experience - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: experience_candidate_write_access} requestBody: required: true content: @@ -800,7 +800,7 @@ paths: summary: Generate an Experience Candidate description: Use the configured model and caller-selected exact evidence; persist only a schema-valid pending Candidate. operationId: generate_experience - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: experience_candidate_write_access} requestBody: required: true content: @@ -870,7 +870,7 @@ paths: summary: Propose managed Skill content description: Persist a pending managed Skill Candidate without creating an Artifact Revision. operationId: propose_skill - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: skill_candidate_write_access} requestBody: required: true content: @@ -905,7 +905,7 @@ paths: summary: Generate a managed Skill Candidate description: Use the configured model with an explicit provenance shape; persist only a schema-valid pending Candidate. operationId: generate_skill - x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {resolver: skill_candidate_write_access} requestBody: required: true content: @@ -973,9 +973,9 @@ paths: post: tags: [skill] summary: List safe publication targets for an exact managed Skill - description: Return only enabled opaque host-local targets after the exact Skill read and publish checks both allow. + description: Return only enabled opaque host-local targets after artifact.read allows the logical Skill identity. operationId: list_skill_publication_targets - x-powercontext-access: {resolver: publish_managed_skill_access} + x-powercontext-access: {resolver: exact_skill_access} requestBody: required: true content: @@ -1008,9 +1008,9 @@ paths: post: tags: [skill] summary: Publish an exact managed Skill to one configured target - description: Publish only after artifact.read and skill.publish both allow; target_id is resolved after authorization. + description: Publish an exact Revision only after artifact.read allows its logical Skill identity; target_id is resolved after authorization. operationId: publish_managed_skill - x-powercontext-access: {resolver: publish_managed_skill_access} + x-powercontext-access: {resolver: exact_skill_access} requestBody: required: true content: @@ -2157,12 +2157,41 @@ paths: $ref: "#/components/responses/InvalidRequest" "503": $ref: "#/components/responses/Unavailable" + /v1/access/bindings/reassign-handoff-receiver: + post: + tags: [access] + summary: Atomically reassign the single Handoff receiver + operationId: reassign_handoff_receiver_binding + x-powercontext-access: {action: access.self, resource: {type: server}} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReassignHandoffReceiverRequest" + responses: + "200": + description: The revoked previous receiver and active replacement Binding. + content: + application/json: + schema: + $ref: "#/components/schemas/HandoffReceiverReassignment" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" /v1/access/audit/list: post: tags: [access] summary: List data-minimized Access audit events operationId: list_access_audit - x-powercontext-access: {resolver: access_audit_access} + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: @@ -2279,22 +2308,49 @@ components: AccessPrincipal: type: object additionalProperties: false - required: [type, issuer, id] + required: [type, id] properties: - type: {type: string, minLength: 1, maxLength: 64} - issuer: {type: string, minLength: 1, maxLength: 255} + type: {type: string, enum: [user, service]} id: {type: string, minLength: 1, maxLength: 255} + description: {type: string, minLength: 1, maxLength: 255, nullable: true} + AccessGroup: + type: object + additionalProperties: false + required: [type, id] + properties: + type: {type: string, enum: [group]} + id: {type: string, minLength: 1, maxLength: 255} + description: {type: string, minLength: 1, maxLength: 255, nullable: true} + AccessSubject: + oneOf: + - $ref: "#/components/schemas/AccessPrincipal" + - $ref: "#/components/schemas/AccessGroup" + discriminator: + propertyName: type + mapping: + user: "#/components/schemas/AccessPrincipal" + service: "#/components/schemas/AccessPrincipal" + group: "#/components/schemas/AccessGroup" AccessControlMode: type: string - enum: [legacy-static-admin, enforced] + enum: [disabled, enforced] AccessProviderCapabilities: type: object additionalProperties: false - required: [safe_resource_filtering, multi_requirement_check, relationship_management] + required: + - safe_resource_filtering + - multi_requirement_check + - relationship_management + - group_subjects + - multi_principal + - max_direct_resource_keys properties: safe_resource_filtering: {type: boolean} multi_requirement_check: {type: boolean} relationship_management: {type: boolean} + group_subjects: {type: boolean} + multi_principal: {type: boolean} + max_direct_resource_keys: {type: integer, minimum: 1, maximum: 10000} ArtifactFamilyAccessCapability: type: object additionalProperties: false @@ -2304,7 +2360,7 @@ components: enabled: {type: boolean} share_unit: type: string - enum: [revision, memory_entry] + enum: [artifact, memory_entry] actions: type: array items: @@ -2313,19 +2369,6 @@ components: type: array items: $ref: "#/components/schemas/AccessRole" - AccessOperationCapability: - type: object - additionalProperties: false - required: [enabled] - properties: - enabled: {type: boolean} - AccessOperationCapabilities: - type: object - additionalProperties: false - required: [skill_publication] - properties: - skill_publication: - $ref: "#/components/schemas/AccessOperationCapability" AccessMeResponse: type: object additionalProperties: false @@ -2335,7 +2378,6 @@ components: - resource_kinds - provider_capabilities - artifact_families - - operation_capabilities properties: principal: $ref: "#/components/schemas/AccessPrincipal" @@ -2351,8 +2393,6 @@ components: type: array items: $ref: "#/components/schemas/ArtifactFamilyAccessCapability" - operation_capabilities: - $ref: "#/components/schemas/AccessOperationCapabilities" AccessAction: type: string enum: @@ -2364,10 +2404,11 @@ components: - scope.delegate - scope.admin - artifact.read - - handoff.evidence.read + - artifact.write + - artifact.share + - handoff.evidence.inspect - handoff.acknowledge - prompt.use - - skill.publish AccessResourceType: type: string enum: [server, scope, artifact] @@ -2388,20 +2429,26 @@ components: MemoryEntryAccessSelector: type: object additionalProperties: false - required: [type, entry_id, entry_version_id] + required: [type, entry_id] properties: type: {type: string, enum: [memory_entry]} entry_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} - entry_version_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + AccessArtifactIdentity: + type: object + additionalProperties: false + required: [family, artifact_id] + properties: + family: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + artifact_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} ArtifactAccessResource: type: object additionalProperties: false - required: [type, scope_id, reference, selector] + required: [type, scope_id, identity] properties: type: {type: string, enum: [artifact]} scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} - reference: - $ref: "#/components/schemas/ArtifactReference" + identity: + $ref: "#/components/schemas/AccessArtifactIdentity" selector: allOf: - $ref: "#/components/schemas/MemoryEntryAccessSelector" @@ -2420,11 +2467,10 @@ components: AccessDecision: type: object additionalProperties: false - required: [allowed, reason_code, policy_revision] + required: [allowed, reason_code] properties: allowed: {type: boolean} reason_code: {type: string, minLength: 1, maxLength: 64} - policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} AccessCheckRequest: type: object additionalProperties: false @@ -2486,7 +2532,7 @@ components: - handoff.receiver - artifact.viewer - prompt.user - - skill.publisher + - artifact.owner - scope.viewer - scope.contributor - scope.reviewer @@ -2502,10 +2548,11 @@ components: allOf: - $ref: "#/components/schemas/AccessResourceType" nullable: true + family: {type: string, minLength: 1, maxLength: 128, nullable: true} AccessRoleDescriptor: type: object additionalProperties: false - required: [role, resource_type, actions, artifact_families] + required: [role, resource_type, actions, artifact_families, assignable_subject_types, system_managed] properties: role: $ref: "#/components/schemas/AccessRole" @@ -2518,6 +2565,10 @@ components: artifact_families: type: array items: {type: string, minLength: 1, maxLength: 128} + assignable_subject_types: + type: array + items: {type: string, enum: [user, service, group]} + system_managed: {type: boolean} AccessRolePage: type: object additionalProperties: false @@ -2552,7 +2603,7 @@ components: properties: binding_id: {type: string, minLength: 1, maxLength: 64} subject: - $ref: "#/components/schemas/AccessPrincipal" + $ref: "#/components/schemas/AccessSubject" resource: $ref: "#/components/schemas/AccessResource" role: @@ -2575,33 +2626,42 @@ components: ListAccessBindingsRequest: type: object additionalProperties: false + required: [management_resource] properties: + management_resource: + $ref: "#/components/schemas/AccessResource" subject: allOf: - - $ref: "#/components/schemas/AccessPrincipal" + - $ref: "#/components/schemas/AccessSubject" nullable: true - resource: + role: + allOf: + - $ref: "#/components/schemas/AccessRole" + nullable: true + state: allOf: - - $ref: "#/components/schemas/AccessResource" + - $ref: "#/components/schemas/AccessBindingState" nullable: true - include_revoked: {type: boolean, default: false} + cursor: {type: string, maxLength: 2048, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} AccessBindingPage: type: object additionalProperties: false - required: [items] + required: [items, next_cursor] properties: items: type: array maxItems: 500 items: $ref: "#/components/schemas/AccessBinding" + next_cursor: {type: string, maxLength: 2048, nullable: true} CreateAccessBindingRequest: type: object additionalProperties: false required: [subject, resource, role, idempotency_key] properties: subject: - $ref: "#/components/schemas/AccessPrincipal" + $ref: "#/components/schemas/AccessSubject" resource: $ref: "#/components/schemas/AccessResource" role: @@ -2612,17 +2672,65 @@ components: RevokeAccessBindingRequest: type: object additionalProperties: false - required: [binding_id, expected_version] + required: [binding_id, expected_version, idempotency_key] properties: binding_id: {type: string, minLength: 1, maxLength: 64} expected_version: {type: integer, minimum: 1} + idempotency_key: {type: string, minLength: 1, maxLength: 255} + ReassignHandoffReceiverRequest: + type: object + additionalProperties: false + required: [binding_id, expected_version, subject, idempotency_key] + properties: + binding_id: {type: string, minLength: 1, maxLength: 64} + expected_version: {type: integer, minimum: 1} + subject: + $ref: "#/components/schemas/AccessPrincipal" + expires_at: {type: string, format: date-time, nullable: true} + reason: {type: string, maxLength: 1024, nullable: true} + idempotency_key: {type: string, minLength: 1, maxLength: 255} + HandoffReceiverReassignment: + type: object + additionalProperties: false + required: [revoked_binding, created_binding] + properties: + revoked_binding: + $ref: "#/components/schemas/AccessBinding" + created_binding: + $ref: "#/components/schemas/AccessBinding" ListAccessAuditRequest: type: object additionalProperties: false + required: [resource] properties: - scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*', nullable: true} - after: {type: integer, minimum: 0, nullable: true} + resource: + oneOf: + - $ref: "#/components/schemas/ServerAccessResource" + - $ref: "#/components/schemas/ScopeAccessResource" + discriminator: + propertyName: type + action: + allOf: + - $ref: "#/components/schemas/AccessAction" + nullable: true + subject: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true + result: {type: string, enum: [allowed, denied], nullable: true} + time_range: + allOf: + - $ref: "#/components/schemas/AccessAuditTimeRange" + nullable: true + cursor: {type: string, maxLength: 2048, nullable: true} limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessAuditTimeRange: + type: object + additionalProperties: false + required: [start, end] + properties: + start: {type: string, format: date-time} + end: {type: string, format: date-time} AccessAuditEvent: type: object additionalProperties: false @@ -2639,9 +2747,12 @@ components: - allowed - reason_code - policy_revision + - matched_subject - binding_id - target - role + - expected_version + - result_version properties: cursor: {type: integer, minimum: 1} event_id: {type: string, minLength: 1, maxLength: 64} @@ -2657,16 +2768,22 @@ components: $ref: "#/components/schemas/AccessResource" allowed: {type: boolean} reason_code: {type: string, minLength: 1, maxLength: 64} - policy_revision: {type: string, maxLength: 64, nullable: true} + policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} + matched_subject: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true binding_id: {type: string, maxLength: 64, nullable: true} target: allOf: - - $ref: "#/components/schemas/AccessPrincipal" + - $ref: "#/components/schemas/AccessSubject" nullable: true role: allOf: - $ref: "#/components/schemas/AccessRole" nullable: true + expected_version: {type: integer, minimum: 1, nullable: true} + result_version: {type: integer, minimum: 1, nullable: true} AccessAuditPage: type: object additionalProperties: false @@ -2677,7 +2794,7 @@ components: maxItems: 500 items: $ref: "#/components/schemas/AccessAuditEvent" - next_cursor: {type: integer, minimum: 1, nullable: true} + next_cursor: {type: string, maxLength: 2048, nullable: true} ActivateHandoffRequest: type: object additionalProperties: false diff --git a/src/powercontext/builtin/artifacts/handoff/__init__.py b/src/powercontext/builtin/artifacts/handoff/__init__.py index a3ad2578a..605f7ae0e 100644 --- a/src/powercontext/builtin/artifacts/handoff/__init__.py +++ b/src/powercontext/builtin/artifacts/handoff/__init__.py @@ -74,7 +74,7 @@ HandoffEvidenceResolver, HandoffGenerationPipeline, ) -from powercontext.builtin.artifacts.handoff.service import HandoffService +from powercontext.builtin.artifacts.handoff.service import HandoffEvidenceAuthorizer, HandoffService __all__ = [ "DEFAULT_HANDOFF_MAX_BYTES", @@ -98,6 +98,7 @@ "HandoffDisposition", "HandoffDraft", "HandoffError", + "HandoffEvidenceAuthorizer", "HandoffEvidenceCheck", "HandoffEvidenceProjector", "HandoffEvidenceResolver", diff --git a/src/powercontext/builtin/artifacts/handoff/service.py b/src/powercontext/builtin/artifacts/handoff/service.py index 8c46dbbd1..599c3b43d 100644 --- a/src/powercontext/builtin/artifacts/handoff/service.py +++ b/src/powercontext/builtin/artifacts/handoff/service.py @@ -16,7 +16,7 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Awaitable, Callable, Iterable from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.handoff.errors import ( @@ -52,6 +52,8 @@ from powercontext.errors import RevisionConflictError from powercontext.sources import SourceRef +HandoffEvidenceAuthorizer = Callable[[HandoffCitation], Awaitable[bool]] + class HandoffService: """Generate, finalize, commit, inspect, and resolve one scope's Handoff lifecycle.""" @@ -145,6 +147,8 @@ async def continue_from( self, handoff: PreparedHandoff | ArtifactRef, /, + *, + evidence_authorizer: HandoffEvidenceAuthorizer | None = None, ) -> HandoffResolution: """Resolve Handoff content without treating historical claims as current truth.""" @@ -164,9 +168,14 @@ async def continue_from( selection=selection, selected_revision=selected_revision, current=current, + evidence_authorizer=evidence_authorizer, ) - async def continue_latest(self) -> HandoffResolution: + async def continue_latest( + self, + *, + evidence_authorizer: HandoffEvidenceAuthorizer | None = None, + ) -> HandoffResolution: """Resolve the latest milestone after the caller selects the current workstream.""" current = await self._backend.latest(self.artifact_id) @@ -181,6 +190,7 @@ async def continue_latest(self) -> HandoffResolution: selection="latest", selected_revision=current.as_ref(), current=current, + evidence_authorizer=evidence_authorizer, ) async def _resolve( @@ -190,6 +200,7 @@ async def _resolve( selection: HandoffResolutionSelection, selected_revision: ArtifactRef | None, current: Handoff | None, + evidence_authorizer: HandoffEvidenceAuthorizer | None, ) -> HandoffResolution: return HandoffResolution( status="resolved", @@ -198,7 +209,7 @@ async def _resolve( selection=selection, selected_revision=selected_revision, current_revision=None if current is None else current.as_ref(), - evidence_checks=await self._evidence_checks(content), + evidence_checks=await self._evidence_checks(content, evidence_authorizer=evidence_authorizer), ) @staticmethod @@ -248,12 +259,18 @@ def _validate_generated_draft(action: PrepareHandoff, draft: object) -> None: if content_bytes > action.max_bytes: raise InvalidHandoffGenerationError("budget") - async def _evidence_checks(self, content: HandoffContent) -> tuple[HandoffEvidenceCheck, ...]: + async def _evidence_checks( + self, + content: HandoffContent, + *, + evidence_authorizer: HandoffEvidenceAuthorizer | None, + ) -> tuple[HandoffEvidenceCheck, ...]: checks = [ await self._check_evidence( statement.citations, claim="state", state_index=index, + evidence_authorizer=evidence_authorizer, ) for index, statement in enumerate(content.state) ] @@ -262,6 +279,7 @@ async def _evidence_checks(self, content: HandoffContent) -> tuple[HandoffEviden await self._check_evidence( content.next_action.citations, claim="next_action", + evidence_authorizer=evidence_authorizer, ) ) return tuple(checks) @@ -272,9 +290,13 @@ async def _check_evidence( *, claim: HandoffClaim, state_index: int | None = None, + evidence_authorizer: HandoffEvidenceAuthorizer | None, ) -> HandoffEvidenceCheck: unavailable: list[HandoffCitation] = [] for citation in citations: + if evidence_authorizer is not None and not await evidence_authorizer(citation): + unavailable.append(citation) + continue try: await self._evidence_resolver.validate(citation) except HandoffEvidenceUnavailableError: diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 750b7d20e..d58238682 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -42,6 +42,7 @@ HandoffAudience, HandoffCitation, HandoffDraft, + HandoffEvidenceAuthorizer, HandoffOmission, HandoffResolution, HandoffService, @@ -199,6 +200,8 @@ StatisticsServiceFactory = Callable[[str], RelationalScopedStatistics] RecallTokenEstimator = Callable[[str, PreparedContextBuild], Awaitable[RecallTokenMeasurement | None]] Clock = Callable[[], datetime] +ScheduledSourceRunner = Callable[[str, "BuiltinRuntime"], Awaitable[MemoryFlushResult]] +ScheduledExperienceRunner = Callable[[str, "BuiltinRuntime"], Awaitable[ExperienceIncubationResult]] _MEMORY_SEARCH_ATTEMPTS = 3 @@ -595,13 +598,22 @@ async def continue_from( self, handoff: PreparedHandoff | ArtifactRef, /, + *, + evidence_authorizer: HandoffEvidenceAuthorizer | None = None, ) -> HandoffResolution: async with self._runtime._context(self.scope_id) as context: - return await context.artifacts.handoff.continue_from(handoff) + return await context.artifacts.handoff.continue_from( + handoff, + evidence_authorizer=evidence_authorizer, + ) - async def continue_latest(self) -> HandoffResolution: + async def continue_latest( + self, + *, + evidence_authorizer: HandoffEvidenceAuthorizer | None = None, + ) -> HandoffResolution: async with self._runtime._context(self.scope_id) as context: - return await context.artifacts.handoff.continue_latest() + return await context.artifacts.handoff.continue_latest(evidence_authorizer=evidence_authorizer) async def latest(self) -> Handoff | None: async with self._runtime._context(self.scope_id) as context: @@ -1092,7 +1104,12 @@ async def run(self) -> None: operation="process_source_window", ) as span: try: - result = await self._runtime.memory.for_scope(scope_id).flush() + runner = self._runtime._scheduled_source_runner + result = ( + await self._runtime.memory.for_scope(scope_id).flush() + if runner is None + else await runner(scope_id, self._runtime) + ) except asyncio.CancelledError: _log_scheduled_processing( "cancelled", @@ -1142,7 +1159,12 @@ async def run(self) -> None: operation="incubate_experience_candidates", ) as span: try: - result = await self._runtime.experience.for_scope(scope_id).incubate() + runner = self._runtime._scheduled_experience_runner + result = ( + await self._runtime.experience.for_scope(scope_id).incubate() + if runner is None + else await runner(scope_id, self._runtime) + ) except asyncio.CancelledError: _log_scheduled_processing( "cancelled", @@ -1230,6 +1252,8 @@ def __init__( readiness: RuntimeReadinessChecks | None = None, clock: Clock | None = None, tracing: RuntimeTracing | None = None, + scheduled_source_runner: ScheduledSourceRunner | None = None, + scheduled_experience_runner: ScheduledExperienceRunner | None = None, ) -> None: if source_window_limit < 1: raise _RuntimeConfigurationError("source_window_limit") @@ -1248,6 +1272,8 @@ def __init__( self._readiness = RuntimeReadinessChecks() if readiness is None else readiness self._clock = _utc_now if clock is None else clock self._tracing = tracing + self._scheduled_source_runner = scheduled_source_runner + self._scheduled_experience_runner = scheduled_experience_runner self.source_window_limit = source_window_limit self._scope_cache = ScopeCache( scope_cache_size, diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 3867a28e9..68da6939e 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -59,7 +59,7 @@ from powercontext.builtin.persistence.sqlite.profile import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import BUILTIN_TABLES from powercontext.builtin.runtime._scope_cache import ScopeCacheObserver -from powercontext.builtin.runtime.application import BuiltinRuntime +from powercontext.builtin.runtime.application import BuiltinRuntime, ScheduledExperienceRunner, ScheduledSourceRunner from powercontext.builtin.runtime.config import BuiltinConfig, ExternalSkillsConfig, InferenceConfig, RuntimeConfig from powercontext.builtin.runtime.models import MemorySearchMode, RuntimeCapabilities from powercontext.builtin.runtime.protocols import RuntimeTracing @@ -170,6 +170,8 @@ async def open_builtin_runtime( instrumentation: InstrumentationSettings | None = None, scope_cache_observer: ScopeCacheObserver | None = None, tracing: RuntimeTracing | None = None, + scheduled_source_runner: ScheduledSourceRunner | None = None, + scheduled_experience_runner: ScheduledExperienceRunner | None = None, ) -> AsyncIterator[BuiltinRuntime]: """Open the selected database, inference adapters, and built-in runtime.""" @@ -281,6 +283,8 @@ async def open_builtin_runtime( recall_token_estimator=contexts.estimate_recall_tokens, readiness=RuntimeReadinessChecks(readiness_probes), tracing=tracing, + scheduled_source_runner=scheduled_source_runner, + scheduled_experience_runner=scheduled_experience_runner, ) ) if config.handoff_report.enabled: diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index 166b84edb..5d6f3dd7e 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -165,7 +165,8 @@ class ApiProtocol: "POWERCONTEXT_SERVER_HTTP_PORT": "8000", "POWERCONTEXT_SERVER_MCP_ENABLED": "true", "POWERCONTEXT_SERVER_MCP_PATH": "/mcp", - "POWERCONTEXT_SERVER_AUTH_ENABLED": "false", + "POWERCONTEXT_SERVER_ACCESS_MODE": "disabled", + "POWERCONTEXT_SERVER_ACCESS_STATIC_PRESET": "true", "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "true", "POWERCONTEXT_SERVER_LOGGING_LEVEL": "INFO", "POWERCONTEXT_SERVER_LOGGING_FORMAT": "console", diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 964dd0f16..caea7bfc2 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -16,6 +16,7 @@ from powercontext.http._generated.models import ( AccessAction, + AccessArtifactIdentity, AccessAuditEvent, AccessAuditPage, AccessBinding, @@ -26,9 +27,8 @@ AccessCheckRequest, AccessControlMode, AccessDecision, + AccessGroup, AccessMeResponse, - AccessOperationCapabilities, - AccessOperationCapability, AccessPrincipal, AccessProviderCapabilities, AccessResource, @@ -37,6 +37,7 @@ AccessRole, AccessRoleDescriptor, AccessRolePage, + AccessSubject, AcknowledgeHandoffRequest, ActivateHandoffRequest, AgentKind, @@ -47,6 +48,7 @@ ArtifactFamilyAccessCapability, ArtifactInventoryStatistics, ArtifactReference, + AssignableSubjectType, AttachHandoffReportWorkspaceRequest, CandidateFamily, CandidateFamilyCount, @@ -107,6 +109,7 @@ HandoffMemoryCitation, HandoffOmission, HandoffReceiptStatus, + HandoffReceiverReassignment, HandoffReportActivity, HandoffReportActivityAgent, HandoffReportActivityPage, @@ -178,6 +181,7 @@ PurgeHandoffReportActivitiesResponse, ReadinessResponse, ReadinessStatus, + ReassignHandoffReceiverRequest, RecallTokenDay, RecallTokenStatistics, RecallTokenValue, @@ -234,6 +238,7 @@ __all__ = [ "AccessAction", + "AccessArtifactIdentity", "AccessAuditEvent", "AccessAuditPage", "AccessBinding", @@ -244,9 +249,8 @@ "AccessCheckRequest", "AccessControlMode", "AccessDecision", + "AccessGroup", "AccessMeResponse", - "AccessOperationCapabilities", - "AccessOperationCapability", "AccessPrincipal", "AccessProviderCapabilities", "AccessResource", @@ -255,6 +259,7 @@ "AccessRole", "AccessRoleDescriptor", "AccessRolePage", + "AccessSubject", "AcknowledgeHandoffRequest", "ActivateHandoffRequest", "AgentKind", @@ -265,6 +270,7 @@ "ArtifactFamilyAccessCapability", "ArtifactInventoryStatistics", "ArtifactReference", + "AssignableSubjectType", "AttachHandoffReportWorkspaceRequest", "CandidateFamily", "CandidateFamilyCount", @@ -325,6 +331,7 @@ "HandoffMemoryCitation", "HandoffOmission", "HandoffReceiptStatus", + "HandoffReceiverReassignment", "HandoffReportActivity", "HandoffReportActivityAgent", "HandoffReportActivityPage", @@ -396,6 +403,7 @@ "PurgeHandoffReportActivitiesResponse", "ReadinessResponse", "ReadinessStatus", + "ReassignHandoffReceiverRequest", "RecallTokenDay", "RecallTokenStatistics", "RecallTokenValue", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 75e9fe0c9..f8150070a 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -21,17 +21,39 @@ ) +class Type(StrEnum): + USER = "user" + SERVICE = "service" + + class AccessPrincipal(BaseModel): model_config = ConfigDict( extra="forbid", ) - type: Annotated[StrictStr, Field(max_length=64, min_length=1)] - issuer: Annotated[StrictStr, Field(max_length=255, min_length=1)] + type: Literal["user", "service"] + id: Annotated[StrictStr, Field(max_length=255, min_length=1)] + description: Annotated[StrictStr | None, Field(max_length=255, min_length=1)] = None + + +class Type1(StrEnum): + GROUP = "group" + + +class AccessGroup(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + type: Literal["group"] id: Annotated[StrictStr, Field(max_length=255, min_length=1)] + description: Annotated[StrictStr | None, Field(max_length=255, min_length=1)] = None + + +class AccessSubject(RootModel[AccessPrincipal | AccessGroup]): + root: Annotated[AccessPrincipal | AccessGroup, Field(discriminator="type")] class AccessControlMode(StrEnum): - LEGACY_STATIC_ADMIN = "legacy-static-admin" + DISABLED = "disabled" ENFORCED = "enforced" @@ -42,27 +64,16 @@ class AccessProviderCapabilities(BaseModel): safe_resource_filtering: StrictBool multi_requirement_check: StrictBool relationship_management: StrictBool + group_subjects: StrictBool + multi_principal: StrictBool + max_direct_resource_keys: Annotated[StrictInt, Field(ge=1, le=10000)] class ShareUnit(StrEnum): - REVISION = "revision" + ARTIFACT = "artifact" MEMORY_ENTRY = "memory_entry" -class AccessOperationCapability(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - enabled: StrictBool - - -class AccessOperationCapabilities(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - skill_publication: AccessOperationCapability - - class AccessAction(StrEnum): SERVER_OBSERVE = "server.observe" SERVER_ADMIN = "server.admin" @@ -72,10 +83,11 @@ class AccessAction(StrEnum): SCOPE_DELEGATE = "scope.delegate" SCOPE_ADMIN = "scope.admin" ARTIFACT_READ = "artifact.read" - HANDOFF_EVIDENCE_READ = "handoff.evidence.read" + ARTIFACT_WRITE = "artifact.write" + ARTIFACT_SHARE = "artifact.share" + HANDOFF_EVIDENCE_INSPECT = "handoff.evidence.inspect" HANDOFF_ACKNOWLEDGE = "handoff.acknowledge" PROMPT_USE = "prompt.use" - SKILL_PUBLISH = "skill.publish" class AccessResourceType(StrEnum): @@ -84,7 +96,7 @@ class AccessResourceType(StrEnum): ARTIFACT = "artifact" -class Type(StrEnum): +class Type2(StrEnum): SERVER = "server" @@ -96,7 +108,7 @@ class ServerAccessResource(BaseModel): deployment_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] -class Type1(StrEnum): +class Type3(StrEnum): SCOPE = "scope" @@ -108,7 +120,7 @@ class ScopeAccessResource(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] -class Type2(StrEnum): +class Type4(StrEnum): MEMORY_ENTRY = "memory_entry" @@ -116,22 +128,57 @@ class MemoryEntryAccessSelector(BaseModel): model_config = ConfigDict( extra="forbid", ) - type: Type2 + type: Type4 entry_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - entry_version_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] -class Type3(StrEnum): +class AccessArtifactIdentity(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + + +class Type5(StrEnum): ARTIFACT = "artifact" +class ArtifactAccessResource(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + type: Literal["artifact"] + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + identity: AccessArtifactIdentity + selector: MemoryEntryAccessSelector | None = None + + +class AccessResource(RootModel[ServerAccessResource | ScopeAccessResource | ArtifactAccessResource]): + root: Annotated[ServerAccessResource | ScopeAccessResource | ArtifactAccessResource, Field(discriminator="type")] + + class AccessDecision(BaseModel): model_config = ConfigDict( extra="forbid", ) allowed: StrictBool reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] - policy_revision: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] + + +class AccessCheckRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + action: AccessAction + resource: AccessResource + + +class AccessCheckBatchRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + checks: Annotated[list[AccessCheckRequest], Field(max_length=100, min_length=1)] class AccessCheckBatchResponse(BaseModel): @@ -152,12 +199,21 @@ class ListAccessResourcesRequest(BaseModel): limit: Annotated[StrictInt, Field(ge=1, le=500)] = 100 +class AccessResourcePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessResource], Field(max_length=500)] + total: Annotated[StrictInt, Field(ge=0)] + next_cursor: Annotated[StrictStr | None, Field(...)] + + class AccessRole(StrEnum): HANDOFF_VIEWER = "handoff.viewer" HANDOFF_RECEIVER = "handoff.receiver" ARTIFACT_VIEWER = "artifact.viewer" PROMPT_USER = "prompt.user" - SKILL_PUBLISHER = "skill.publisher" + ARTIFACT_OWNER = "artifact.owner" SCOPE_VIEWER = "scope.viewer" SCOPE_CONTRIBUTOR = "scope.contributor" SCOPE_REVIEWER = "scope.reviewer" @@ -172,12 +228,19 @@ class ListAccessRolesRequest(BaseModel): extra="forbid", ) resource_type: AccessResourceType | None = None + family: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] = None class ArtifactFamily(RootModel[StrictStr]): root: Annotated[StrictStr, Field(max_length=128, min_length=1)] +class AssignableSubjectType(StrEnum): + USER = "user" + SERVICE = "service" + GROUP = "group" + + class AccessRoleDescriptor(BaseModel): model_config = ConfigDict( extra="forbid", @@ -186,6 +249,8 @@ class AccessRoleDescriptor(BaseModel): resource_type: AccessResourceType actions: list[AccessAction] artifact_families: list[ArtifactFamily] + assignable_subject_types: list[AssignableSubjectType] + system_managed: StrictBool class AccessRolePage(BaseModel): @@ -200,21 +265,130 @@ class AccessBindingState(StrEnum): REVOKED = "revoked" +class AccessBinding(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + subject: AccessSubject + resource: AccessResource + role: AccessRole + granted_by: AccessPrincipal + reason: Annotated[StrictStr | None, Field(max_length=1024)] + created_at: AwareDatetime + expires_at: Annotated[AwareDatetime | None, Field(...)] + state: AccessBindingState + version: Annotated[StrictInt, Field(ge=1)] + policy_revision: Annotated[StrictStr, Field(max_length=64, min_length=1)] + idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] + revoked_at: Annotated[AwareDatetime | None, Field(...)] + revoked_by: Annotated[AccessPrincipal | None, Field(...)] + + +class ListAccessBindingsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + management_resource: AccessResource + subject: AccessSubject | None = None + role: AccessRole | None = None + state: AccessBindingState | None = None + cursor: Annotated[StrictStr | None, Field(max_length=2048)] = None + limit: Annotated[StrictInt, Field(ge=1, le=500)] = 100 + + +class AccessBindingPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessBinding], Field(max_length=500)] + next_cursor: Annotated[StrictStr | None, Field(max_length=2048)] + + +class CreateAccessBindingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + subject: AccessSubject + resource: AccessResource + role: AccessRole + idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] + reason: Annotated[StrictStr | None, Field(max_length=1024)] = None + expires_at: AwareDatetime | None = None + + class RevokeAccessBindingRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] expected_version: Annotated[StrictInt, Field(ge=1)] + idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] -class ListAccessAuditRequest(BaseModel): +class ReassignHandoffReceiverRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None - after: Annotated[StrictInt | None, Field(ge=0)] = None - limit: Annotated[StrictInt, Field(ge=1, le=500)] = 100 + binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + expected_version: Annotated[StrictInt, Field(ge=1)] + subject: AccessPrincipal + expires_at: AwareDatetime | None = None + reason: Annotated[StrictStr | None, Field(max_length=1024)] = None + idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] + + +class HandoffReceiverReassignment(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + revoked_binding: AccessBinding + created_binding: AccessBinding + + +class Result(StrEnum): + ALLOWED = "allowed" + DENIED = "denied" + + +class AccessAuditTimeRange(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + start: AwareDatetime + end: AwareDatetime + + +class AccessAuditEvent(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + cursor: Annotated[StrictInt, Field(ge=1)] + event_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + occurred_at: AwareDatetime + request_id: Annotated[StrictStr | None, Field(max_length=128)] + transport: Annotated[StrictStr, Field(max_length=16, min_length=1)] + operation: Annotated[StrictStr, Field(max_length=128, min_length=1)] + principal: AccessPrincipal + action: AccessAction + resource: AccessResource + allowed: StrictBool + reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] + policy_revision: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] + matched_subject: Annotated[AccessSubject | None, Field(...)] + binding_id: Annotated[StrictStr | None, Field(max_length=64)] + target: Annotated[AccessSubject | None, Field(...)] + role: Annotated[AccessRole | None, Field(...)] + expected_version: Annotated[StrictInt | None, Field(ge=1)] + result_version: Annotated[StrictInt | None, Field(ge=1)] + + +class AccessAuditPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[AccessAuditEvent], Field(max_length=500)] + next_cursor: Annotated[StrictStr | None, Field(max_length=2048)] class ArtifactReference(BaseModel): @@ -1143,122 +1317,19 @@ class AccessMeResponse(BaseModel): resource_kinds: list[AccessResourceType] provider_capabilities: AccessProviderCapabilities artifact_families: list[ArtifactFamilyAccessCapability] - operation_capabilities: AccessOperationCapabilities - -class ArtifactAccessResource(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - type: Literal["artifact"] - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - reference: ArtifactReference - selector: Annotated[MemoryEntryAccessSelector | None, Field(...)] - - -class AccessResource(RootModel[ServerAccessResource | ScopeAccessResource | ArtifactAccessResource]): - root: Annotated[ServerAccessResource | ScopeAccessResource | ArtifactAccessResource, Field(discriminator="type")] - - -class AccessCheckRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - action: AccessAction - resource: AccessResource - - -class AccessCheckBatchRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - checks: Annotated[list[AccessCheckRequest], Field(max_length=100, min_length=1)] - - -class AccessResourcePage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[AccessResource], Field(max_length=500)] - total: Annotated[StrictInt, Field(ge=0)] - next_cursor: Annotated[StrictStr | None, Field(...)] - -class AccessBinding(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] - subject: AccessPrincipal - resource: AccessResource - role: AccessRole - granted_by: AccessPrincipal - reason: Annotated[StrictStr | None, Field(max_length=1024)] - created_at: AwareDatetime - expires_at: Annotated[AwareDatetime | None, Field(...)] - state: AccessBindingState - version: Annotated[StrictInt, Field(ge=1)] - policy_revision: Annotated[StrictStr, Field(max_length=64, min_length=1)] - idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] - revoked_at: Annotated[AwareDatetime | None, Field(...)] - revoked_by: Annotated[AccessPrincipal | None, Field(...)] - - -class ListAccessBindingsRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - subject: AccessPrincipal | None = None - resource: AccessResource | None = None - include_revoked: StrictBool = False - - -class AccessBindingPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[AccessBinding], Field(max_length=500)] - - -class CreateAccessBindingRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - subject: AccessPrincipal - resource: AccessResource - role: AccessRole - idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] - reason: Annotated[StrictStr | None, Field(max_length=1024)] = None - expires_at: AwareDatetime | None = None - - -class AccessAuditEvent(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - cursor: Annotated[StrictInt, Field(ge=1)] - event_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] - occurred_at: AwareDatetime - request_id: Annotated[StrictStr | None, Field(max_length=128)] - transport: Annotated[StrictStr, Field(max_length=16, min_length=1)] - operation: Annotated[StrictStr, Field(max_length=128, min_length=1)] - principal: AccessPrincipal - action: AccessAction - resource: AccessResource - allowed: StrictBool - reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] - policy_revision: Annotated[StrictStr | None, Field(max_length=64)] - binding_id: Annotated[StrictStr | None, Field(max_length=64)] - target: Annotated[AccessPrincipal | None, Field(...)] - role: Annotated[AccessRole | None, Field(...)] - - -class AccessAuditPage(BaseModel): +class ListAccessAuditRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - items: Annotated[list[AccessAuditEvent], Field(max_length=500)] - next_cursor: Annotated[StrictInt | None, Field(ge=1)] + resource: Annotated[ServerAccessResource | ScopeAccessResource, Field(discriminator="type")] + action: AccessAction | None = None + subject: AccessSubject | None = None + result: Result | None = None + time_range: AccessAuditTimeRange | None = None + cursor: Annotated[StrictStr | None, Field(max_length=2048)] = None + limit: Annotated[StrictInt, Field(ge=1, le=500)] = 100 class Capabilities(BaseModel): diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index ec552abd7..83d67a9eb 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -53,6 +53,7 @@ HandoffActivation, HandoffCurrentWorkRequest, HandoffDraft, + HandoffReceiverReassignment, HandoffReportActivityPage, HandoffReportResponse, HandoffReportWorkspaceBinding, @@ -93,6 +94,7 @@ PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, + ReassignHandoffReceiverRequest, RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, RegisterHandoffReportWorkstreamRequest, @@ -477,9 +479,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" - ), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="commit_handoff_access"), ) CONTINUE_HANDOFF = Operation[ContinueHandoffRequest, HandoffResolution]( @@ -658,9 +658,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" - ), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="exact_memory_write_access"), ) RETIRE_MEMORY_ENTRY = Operation[RetireMemoryEntryRequest, MemoryMutationResponse]( @@ -686,9 +684,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" - ), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="exact_memory_write_access"), ) LIST_MEMORY_CHANGES = Operation[ListMemoryChangesRequest, ListMemoryChangesResponse]( @@ -739,7 +735,7 @@ class AccessRequirement(BaseModel): 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + action=None, resource=None, scope_id_field=None, resolver="experience_candidate_write_access" ), ) @@ -766,7 +762,7 @@ class AccessRequirement(BaseModel): 500: {"$ref": "#/components/responses/InternalError"}, }, access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + action=None, resource=None, scope_id_field=None, resolver="experience_candidate_write_access" ), ) @@ -817,9 +813,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" - ), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="skill_candidate_write_access"), ) GENERATE_SKILL = Operation[GenerateSkillRequest, GeneratedCandidateResponse]( @@ -844,9 +838,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" - ), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="skill_candidate_write_access"), ) GET_SKILL = Operation[GetSkillRequest, SkillArtifact]( @@ -896,7 +888,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="publish_managed_skill_access"), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="exact_skill_access"), ) PUBLISH_MANAGED_SKILL = Operation[PublishManagedSkillRequest, ManagedSkillPublication]( @@ -922,7 +914,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="publish_managed_skill_access"), + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="exact_skill_access"), ) SCAN_EXTERNAL_SKILLS = Operation[ScanExternalSkillsRequest, ScanExternalSkillsResponse]( @@ -1744,6 +1736,27 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), ) +REASSIGN_HANDOFF_RECEIVER_BINDING = Operation[ReassignHandoffReceiverRequest, HandoffReceiverReassignment]( + method="POST", + path="/v1/access/bindings/reassign-handoff-receiver", + operation_id="reassign_handoff_receiver_binding", + request_type=ReassignHandoffReceiverRequest, + request_location="body", + response_type=HandoffReceiverReassignment, + success_status=200, + summary="Atomically reassign the single Handoff receiver", + tags=("access",), + responses={ + 200: {"description": "The revoked previous receiver and active replacement Binding."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + LIST_ACCESS_AUDIT = Operation[ListAccessAuditRequest, AccessAuditPage]( method="POST", path="/v1/access/audit/list", @@ -1761,5 +1774,5 @@ class AccessRequirement(BaseModel): 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, }, - access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="access_audit_access"), + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), ) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index c90e692c4..04be84210 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -398,10 +398,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": {"type": "scope", "scope-id-from": "scope_id"}, - }, + "x-powercontext-access": {"resolver": "commit_handoff_access"}, } }, "/v1/handoff/continue": { @@ -615,10 +612,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": {"type": "scope", "scope-id-from": "scope_id"}, - }, + "x-powercontext-access": {"resolver": "exact_memory_write_access"}, } }, "/v1/memory/entries/retire": { @@ -651,10 +645,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": {"type": "scope", "scope-id-from": "scope_id"}, - }, + "x-powercontext-access": {"resolver": "exact_memory_write_access"}, } }, "/v1/memory/changes": { @@ -715,10 +706,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": {"type": "scope", "scope-id-from": "scope_id"}, - }, + "x-powercontext-access": {"resolver": "experience_candidate_write_access"}, } }, "/v1/experience/generate": { @@ -751,10 +739,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": {"type": "scope", "scope-id-from": "scope_id"}, - }, + "x-powercontext-access": {"resolver": "experience_candidate_write_access"}, } }, "/v1/experience/get": { @@ -808,10 +793,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": {"type": "scope", "scope-id-from": "scope_id"}, - }, + "x-powercontext-access": {"resolver": "skill_candidate_write_access"}, } }, "/v1/skill/generate": { @@ -841,10 +823,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": {"type": "scope", "scope-id-from": "scope_id"}, - }, + "x-powercontext-access": {"resolver": "skill_candidate_write_access"}, } }, "/v1/skill/get": { @@ -879,10 +858,10 @@ "summary": "List safe publication targets for an exact managed Skill", "description": "Return only enabled " "opaque host-local " - "targets after the " - "exact Skill read and " - "publish checks both " - "allow.", + "targets after " + "artifact.read allows " + "the logical Skill " + "identity.", "operationId": "list_skill_publication_targets", "requestBody": { "content": { @@ -909,16 +888,17 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"resolver": "publish_managed_skill_access"}, + "x-powercontext-access": {"resolver": "exact_skill_access"}, } }, "/v1/skills/publish": { "post": { "tags": ["skill"], "summary": "Publish an exact managed Skill to one configured target", - "description": "Publish only after artifact.read and " - "skill.publish both allow; target_id is " - "resolved after authorization.", + "description": "Publish an exact Revision only after " + "artifact.read allows its logical Skill " + "identity; target_id is resolved after " + "authorization.", "operationId": "publish_managed_skill", "requestBody": { "content": { @@ -942,7 +922,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"resolver": "publish_managed_skill_access"}, + "x-powercontext-access": {"resolver": "exact_skill_access"}, } }, "/v1/external-skills/scan": { @@ -1973,6 +1953,33 @@ "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, + "/v1/access/bindings/reassign-handoff-receiver": { + "post": { + "tags": ["access"], + "summary": "Atomically reassign the single Handoff receiver", + "operationId": "reassign_handoff_receiver_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ReassignHandoffReceiverRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The revoked previous receiver and active replacement Binding.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReceiverReassignment"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, + } + }, "/v1/access/audit/list": { "post": { "tags": ["access"], @@ -1994,7 +2001,7 @@ "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, }, - "x-powercontext-access": {"resolver": "access_audit_access"}, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, }, @@ -2002,30 +2009,64 @@ "schemas": { "AccessPrincipal": { "properties": { - "type": {"type": "string", "maxLength": 64, "minLength": 1}, - "issuer": {"type": "string", "maxLength": 255, "minLength": 1}, + "type": {"type": "string", "enum": ["user", "service"]}, + "id": {"type": "string", "maxLength": 255, "minLength": 1}, + "description": {"type": "string", "maxLength": 255, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["type", "id"], + }, + "AccessGroup": { + "properties": { + "type": {"type": "string", "enum": ["group"]}, "id": {"type": "string", "maxLength": 255, "minLength": 1}, + "description": {"type": "string", "maxLength": 255, "minLength": 1, "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["type", "issuer", "id"], + "required": ["type", "id"], + }, + "AccessSubject": { + "oneOf": [ + {"$ref": "#/components/schemas/AccessPrincipal"}, + {"$ref": "#/components/schemas/AccessGroup"}, + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "user": "#/components/schemas/AccessPrincipal", + "service": "#/components/schemas/AccessPrincipal", + "group": "#/components/schemas/AccessGroup", + }, + }, }, - "AccessControlMode": {"type": "string", "enum": ["legacy-static-admin", "enforced"]}, + "AccessControlMode": {"type": "string", "enum": ["disabled", "enforced"]}, "AccessProviderCapabilities": { "properties": { "safe_resource_filtering": {"type": "boolean"}, "multi_requirement_check": {"type": "boolean"}, "relationship_management": {"type": "boolean"}, + "group_subjects": {"type": "boolean"}, + "multi_principal": {"type": "boolean"}, + "max_direct_resource_keys": {"type": "integer", "maximum": 10000.0, "minimum": 1.0}, }, "additionalProperties": False, "type": "object", - "required": ["safe_resource_filtering", "multi_requirement_check", "relationship_management"], + "required": [ + "safe_resource_filtering", + "multi_requirement_check", + "relationship_management", + "group_subjects", + "multi_principal", + "max_direct_resource_keys", + ], }, "ArtifactFamilyAccessCapability": { "properties": { "family": {"type": "string", "maxLength": 128, "minLength": 1}, "enabled": {"type": "boolean"}, - "share_unit": {"type": "string", "enum": ["revision", "memory_entry"]}, + "share_unit": {"type": "string", "enum": ["artifact", "memory_entry"]}, "actions": {"items": {"$ref": "#/components/schemas/AccessAction"}, "type": "array"}, "grantable_roles": {"items": {"$ref": "#/components/schemas/AccessRole"}, "type": "array"}, }, @@ -2033,18 +2074,6 @@ "type": "object", "required": ["family", "enabled", "share_unit", "actions", "grantable_roles"], }, - "AccessOperationCapability": { - "properties": {"enabled": {"type": "boolean"}}, - "additionalProperties": False, - "type": "object", - "required": ["enabled"], - }, - "AccessOperationCapabilities": { - "properties": {"skill_publication": {"$ref": "#/components/schemas/AccessOperationCapability"}}, - "additionalProperties": False, - "type": "object", - "required": ["skill_publication"], - }, "AccessMeResponse": { "properties": { "principal": {"$ref": "#/components/schemas/AccessPrincipal"}, @@ -2055,18 +2084,10 @@ "items": {"$ref": "#/components/schemas/ArtifactFamilyAccessCapability"}, "type": "array", }, - "operation_capabilities": {"$ref": "#/components/schemas/AccessOperationCapabilities"}, }, "additionalProperties": False, "type": "object", - "required": [ - "principal", - "mode", - "resource_kinds", - "provider_capabilities", - "artifact_families", - "operation_capabilities", - ], + "required": ["principal", "mode", "resource_kinds", "provider_capabilities", "artifact_families"], }, "AccessAction": { "type": "string", @@ -2079,10 +2100,11 @@ "scope.delegate", "scope.admin", "artifact.read", - "handoff.evidence.read", + "artifact.write", + "artifact.share", + "handoff.evidence.inspect", "handoff.acknowledge", "prompt.use", - "skill.publish", ], }, "AccessResourceType": {"type": "string", "enum": ["server", "scope", "artifact"]}, @@ -2113,22 +2135,25 @@ "properties": { "type": {"type": "string", "enum": ["memory_entry"]}, "entry_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "entry_version_id": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "pattern": "^[\\x21-\\x7E]+$", - }, }, "additionalProperties": False, "type": "object", - "required": ["type", "entry_id", "entry_version_id"], + "required": ["type", "entry_id"], + }, + "AccessArtifactIdentity": { + "properties": { + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "artifact_id"], }, "ArtifactAccessResource": { "properties": { "type": {"type": "string", "enum": ["artifact"]}, "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "reference": {"$ref": "#/components/schemas/ArtifactReference"}, + "identity": {"$ref": "#/components/schemas/AccessArtifactIdentity"}, "selector": { "allOf": [{"$ref": "#/components/schemas/MemoryEntryAccessSelector"}], "nullable": True, @@ -2136,7 +2161,7 @@ }, "additionalProperties": False, "type": "object", - "required": ["type", "scope_id", "reference", "selector"], + "required": ["type", "scope_id", "identity"], }, "AccessResource": { "oneOf": [ @@ -2157,11 +2182,10 @@ "properties": { "allowed": {"type": "boolean"}, "reason_code": {"type": "string", "maxLength": 64, "minLength": 1}, - "policy_revision": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["allowed", "reason_code", "policy_revision"], + "required": ["allowed", "reason_code"], }, "AccessCheckRequest": { "properties": { @@ -2230,7 +2254,7 @@ "handoff.receiver", "artifact.viewer", "prompt.user", - "skill.publisher", + "artifact.owner", "scope.viewer", "scope.contributor", "scope.reviewer", @@ -2242,7 +2266,8 @@ }, "ListAccessRolesRequest": { "properties": { - "resource_type": {"allOf": [{"$ref": "#/components/schemas/AccessResourceType"}], "nullable": True} + "resource_type": {"allOf": [{"$ref": "#/components/schemas/AccessResourceType"}], "nullable": True}, + "family": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, }, "additionalProperties": False, "type": "object", @@ -2256,10 +2281,22 @@ "items": {"type": "string", "maxLength": 128, "minLength": 1}, "type": "array", }, + "assignable_subject_types": { + "items": {"type": "string", "enum": ["user", "service", "group"]}, + "type": "array", + }, + "system_managed": {"type": "boolean"}, }, "additionalProperties": False, "type": "object", - "required": ["role", "resource_type", "actions", "artifact_families"], + "required": [ + "role", + "resource_type", + "actions", + "artifact_families", + "assignable_subject_types", + "system_managed", + ], }, "AccessRolePage": { "properties": { @@ -2277,7 +2314,7 @@ "AccessBinding": { "properties": { "binding_id": {"type": "string", "maxLength": 64, "minLength": 1}, - "subject": {"$ref": "#/components/schemas/AccessPrincipal"}, + "subject": {"$ref": "#/components/schemas/AccessSubject"}, "resource": {"$ref": "#/components/schemas/AccessResource"}, "role": {"$ref": "#/components/schemas/AccessRole"}, "granted_by": {"$ref": "#/components/schemas/AccessPrincipal"}, @@ -2312,24 +2349,33 @@ }, "ListAccessBindingsRequest": { "properties": { - "subject": {"allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], "nullable": True}, - "resource": {"allOf": [{"$ref": "#/components/schemas/AccessResource"}], "nullable": True}, - "include_revoked": {"type": "boolean", "default": False}, + "management_resource": {"$ref": "#/components/schemas/AccessResource"}, + "subject": {"allOf": [{"$ref": "#/components/schemas/AccessSubject"}], "nullable": True}, + "role": {"allOf": [{"$ref": "#/components/schemas/AccessRole"}], "nullable": True}, + "state": {"allOf": [{"$ref": "#/components/schemas/AccessBindingState"}], "nullable": True}, + "cursor": {"type": "string", "maxLength": 2048, "nullable": True}, + "limit": {"type": "integer", "maximum": 500.0, "minimum": 1.0, "default": 100}, }, "additionalProperties": False, "type": "object", + "required": ["management_resource"], }, "AccessBindingPage": { "properties": { - "items": {"items": {"$ref": "#/components/schemas/AccessBinding"}, "type": "array", "maxItems": 500} + "items": { + "items": {"$ref": "#/components/schemas/AccessBinding"}, + "type": "array", + "maxItems": 500, + }, + "next_cursor": {"type": "string", "maxLength": 2048, "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["items"], + "required": ["items", "next_cursor"], }, "CreateAccessBindingRequest": { "properties": { - "subject": {"$ref": "#/components/schemas/AccessPrincipal"}, + "subject": {"$ref": "#/components/schemas/AccessSubject"}, "resource": {"$ref": "#/components/schemas/AccessResource"}, "role": {"$ref": "#/components/schemas/AccessRole"}, "idempotency_key": {"type": "string", "maxLength": 255, "minLength": 1}, @@ -2344,25 +2390,62 @@ "properties": { "binding_id": {"type": "string", "maxLength": 64, "minLength": 1}, "expected_version": {"type": "integer", "minimum": 1.0}, + "idempotency_key": {"type": "string", "maxLength": 255, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["binding_id", "expected_version", "idempotency_key"], + }, + "ReassignHandoffReceiverRequest": { + "properties": { + "binding_id": {"type": "string", "maxLength": 64, "minLength": 1}, + "expected_version": {"type": "integer", "minimum": 1.0}, + "subject": {"$ref": "#/components/schemas/AccessPrincipal"}, + "expires_at": {"type": "string", "format": "date-time", "nullable": True}, + "reason": {"type": "string", "maxLength": 1024, "nullable": True}, + "idempotency_key": {"type": "string", "maxLength": 255, "minLength": 1}, }, "additionalProperties": False, "type": "object", - "required": ["binding_id", "expected_version"], + "required": ["binding_id", "expected_version", "subject", "idempotency_key"], + }, + "HandoffReceiverReassignment": { + "properties": { + "revoked_binding": {"$ref": "#/components/schemas/AccessBinding"}, + "created_binding": {"$ref": "#/components/schemas/AccessBinding"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["revoked_binding", "created_binding"], }, "ListAccessAuditRequest": { "properties": { - "scope_id": { - "type": "string", - "maxLength": 256, - "minLength": 1, - "pattern": ".*\\S.*", - "nullable": True, - }, - "after": {"type": "integer", "minimum": 0.0, "nullable": True}, + "resource": { + "oneOf": [ + {"$ref": "#/components/schemas/ServerAccessResource"}, + {"$ref": "#/components/schemas/ScopeAccessResource"}, + ], + "discriminator": {"propertyName": "type"}, + }, + "action": {"allOf": [{"$ref": "#/components/schemas/AccessAction"}], "nullable": True}, + "subject": {"allOf": [{"$ref": "#/components/schemas/AccessSubject"}], "nullable": True}, + "result": {"type": "string", "enum": ["allowed", "denied"], "nullable": True}, + "time_range": {"allOf": [{"$ref": "#/components/schemas/AccessAuditTimeRange"}], "nullable": True}, + "cursor": {"type": "string", "maxLength": 2048, "nullable": True}, "limit": {"type": "integer", "maximum": 500.0, "minimum": 1.0, "default": 100}, }, "additionalProperties": False, "type": "object", + "required": ["resource"], + }, + "AccessAuditTimeRange": { + "properties": { + "start": {"type": "string", "format": "date-time"}, + "end": {"type": "string", "format": "date-time"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["start", "end"], }, "AccessAuditEvent": { "properties": { @@ -2377,10 +2460,13 @@ "resource": {"$ref": "#/components/schemas/AccessResource"}, "allowed": {"type": "boolean"}, "reason_code": {"type": "string", "maxLength": 64, "minLength": 1}, - "policy_revision": {"type": "string", "maxLength": 64, "nullable": True}, + "policy_revision": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, + "matched_subject": {"allOf": [{"$ref": "#/components/schemas/AccessSubject"}], "nullable": True}, "binding_id": {"type": "string", "maxLength": 64, "nullable": True}, - "target": {"allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], "nullable": True}, + "target": {"allOf": [{"$ref": "#/components/schemas/AccessSubject"}], "nullable": True}, "role": {"allOf": [{"$ref": "#/components/schemas/AccessRole"}], "nullable": True}, + "expected_version": {"type": "integer", "minimum": 1.0, "nullable": True}, + "result_version": {"type": "integer", "minimum": 1.0, "nullable": True}, }, "additionalProperties": False, "type": "object", @@ -2397,9 +2483,12 @@ "allowed", "reason_code", "policy_revision", + "matched_subject", "binding_id", "target", "role", + "expected_version", + "result_version", ], }, "AccessAuditPage": { @@ -2409,7 +2498,7 @@ "type": "array", "maxItems": 500, }, - "next_cursor": {"type": "integer", "minimum": 1.0, "nullable": True}, + "next_cursor": {"type": "string", "maxLength": 2048, "nullable": True}, }, "additionalProperties": False, "type": "object", diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 9ef067948..6d7d5eb43 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -41,13 +41,16 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.experience import Experience from powercontext.builtin.artifacts.handoff import ( + HandoffArtifactCitation, + HandoffCitation, HandoffEvidenceUnavailableError, HandoffGenerationUnavailableError, + HandoffMemoryCitation, HandoffScopeMismatchError, + HandoffSourceCitation, InvalidHandoffGenerationError, InvalidHandoffReferenceError, ) -from powercontext.builtin.artifacts.memory import MemoryCitation as RuntimeMemoryCitation from powercontext.builtin.artifacts.memory.errors import ( CapabilityNotSupportedError, InvalidMemoryCandidateError, @@ -111,6 +114,7 @@ CandidateTerminalError, InvalidCandidateError, ) +from powercontext.builtin.review import CandidateStatus as RuntimeCandidateStatus from powercontext.builtin.review.generation import ( GeneratedCandidateResult as RuntimeGeneratedCandidateResult, ) @@ -224,6 +228,9 @@ from powercontext.http import ( AccessAction as TransportAccessAction, ) +from powercontext.http import ( + AccessArtifactIdentity as TransportAccessArtifactIdentity, +) from powercontext.http import ( AccessAuditEvent as TransportAccessAuditEvent, ) @@ -234,8 +241,6 @@ AccessCheckBatchResponse, AccessCheckRequest, AccessMeResponse, - AccessOperationCapabilities, - AccessOperationCapability, AccessProviderCapabilities, AccessResourcePage, AccessRolePage, @@ -320,6 +325,7 @@ PurgeHandoffReportActivitiesResponse, ReadinessResponse, ReadinessStatus, + ReassignHandoffReceiverRequest, RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, RegisterHandoffReportWorkstreamRequest, @@ -355,6 +361,9 @@ from powercontext.http import ( AccessDecision as TransportAccessDecision, ) +from powercontext.http import ( + AccessGroup as TransportAccessGroup, +) from powercontext.http import ( AccessPrincipal as TransportAccessPrincipal, ) @@ -370,9 +379,15 @@ from powercontext.http import ( AccessRoleDescriptor as TransportAccessRoleDescriptor, ) +from powercontext.http import ( + AccessSubject as TransportAccessSubject, +) from powercontext.http import ( AgentKind as TransportAgentKind, ) +from powercontext.http import ( + AssignableSubjectType as TransportAssignableSubjectType, +) from powercontext.http import ( ExternalSkillInstallationScope as TransportExternalSkillInstallationScope, ) @@ -382,6 +397,9 @@ from powercontext.http import ( HandoffDraft as TransportHandoffDraft, ) +from powercontext.http import ( + HandoffReceiverReassignment as TransportHandoffReceiverReassignment, +) from powercontext.http import ( HandoffResolution as TransportHandoffResolution, ) @@ -394,9 +412,6 @@ from powercontext.http._generated.models import ( ArtifactFamily as TransportArtifactFamily, ) -from powercontext.http._generated.models import ( - ArtifactReference as TransportArtifactReference, -) from powercontext.http._generated.models import ( Capability as TransportSkillPublicationCapability, ) @@ -406,9 +421,7 @@ from powercontext.http._generated.models import ( State as TransportManagedSkillPublicationState, ) -from powercontext.http._generated.models import ( - Type2 as TransportMemoryEntrySelectorType, -) +from powercontext.http._generated.models import Type4 as TransportMemoryEntrySelectorType from powercontext.http._generated.operations import ( ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, @@ -464,6 +477,7 @@ PROPOSE_SKILL, PUBLISH_MANAGED_SKILL, PURGE_HANDOFF_REPORT_ACTIVITIES, + REASSIGN_HANDOFF_RECEIVER_BINDING, RECORD_HANDOFF_REPORT_ACTIVITY, RECORD_TASK_OUTCOME, REGISTER_HANDOFF_REPORT_WORKSTREAM, @@ -483,11 +497,14 @@ ) from powercontext.http._generated.schema import OPENAPI_SCHEMA from powercontext.server import mapping +from powercontext.server.authentication import AuthenticationProvider from powercontext.server.authz import ( AccessAction, AccessAuditContext, AccessAuditEvent, AccessBinding, + AccessBindingNotFoundError, + AccessBindingState, AccessConflictError, AccessControlError, AccessControlService, @@ -497,17 +514,24 @@ AccessInvalidRequestError, AccessResourceType, AccessRole, + AccessSubjectRef, AccessUnavailableError, + AuditSearchRequest, + AuthorizedResourceFilter, + BindingSearchRequest, CreateBinding, + GroupRef, MemoryEntrySelector, PrincipalRef, + ReassignHandoffReceiver, ResourceRef, access_control_for_mode, ) -from powercontext.server.authz.models import ROLE_ACTIONS, ROLE_RESOURCE_TYPES +from powercontext.server.authz.models import ROLE_ACTIONS, ROLE_RESOURCE_TYPES, ROLE_SUBJECT_TYPES from powercontext.server.authz.profiles import ARTIFACT_FAMILY_PROFILES, artifact_family_profile from powercontext.server.context import ( bind_request_id, + current_authentication, current_principal, current_request_id, is_internal_bridge, @@ -613,12 +637,24 @@ async def finalize(self, draft: HandoffDraft, /) -> PreparedHandoff: ... async def commit(self, prepared: PreparedHandoff, /) -> Handoff: ... - async def continue_from(self, handoff: PreparedHandoff | ArtifactRef, /) -> HandoffResolution: ... + async def continue_from( + self, + handoff: PreparedHandoff | ArtifactRef, + /, + *, + evidence_authorizer: Callable[[HandoffCitation], Awaitable[bool]] | None = None, + ) -> HandoffResolution: ... - async def continue_latest(self) -> HandoffResolution: ... + async def continue_latest( + self, + *, + evidence_authorizer: Callable[[HandoffCitation], Awaitable[bool]] | None = None, + ) -> HandoffResolution: ... async def revision(self, reference: ArtifactRef, /) -> Handoff: ... + async def revisions(self) -> tuple[Handoff, ...]: ... + class _HandoffApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedHandoffApplication: ... @@ -709,7 +745,8 @@ def create_app( tracing: ServerTracing | None = None, handoff_report_enabled: bool = False, access_control: AccessControlService | None = None, - access_mode: Literal["disabled", "legacy-static-admin", "enforced"] | None = None, + access_mode: Literal["disabled", "enforced"] | None = None, + authentication_provider: AuthenticationProvider | None = None, agent_skill_targets: Sequence[AgentSkillTarget] = (), ) -> FastAPI: """Build the HTTP adapter around an optional Runtime application binding.""" @@ -728,6 +765,7 @@ def create_app( app.state.capability_provider = capability_provider app.state.readiness_probe = readiness_probe app.state.access_control = access_control + app.state.authentication_provider = authentication_provider app.state.access_mode = ( ("disabled" if access_control is None else access_control.mode) if access_mode is None else access_mode ) @@ -798,6 +836,7 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, LIST_ACCESS_BINDINGS, list_access_bindings) _add_route(app, CREATE_ACCESS_BINDING, create_access_binding) _add_route(app, REVOKE_ACCESS_BINDING, revoke_access_binding) + _add_route(app, REASSIGN_HANDOFF_RECEIVER_BINDING, reassign_handoff_receiver_binding) _add_route(app, LIST_ACCESS_AUDIT, list_access_audit) if handoff_report_enabled: _add_route(app, CREATE_HANDOFF_REPORT_PROJECT, create_handoff_report_project) @@ -896,35 +935,42 @@ async def get_readiness(request: Request) -> JSONResponse: readiness = ( await readiness_probe() if readiness_probe is not None else _runtime_readiness(request.app.state.application) ) - checks = {**readiness.checks, **_access_readiness_checks(request)} + checks = {**readiness.checks, **await _access_readiness_checks(request)} response_status = status.HTTP_200_OK readiness_status = readiness.status - if readiness.status is ReadinessStatus.NOT_READY or checks["access_provider"] == "not_ready": + if readiness.status is ReadinessStatus.NOT_READY or any( + checks[name] == "not_ready" for name in ("authentication_provider", "access_provider") + ): readiness_status = ReadinessStatus.NOT_READY response_status = status.HTTP_503_SERVICE_UNAVAILABLE response = ReadinessResponse(status=readiness_status, checks=checks) return JSONResponse(content=response.model_dump(mode="json"), status_code=response_status) -def _access_readiness_checks(request: Request) -> dict[str, str]: +async def _access_readiness_checks(request: Request) -> dict[str, str]: mode: str = request.app.state.access_mode access: AccessControlService | None = request.app.state.access_control provider = "ready" if access is not None else ("not_ready" if mode == "enforced" else "disabled") - publication = ( - access is not None - and access.provider_capabilities.multi_requirement_check - and bool(request.app.state.agent_skill_targets) - ) + authentication: AuthenticationProvider | None = request.app.state.authentication_provider + if mode == "disabled": + authentication_status = "disabled" + elif authentication is None: + authentication_status = "not_ready" + else: + try: + authentication_status = "ready" if (await authentication.readiness()).ready else "not_ready" + except Exception: + authentication_status = "not_ready" family_capabilities = ",".join( f"{profile.family}:{'enabled' if profile.enabled else 'disabled'}" for profile in sorted(ARTIFACT_FAMILY_PROFILES.values(), key=lambda item: item.family) ) return { "access_mode": mode, + "authentication_provider": authentication_status, "access_provider": provider, "access_resource_kinds": ",".join(resource_type.value for resource_type in AccessResourceType), "access_artifact_families": family_capabilities, - "access_skill_publication": "enabled" if publication else "disabled", } @@ -946,6 +992,9 @@ async def get_access_principal(request: Request) -> AccessMeResponse: safe_resource_filtering=provider.safe_resource_filtering, multi_requirement_check=provider.multi_requirement_check, relationship_management=provider.relationship_management, + group_subjects=provider.group_subjects, + multi_principal=provider.multi_principal, + max_direct_resource_keys=provider.max_direct_resource_keys, ), artifact_families=[ ArtifactFamilyAccessCapability( @@ -957,11 +1006,6 @@ async def get_access_principal(request: Request) -> AccessMeResponse: ) for profile in ARTIFACT_FAMILY_PROFILES.values() ], - operation_capabilities=AccessOperationCapabilities( - skill_publication=AccessOperationCapability( - enabled=provider.multi_requirement_check and bool(request.app.state.agent_skill_targets) - ) - ), ) @@ -989,14 +1033,21 @@ async def check_access_batch(payload: AccessCheckBatchRequest, request: Request) async def list_access_resources(payload: ListAccessResourcesRequest, request: Request) -> AccessResourcePage: access = _require_access_control(request) + resource_type = AccessResourceType(payload.resource_type.value) page = await access.list_resources( _require_principal(), action=AccessAction(payload.action.value), - resource_type=AccessResourceType(payload.resource_type.value), + resource_type=resource_type, family=payload.family, cursor=payload.cursor, limit=payload.limit, context=_access_audit_context(LIST_ACCESS_RESOURCES.operation_id), + query_resources=lambda authorized: _query_authorized_resources( + request, + authorized, + resource_type=resource_type, + family=payload.family, + ), ) return AccessResourcePage( items=[_access_resource_response(resource) for resource in page.items], @@ -1005,16 +1056,120 @@ async def list_access_resources(payload: ListAccessResourcesRequest, request: Re ) +async def _query_authorized_resources( + request: Request, + authorized: AuthorizedResourceFilter, + *, + resource_type: AccessResourceType, + family: str | None, +) -> tuple[ResourceRef, ...]: + resources = {resource.key: resource for resource in authorized.exact_resources} + if not authorized.parent_constraints: + return tuple(resources.values()) + if resource_type is not AccessResourceType.ARTIFACT or any( + parent.type is not AccessResourceType.SCOPE for parent in authorized.parent_constraints + ): + raise AccessUnavailableError("safe_resource_filtering_unavailable") + + application = _require_application(request) + access = _require_access_control(request) + families = (family,) if family is not None else ("handoff", "memory", "experience", "skill") + + for parent in authorized.parent_constraints: + scope_id = parent.scope_id + if scope_id is None: + raise AccessUnavailableError("safe_resource_filtering_unavailable") + for selected_family in families: + discovered = await _discover_scope_artifact_resources(application, scope_id, selected_family) + for resource in discovered: + if await access.artifact_owner(resource) is not None: + resources[resource.key] = resource + if len(resources) > authorized.max_direct_resource_keys: + raise AccessUnavailableError("resource_filter_limit_exceeded") + return tuple(resources.values()) + + +async def _discover_scope_artifact_resources( + application: ServerApplication, + scope_id: str, + family: str, +) -> tuple[ResourceRef, ...]: + if family == "handoff": + if not await application.handoff.for_scope(scope_id).revisions(): + return () + return (ResourceRef.artifact(scope_id, family="handoff", artifact_id="handoff"),) + if family == "memory": + entries = await application.memory.for_scope(scope_id).list(include_inactive=True) + return tuple( + ResourceRef.artifact( + scope_id, + family="memory", + artifact_id=entry.citation.memory_ref.artifact_id, + selector=MemoryEntrySelector(entry_id=entry.citation.entry_id), + ) + for entry in entries.entries + ) + if family in {"experience", "skill"}: + return await _approved_artifact_resources( + application, + scope_id, + cast(Literal["experience", "skill"], family), + ) + raise AccessInvalidRequestError("artifact-family") + + +async def _approved_artifact_resources( + application: ServerApplication, + scope_id: str, + family: Literal["experience", "skill"], +) -> tuple[ResourceRef, ...]: + resources: list[ResourceRef] = [] + cursor: str | None = None + while True: + page = await application.review.for_scope(scope_id).list( + RuntimeListArtifactCandidatesRequest( + status=RuntimeCandidateStatus.APPROVED, + family=family, + cursor=cursor, + limit=100, + ) + ) + resources.extend( + ResourceRef.artifact(scope_id, family=artifact.family, artifact_id=artifact.artifact_id) + for candidate in page.candidates + if (artifact := candidate.result_artifact) is not None + ) + cursor = page.next_cursor + if cursor is None: + return tuple(resources) + + async def list_access_roles(payload: ListAccessRolesRequest, request: Request) -> AccessRolePage: _require_access_control(request) resource_type = None if payload.resource_type is None else AccessResourceType(payload.resource_type.value) + selected_profile = None + if payload.family is not None: + if resource_type not in {None, AccessResourceType.ARTIFACT}: + raise AccessInvalidRequestError("action-resource") + selected_profile = ARTIFACT_FAMILY_PROFILES.get(payload.family) + if selected_profile is None: + raise AccessInvalidRequestError("artifact-family") + if not selected_profile.enabled: + raise AccessInvalidRequestError("artifact-family-disabled") roles = [ role for role in AccessRole if (resource_type is None or ROLE_RESOURCE_TYPES[role] is resource_type) and ( ROLE_RESOURCE_TYPES[role] is not AccessResourceType.ARTIFACT - or any(profile.enabled and role in profile.grantable_roles for profile in ARTIFACT_FAMILY_PROFILES.values()) + or role is AccessRole.ARTIFACT_OWNER + or ( + role in selected_profile.grantable_roles + if selected_profile is not None + else any( + profile.enabled and role in profile.grantable_roles for profile in ARTIFACT_FAMILY_PROFILES.values() + ) + ) ) ] return AccessRolePage( @@ -1030,8 +1185,16 @@ async def list_access_roles(payload: ListAccessRolesRequest, request: Request) - artifact_families=[ TransportArtifactFamily(root=profile.family) for profile in ARTIFACT_FAMILY_PROFILES.values() - if profile.enabled and role in profile.grantable_roles + if profile.enabled + and (selected_profile is None or profile.family == selected_profile.family) + and (role is AccessRole.ARTIFACT_OWNER or role in profile.grantable_roles) + ], + assignable_subject_types=[ + TransportAssignableSubjectType(subject_type) + for subject_type in sorted(ROLE_SUBJECT_TYPES[role]) + if role is not AccessRole.ARTIFACT_OWNER ], + system_managed=role is AccessRole.ARTIFACT_OWNER, ) for role in roles ] @@ -1040,22 +1203,22 @@ async def list_access_roles(payload: ListAccessRolesRequest, request: Request) - async def list_access_bindings(payload: ListAccessBindingsRequest, request: Request) -> AccessBindingPage: access = _require_access_control(request) - principal = _require_principal() - resource = None if payload.resource is None else _access_resource(payload.resource) - action, boundary = _binding_administrative_check(resource, deployment_id=access.deployment_id) - await access.require( - principal, - action, - boundary, + page = await access.list_bindings( + _require_principal(), + BindingSearchRequest( + management_resource=_access_resource(payload.management_resource), + subject=None if payload.subject is None else _access_subject(payload.subject), + role=None if payload.role is None else AccessRole(payload.role.value), + state=None if payload.state is None else AccessBindingState(payload.state.value), + cursor=payload.cursor, + limit=payload.limit, + ), context=_access_audit_context(LIST_ACCESS_BINDINGS.operation_id), ) - subject = None if payload.subject is None else _access_principal(payload.subject) - bindings = await access.list_bindings( - subject=subject, - resource=resource, - include_revoked=payload.include_revoked, + return AccessBindingPage( + items=[_access_binding_response(binding) for binding in page.items], + next_cursor=page.next_cursor, ) - return AccessBindingPage(items=[_access_binding_response(binding) for binding in bindings]) async def create_access_binding(payload: CreateAccessBindingRequest, request: Request) -> TransportAccessBinding: @@ -1063,7 +1226,7 @@ async def create_access_binding(payload: CreateAccessBindingRequest, request: Re binding = await access.create_binding( _require_principal(), CreateBinding( - subject=_access_principal(payload.subject), + subject=_access_subject(payload.subject), resource=_access_resource(payload.resource), role=AccessRole(payload.role.value), idempotency_key=payload.idempotency_key, @@ -1082,19 +1245,59 @@ async def revoke_access_binding(payload: RevokeAccessBindingRequest, request: Re _require_principal(), payload.binding_id, expected_version=payload.expected_version, + idempotency_key=payload.idempotency_key, context=_access_audit_context(REVOKE_ACCESS_BINDING.operation_id), ) return _access_binding_response(binding) +async def reassign_handoff_receiver_binding( + payload: ReassignHandoffReceiverRequest, + request: Request, +) -> TransportHandoffReceiverReassignment: + access = _require_access_control(request) + result = await access.reassign_handoff_receiver( + _require_principal(), + ReassignHandoffReceiver( + binding_id=payload.binding_id, + expected_version=payload.expected_version, + subject=_access_principal(payload.subject), + idempotency_key=payload.idempotency_key, + reason=payload.reason, + expires_at=payload.expires_at, + ), + context=_access_audit_context(REASSIGN_HANDOFF_RECEIVER_BINDING.operation_id), + ) + return TransportHandoffReceiverReassignment( + revoked_binding=_access_binding_response(result.revoked_binding), + created_binding=_access_binding_response(result.created_binding), + ) + + async def list_access_audit(payload: ListAccessAuditRequest, request: Request) -> AccessAuditPage: access = _require_access_control(request) - resource = None if payload.scope_id is None else ResourceRef.scope(payload.scope_id) - events = await access.list_audit(resource=resource, after=payload.after, limit=payload.limit) - next_cursor = events[-1].cursor if len(events) == payload.limit else None + resource = ( + ResourceRef.server(payload.resource.deployment_id) + if isinstance(payload.resource, ServerAccessResource) + else ResourceRef.scope(payload.resource.scope_id) + ) + page = await access.list_audit( + _require_principal(), + AuditSearchRequest( + resource=resource, + action=None if payload.action is None else AccessAction(payload.action.value), + subject=None if payload.subject is None else _access_subject(payload.subject), + allowed=None if payload.result is None else payload.result.value == "allowed", + occurred_after=None if payload.time_range is None else payload.time_range.start, + occurred_before=None if payload.time_range is None else payload.time_range.end, + cursor=payload.cursor, + limit=payload.limit, + ), + context=_access_audit_context(LIST_ACCESS_AUDIT.operation_id), + ) return AccessAuditPage( - items=[_access_audit_response(event) for event in events], - next_cursor=next_cursor, + items=[_access_audit_response(event) for event in page.items], + next_cursor=page.next_cursor, ) @@ -1373,16 +1576,66 @@ async def capture_content_source( async def flush_memory( request: FlushMemoryRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> FlushMemoryResponse: - result = await application.memory.for_scope(request.scope_id).flush() + memory = application.memory.for_scope(request.scope_id) + access = access_control_for_mode( + http_request.app.state.access_control, + mode=http_request.app.state.access_mode, + ) + principal = _require_principal() if access is not None else None + if access is not None: + current = await memory.list(include_inactive=True) + await access.require_all( + principal, + tuple( + (AccessAction.ARTIFACT_WRITE, _memory_entry_resource(request.scope_id, entry)) + for entry in current.entries + ), + context=_access_audit_context(FLUSH_MEMORY.operation_id), + ) + result = await memory.flush() + if access is not None and result.memory_ref is not None: + current = await memory.list(include_inactive=True) + for entry in current.entries: + resource = _memory_entry_resource(request.scope_id, entry) + if await access.artifact_owner(resource) is None: + await access.establish_artifact_owner( + resource, + cast(PrincipalRef, principal), + idempotency_key=f"memory-owner:{request.scope_id}:{entry.entry.entry_id}", + context=_access_audit_context(FLUSH_MEMORY.operation_id), + ) return mapping.flush_response(result) +def _memory_entry_resource(scope_id: str, entry: MemoryEntryRecord) -> ResourceRef: + return ResourceRef.artifact( + scope_id, + family="memory", + artifact_id=entry.memory_ref.artifact_id, + selector=MemoryEntrySelector(entry_id=entry.entry.entry_id), + ) + + async def remember_memory( request: RememberMemoryRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> MemoryMutationResponse: result = await application.memory.for_scope(request.scope_id).remember(mapping.remember_request(request)) + if result.entry is not None: + await _establish_created_owner( + http_request, + ResourceRef.artifact( + request.scope_id, + family="memory", + artifact_id=result.memory_ref.artifact_id, + selector=MemoryEntrySelector(entry_id=result.entry.entry.entry_id), + ), + idempotency_key=f"memory-owner:{request.scope_id}:{result.entry.entry.entry_id}", + operation=REMEMBER_MEMORY.operation_id, + ) return mapping.mutation_response(result) @@ -1480,36 +1733,94 @@ async def finalize_handoff( async def commit_handoff( request: CommitHandoffRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> CommittedHandoff: result = await application.handoff.for_scope(request.scope_id).commit( mapping.runtime_prepared_handoff(request.handoff) ) + if result.revision == 1: + await _establish_created_owner( + http_request, + ResourceRef.artifact( + request.scope_id, + family="handoff", + artifact_id=result.artifact_id, + ), + idempotency_key=f"handoff-owner:{request.scope_id}:{result.artifact_id}", + operation=COMMIT_HANDOFF.operation_id, + ) return mapping.committed_handoff_response(result) async def continue_handoff( request: ContinueHandoffRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> TransportHandoffResolution: handoff = application.handoff.for_scope(request.scope_id) + evidence_authorizer = _handoff_evidence_authorizer(http_request, request.scope_id) if request.selection is HandoffSelection.LATEST: _require_handoff_selection(request, prepared=False, revision=False) - result = await handoff.continue_latest() + result = await handoff.continue_latest(evidence_authorizer=evidence_authorizer) elif request.selection is HandoffSelection.PREPARED: _require_handoff_selection(request, prepared=True, revision=False) prepared = request.prepared if prepared is None: raise InvalidRuntimeRequestError("handoff-selection") - result = await handoff.continue_from(mapping.runtime_prepared_handoff(prepared)) + result = await handoff.continue_from( + mapping.runtime_prepared_handoff(prepared), + evidence_authorizer=evidence_authorizer, + ) else: _require_handoff_selection(request, prepared=False, revision=True) revision = request.revision if revision is None: raise InvalidRuntimeRequestError("handoff-selection") - result = await handoff.continue_from(mapping.runtime_artifact_reference(revision)) + result = await handoff.continue_from( + mapping.runtime_artifact_reference(revision), + evidence_authorizer=evidence_authorizer, + ) return mapping.handoff_resolution_response(result) +def _handoff_evidence_authorizer( + request: Request, + scope_id: str, +) -> Callable[[HandoffCitation], Awaitable[bool]] | None: + access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) + if access is None: + return None + principal = _require_principal() + context = _access_audit_context(CONTINUE_HANDOFF.operation_id) + + async def authorize(citation: HandoffCitation) -> bool: + if isinstance(citation, HandoffSourceCitation): + action = AccessAction.SCOPE_READ + resource = ResourceRef.scope(scope_id) + elif isinstance(citation, HandoffArtifactCitation): + action = AccessAction.ARTIFACT_READ + resource = ResourceRef.artifact( + scope_id, + family=citation.artifact_ref.family, + artifact_id=citation.artifact_ref.artifact_id, + ) + elif isinstance(citation, HandoffMemoryCitation): + action = AccessAction.ARTIFACT_READ + memory = citation.memory_citation + resource = ResourceRef.artifact( + scope_id, + family=memory.memory_ref.family, + artifact_id=memory.memory_ref.artifact_id, + selector=MemoryEntrySelector(entry_id=memory.entry_id), + ) + else: + return False + decision = await access.check(principal, action, resource, context=context) + return decision.allowed + + return authorize + + async def list_memory_entries( request: ListMemoryEntriesRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -1555,20 +1866,37 @@ async def list_memory_changes( async def propose_experience( request: ProposeExperienceRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> ArtifactCandidate: result = await application.experience.for_scope(request.scope_id).propose( mapping.propose_experience_request(request) ) + await _attest_candidate_owner( + http_request, + scope_id=request.scope_id, + candidate_id=result.candidate_id, + family=result.family, + target=result.target, + ) return mapping.candidate_response(result) async def generate_experience( request: GenerateExperienceRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> GeneratedCandidateResponse: result = await application.experience.for_scope(request.scope_id).generate( mapping.generate_experience_request(request) ) + if result.candidate is not None: + await _attest_candidate_owner( + http_request, + scope_id=request.scope_id, + candidate_id=result.candidate.candidate_id, + family=result.candidate.family, + target=result.candidate.target, + ) return mapping.generated_candidate_response(result) @@ -1583,16 +1911,33 @@ async def get_experience( async def propose_skill( request: ProposeSkillRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> ArtifactCandidate: result = await application.skill.for_scope(request.scope_id).propose(mapping.propose_skill_request(request)) + await _attest_candidate_owner( + http_request, + scope_id=request.scope_id, + candidate_id=result.candidate_id, + family=result.family, + target=result.target, + ) return mapping.candidate_response(result) async def generate_skill( request: GenerateSkillRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> GeneratedCandidateResponse: result = await application.skill.for_scope(request.scope_id).generate(mapping.generate_skill_request(request)) + if result.candidate is not None: + await _attest_candidate_owner( + http_request, + scope_id=request.scope_id, + candidate_id=result.candidate.candidate_id, + family=result.candidate.family, + target=result.candidate.target, + ) return mapping.generated_candidate_response(result) @@ -1679,13 +2024,13 @@ async def _validate_shareable_resource(application: ServerApplication | None, re if application is None: raise _RuntimeNotReadyError profile = artifact_family_profile(resource) - reference = resource.reference - if reference is None or resource.scope_id is None: - raise AccessInvalidRequestError("artifact-reference") + identity = resource.identity + if identity is None or resource.scope_id is None: + raise AccessInvalidRequestError("artifact-identity") artifact = ArtifactRef( - family=reference.family, - artifact_id=reference.artifact_id, - revision=reference.revision, + family=identity.family, + artifact_id=identity.artifact_id, + revision=1, ) if profile.family == "handoff": await application.handoff.for_scope(resource.scope_id).revision(artifact) @@ -1694,17 +2039,13 @@ async def _validate_shareable_resource(application: ServerApplication | None, re selector = resource.selector if selector is None: raise AccessInvalidRequestError("memory-entry-selector") - entry = await application.memory.for_scope(resource.scope_id).get( - RuntimeGetMemoryEntryRequest( - citation=RuntimeMemoryCitation( - memory_ref=artifact, - entry_id=selector.entry_id, - entry_version_id=selector.entry_version_id, - ) - ) - ) - if entry.state != "active": - raise AccessInvalidRequestError("artifact-state") + page = await application.memory.for_scope(resource.scope_id).list(include_inactive=True) + if ( + page.memory_ref is None + or page.memory_ref.artifact_id != identity.artifact_id + or all(entry.entry.entry_id != selector.entry_id for entry in page.entries) + ): + raise MemoryEntryNotFoundError(selector.entry_id) return if profile.family == "experience": await application.experience.for_scope(resource.scope_id).get(RuntimeGetExperienceRequest(artifact=artifact)) @@ -1715,6 +2056,60 @@ async def _validate_shareable_resource(application: ServerApplication | None, re raise AccessInvalidRequestError("artifact-family-disabled") +async def _establish_created_owner( + request: Request, + resource: ResourceRef, + *, + idempotency_key: str, + operation: str, +) -> None: + access = access_control_for_mode( + request.app.state.access_control, + mode=request.app.state.access_mode, + ) + if access is None: + return + await access.establish_artifact_owner( + resource, + _require_principal(), + idempotency_key=idempotency_key, + context=_access_audit_context(operation), + ) + + +async def _attest_candidate_owner( + request: Request, + *, + scope_id: str, + candidate_id: str, + family: str, + target: ArtifactRef | None, +) -> None: + access = access_control_for_mode( + request.app.state.access_control, + mode=request.app.state.access_mode, + ) + if access is None: + return + logical_target = ( + None + if target is None + else ResourceRef.artifact( + scope_id, + family=target.family, + artifact_id=target.artifact_id, + ) + ) + await access.attest_candidate_owner( + scope_id=scope_id, + candidate_id=candidate_id, + family=family, + proposed_owner=_require_principal(), + target=logical_target, + idempotency_key=f"candidate-owner:{scope_id}:{candidate_id}", + ) + + async def scan_external_skills( request: ScanExternalSkillsRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -1746,10 +2141,19 @@ async def resolve_external_skill( async def import_external_skill( request: ImportExternalSkillRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> GeneratedCandidateResponse: result = await application.external_skills.for_scope(request.scope_id).import_managed( mapping.import_external_skill_request(request) ) + if result.candidate is not None: + await _attest_candidate_owner( + http_request, + scope_id=request.scope_id, + candidate_id=result.candidate.candidate_id, + family=result.candidate.family, + target=result.candidate.target, + ) return mapping.generated_candidate_response(result) @@ -1764,16 +2168,47 @@ async def list_artifact_candidates( async def get_artifact_candidate( request: GetArtifactCandidateRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> ArtifactCandidate: result = await application.review.for_scope(request.scope_id).get(mapping.get_candidate_request(request)) + await _require_candidate_artifact_owner(http_request, request.scope_id, result) return mapping.candidate_response(result) async def approve_artifact_candidate( request: ApproveArtifactCandidateRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> ArtifactCandidate: - result = await application.review.for_scope(request.scope_id).approve(mapping.approve_candidate_request(request)) + review = application.review.for_scope(request.scope_id) + access = access_control_for_mode( + http_request.app.state.access_control, + mode=http_request.app.state.access_mode, + ) + attestation = None if access is None else await access.candidate_owner(request.scope_id, request.candidate_id) + if access is not None and attestation is None: + raise AccessUnavailableError("artifact_owner_pending") + try: + result = await review.approve(mapping.approve_candidate_request(request)) + except CandidateTerminalError: + current = await review.get(RuntimeGetArtifactCandidateRequest(candidate_id=request.candidate_id)) + if current.status.value != "approved" or current.version != request.expected_version: + raise + result = current + if access is not None and attestation is not None and attestation.target is None: + artifact = result.result_artifact + if artifact is None: + raise AccessUnavailableError("artifact_owner_pending") + await access.establish_artifact_owner( + ResourceRef.artifact( + request.scope_id, + family=artifact.family, + artifact_id=artifact.artifact_id, + ), + attestation.proposed_owner, + idempotency_key=f"candidate-artifact-owner:{request.scope_id}:{request.candidate_id}", + context=_access_audit_context(APPROVE_ARTIFACT_CANDIDATE.operation_id), + ) return mapping.candidate_response(result) @@ -1788,11 +2223,40 @@ async def reject_artifact_candidate( async def revise_artifact_candidate( request: ReviseArtifactCandidateRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> ArtifactCandidate: + access = access_control_for_mode( + http_request.app.state.access_control, + mode=http_request.app.state.access_mode, + ) + if access is not None: + attestation = await access.candidate_owner(request.scope_id, request.candidate_id) + if attestation is None: + raise AccessUnavailableError("artifact_owner_pending") + if attestation.proposed_owner != _require_principal(): + raise AccessDeniedError result = await application.review.for_scope(request.scope_id).revise(mapping.revise_candidate_request(request)) return mapping.candidate_response(result) +async def _require_candidate_artifact_owner( + request: Request, + scope_id: str, + candidate: ReviewedCandidate, +) -> None: + if candidate.status.value != "approved" or candidate.result_artifact is None: + return + access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) + if access is None: + return + artifact = candidate.result_artifact + owner = await access.artifact_owner( + ResourceRef.artifact(scope_id, family=artifact.family, artifact_id=artifact.artifact_id) + ) + if owner is None: + raise AccessUnavailableError("artifact_owner_pending") + + def _require_handoff_selection( request: ContinueHandoffRequest, *, @@ -1847,19 +2311,43 @@ def _require_principal() -> PrincipalRef: def _access_audit_context(operation: str) -> AccessAuditContext: + authentication = current_authentication() return AccessAuditContext( transport="mcp" if is_internal_bridge() else "http", operation=operation, request_id=current_request_id(), + actor=None if authentication is None else authentication.actor, + subject_groups=() if authentication is None else authentication.subject_groups, ) def _access_principal(value: TransportAccessPrincipal) -> PrincipalRef: - return PrincipalRef(type=value.type, issuer=value.issuer, id=value.id) + # IDs are canonical. A description in an Access mutation payload is not a + # trusted directory assertion, so never persist it as identity metadata. + return PrincipalRef(type=value.type, id=value.id) + + +def _access_subject(value: TransportAccessSubject) -> AccessSubjectRef: + subject = value.root + if isinstance(subject, TransportAccessGroup): + return GroupRef(type=subject.type, id=subject.id) + return _access_principal(subject) def _access_principal_response(value: PrincipalRef) -> TransportAccessPrincipal: - return TransportAccessPrincipal(type=value.type, issuer=value.issuer, id=value.id) + return TransportAccessPrincipal( + type=cast(Literal["user", "service"], value.type), + id=value.id, + description=value.description, + ) + + +def _access_subject_response(value: AccessSubjectRef) -> TransportAccessSubject: + if isinstance(value, GroupRef): + return TransportAccessSubject( + root=TransportAccessGroup(type="group", id=value.id, description=value.description) + ) + return TransportAccessSubject(root=_access_principal_response(value)) def _access_resource(value: TransportAccessResource) -> ResourceRef: @@ -1873,14 +2361,12 @@ def _access_resource(value: TransportAccessResource) -> ResourceRef: if resource.selector is None else MemoryEntrySelector( entry_id=resource.selector.entry_id, - entry_version_id=resource.selector.entry_version_id, ) ) return ResourceRef.artifact( resource.scope_id, - family=resource.reference.family, - artifact_id=resource.reference.artifact_id, - revision=resource.reference.revision, + family=resource.identity.family, + artifact_id=resource.identity.artifact_id, selector=selector, ) @@ -1892,17 +2378,16 @@ def _access_resource_response(value: ResourceRef) -> TransportAccessResource: ) if value.type is AccessResourceType.SCOPE: return TransportAccessResource(root=ScopeAccessResource(type="scope", scope_id=value.scope_id or "")) - if value.reference is None: + if value.identity is None: raise AccessUnavailableError selector = value.selector return TransportAccessResource( root=ArtifactAccessResource( type="artifact", scope_id=value.scope_id or "", - reference=TransportArtifactReference( - family=value.reference.family, - artifact_id=value.reference.artifact_id, - revision=value.reference.revision, + identity=TransportAccessArtifactIdentity( + family=value.identity.family, + artifact_id=value.identity.artifact_id, ), selector=( None @@ -1910,7 +2395,6 @@ def _access_resource_response(value: ResourceRef) -> TransportAccessResource: else MemoryEntryAccessSelector( type=TransportMemoryEntrySelectorType.MEMORY_ENTRY, entry_id=selector.entry_id, - entry_version_id=selector.entry_version_id, ) ), ) @@ -1921,14 +2405,13 @@ def _access_decision_response(value: AccessDecision) -> TransportAccessDecision: return TransportAccessDecision( allowed=value.allowed, reason_code=value.reason_code, - policy_revision=value.policy_revision, ) def _access_binding_response(value: AccessBinding) -> TransportAccessBinding: return TransportAccessBinding( binding_id=value.binding_id, - subject=_access_principal_response(value.subject), + subject=_access_subject_response(value.subject), resource=_access_resource_response(value.resource), role=TransportAccessRole(value.role.value), granted_by=_access_principal_response(value.granted_by), @@ -1960,9 +2443,12 @@ def _access_audit_response(value: AccessAuditEvent) -> TransportAccessAuditEvent allowed=value.allowed, reason_code=value.reason_code, policy_revision=value.policy_revision, + matched_subject=(None if value.matched_subject is None else _access_subject_response(value.matched_subject)), binding_id=value.binding_id, - target=None if value.target is None else _access_principal_response(value.target), + target=None if value.target is None else _access_subject_response(value.target), role=None if value.role is None else TransportAccessRole(value.role.value), + expected_version=value.expected_version, + result_version=value.result_version, ) @@ -2032,6 +2518,8 @@ async def authorize(request: Request) -> None: payload = await _authorization_payload(request, operation) checks = _resolve_access_requirements(requirement, payload, deployment_id=access.deployment_id) context = _access_audit_context(operation.operation_id) + for scope_id in sorted({resource.scope_id for _, resource in checks if resource.scope_id is not None}): + await access.bootstrap_static_scope(current_principal(), scope_id, context=context) if len(checks) == 1: action, resource = checks[0] await access.require(current_principal(), action, resource, context=context) @@ -2086,12 +2574,16 @@ def _resolve_access_requirements( def _continue_handoff_access(payload: Mapping[str, Any]) -> tuple[tuple[AccessAction, ResourceRef], ...]: scope_id = _nested_request_value(payload, "scope_id") selection = str(_nested_request_value(payload, "selection")) - if selection != "exact": + if selection == "prepared": return ((AccessAction.SCOPE_READ, ResourceRef.scope(scope_id)),) - resource = _artifact_resource(payload, "revision", family="handoff") + resource = ( + _artifact_resource(payload, "revision", family="handoff") + if selection == "exact" + else ResourceRef.artifact(scope_id, family="handoff", artifact_id="handoff") + ) return ( (AccessAction.ARTIFACT_READ, resource), - (AccessAction.HANDOFF_EVIDENCE_READ, resource), + (AccessAction.HANDOFF_EVIDENCE_INSPECT, resource), ) @@ -2111,7 +2603,6 @@ def _artifact_resource(payload: Mapping[str, Any], field: str, *, family: str) - _nested_request_value(payload, "scope_id"), family=family, artifact_id=_mapping_text(reference, "artifact_id"), - revision=_mapping_revision(reference), ) @@ -2126,10 +2617,8 @@ def _memory_artifact_resource(payload: Mapping[str, Any]) -> ResourceRef: _nested_request_value(payload, "scope_id"), family="memory", artifact_id=_mapping_text(reference, "artifact_id"), - revision=_mapping_revision(reference), selector=MemoryEntrySelector( entry_id=_mapping_text(citation, "entry_id"), - entry_version_id=_mapping_text(citation, "entry_version_id"), ), ) @@ -2141,38 +2630,81 @@ def _exact_memory_access( return ((AccessAction.ARTIFACT_READ, _memory_artifact_resource(payload)),) -def _exact_experience_access( +def _exact_memory_write_access( payload: Mapping[str, Any], _deployment_id: str, ) -> tuple[tuple[AccessAction, ResourceRef], ...]: - return ((AccessAction.ARTIFACT_READ, _artifact_resource(payload, "artifact", family="experience")),) + return ((AccessAction.ARTIFACT_WRITE, _memory_artifact_resource(payload)),) -def _exact_skill_access( +def _commit_handoff_access( payload: Mapping[str, Any], _deployment_id: str, ) -> tuple[tuple[AccessAction, ResourceRef], ...]: - return ((AccessAction.ARTIFACT_READ, _artifact_resource(payload, "artifact", family="skill")),) + scope_id = _nested_request_value(payload, "scope_id") + checks = [(AccessAction.SCOPE_CONTRIBUTE, ResourceRef.scope(scope_id))] + handoff = payload.get("handoff") + base = handoff.get("base") if isinstance(handoff, Mapping) else None + if isinstance(base, Mapping): + checks.append(( + AccessAction.ARTIFACT_WRITE, + ResourceRef.artifact( + scope_id, + family="handoff", + artifact_id=_mapping_text(base, "artifact_id"), + ), + )) + return tuple(checks) -def _publish_managed_skill_access( +def _candidate_write_access( + payload: Mapping[str, Any], + *, + family: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + scope_id = _nested_request_value(payload, "scope_id") + checks = [(AccessAction.SCOPE_CONTRIBUTE, ResourceRef.scope(scope_id))] + target = payload.get("target") + if isinstance(target, Mapping): + if _mapping_text(target, "family") != family: + raise AccessInvalidRequestError("artifact-family") + checks.append(( + AccessAction.ARTIFACT_WRITE, + ResourceRef.artifact( + scope_id, + family=family, + artifact_id=_mapping_text(target, "artifact_id"), + ), + )) + return tuple(checks) + + +def _experience_candidate_write_access( payload: Mapping[str, Any], _deployment_id: str, ) -> tuple[tuple[AccessAction, ResourceRef], ...]: - resource = _artifact_resource(payload, "artifact", family="skill") - return ((AccessAction.ARTIFACT_READ, resource), (AccessAction.SKILL_PUBLISH, resource)) + return _candidate_write_access(payload, family="experience") -def _access_audit_access( +def _skill_candidate_write_access( payload: Mapping[str, Any], - deployment_id: str, + _deployment_id: str, ) -> tuple[tuple[AccessAction, ResourceRef], ...]: - scope_id = payload.get("scope_id") - if scope_id is None: - return ((AccessAction.SERVER_ADMIN, ResourceRef.server(deployment_id)),) - if not isinstance(scope_id, str) or not scope_id: - raise AccessInvalidRequestError("resource") - return ((AccessAction.SCOPE_ADMIN, ResourceRef.scope(scope_id)),) + return _candidate_write_access(payload, family="skill") + + +def _exact_experience_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return ((AccessAction.ARTIFACT_READ, _artifact_resource(payload, "artifact", family="experience")),) + + +def _exact_skill_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return ((AccessAction.ARTIFACT_READ, _artifact_resource(payload, "artifact", family="skill")),) def _continue_handoff_resolver( @@ -2193,13 +2725,15 @@ def _acknowledge_handoff_resolver( str, Callable[[Mapping[str, Any], str], tuple[tuple[AccessAction, ResourceRef], ...]], ] = { - "access_audit_access": _access_audit_access, "acknowledge_handoff_access": _acknowledge_handoff_resolver, "continue_handoff_access": _continue_handoff_resolver, + "commit_handoff_access": _commit_handoff_access, + "exact_memory_write_access": _exact_memory_write_access, + "experience_candidate_write_access": _experience_candidate_write_access, "exact_experience_access": _exact_experience_access, "exact_memory_access": _exact_memory_access, "exact_skill_access": _exact_skill_access, - "publish_managed_skill_access": _publish_managed_skill_access, + "skill_candidate_write_access": _skill_candidate_write_access, } @@ -2447,6 +2981,8 @@ def _map_access_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | return status.HTTP_401_UNAUTHORIZED, "unauthorized", "An authenticated Principal is required.", None if isinstance(error, AccessDeniedError): return status.HTTP_403_FORBIDDEN, "forbidden", "The Principal is not authorized for this operation.", None + if isinstance(error, AccessBindingNotFoundError): + return status.HTTP_404_NOT_FOUND, "access_binding_not_found", "The Access Binding was not found.", None if isinstance(error, AccessConflictError): return status.HTTP_409_CONFLICT, error.code, "The Access Binding conflicts with current state.", None if isinstance(error, AccessInvalidRequestError): diff --git a/src/powercontext/server/authentication.py b/src/powercontext/server/authentication.py new file mode 100644 index 000000000..b8724c13b --- /dev/null +++ b/src/powercontext/server/authentication.py @@ -0,0 +1,129 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Authentication Provider SPI kept outside Runtime domain models.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from secrets import compare_digest +from typing import Protocol + +from powercontext.server.authz import GroupRef, PrincipalRef + +_MAX_AUTHENTICATED_GROUPS = 100 + + +@dataclass(frozen=True, slots=True) +class AuthenticationRequest: + """Transport credential carrier supplied only to an Authentication Provider.""" + + transport: str + headers: Mapping[str, str] + client_host: str | None + + +@dataclass(frozen=True, slots=True) +class AuthenticationResult: + """Immutable trusted identity facts for one request.""" + + subject: PrincipalRef + actor: PrincipalRef | None = None + subject_groups: tuple[GroupRef, ...] = () + credential_id: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.subject, PrincipalRef) or ( + self.actor is not None and not isinstance(self.actor, PrincipalRef) + ): + raise ValueError("Authentication Provider returned an invalid Principal") # noqa: TRY003 + if not isinstance(self.subject_groups, tuple) or len(self.subject_groups) > _MAX_AUTHENTICATED_GROUPS: + raise ValueError("Authentication Provider returned invalid groups") # noqa: TRY003 + group_ids = [group.id for group in self.subject_groups if isinstance(group, GroupRef)] + if ( + len(group_ids) != len(self.subject_groups) + or len(group_ids) != len(set(group_ids)) + or self.subject.id in group_ids + or (self.actor is not None and self.actor.id in group_ids) + ): + raise ValueError("Authentication Provider returned invalid groups") # noqa: TRY003 + if self.credential_id is not None and ( + not self.credential_id.strip() + or self.credential_id != self.credential_id.strip() + or len(self.credential_id) > 255 + ): + raise ValueError("Authentication Provider returned an invalid credential ID") # noqa: TRY003 + + +@dataclass(frozen=True, slots=True) +class ProviderReadiness: + """Low-sensitivity readiness result for a required authentication dependency.""" + + ready: bool + reason: str | None = None + + +class AuthenticationRejectedError(PermissionError): + """The request did not carry a valid credential.""" + + +class AuthenticationUnavailableError(RuntimeError): + """The configured Authentication Provider cannot currently decide.""" + + +class AuthenticationProvider(Protocol): + """Authenticate transport credentials into canonical deployment identities.""" + + async def authenticate(self, request: AuthenticationRequest, /) -> AuthenticationResult: ... + + async def readiness(self) -> ProviderReadiness: ... + + +class StaticBearerAuthenticationProvider: + """Map one constant-time validated bearer token to one fixed service Principal.""" + + def __init__(self, token: str, principal: PrincipalRef) -> None: + if not token: + raise ValueError("Bearer token must not be empty") # noqa: TRY003 + self._token = token.encode() + self._principal = principal + + async def authenticate(self, request: AuthenticationRequest, /) -> AuthenticationResult: + authorization = request.headers.get("authorization") + if authorization is None: + raise AuthenticationRejectedError + scheme, separator, credential = authorization.partition(" ") + if ( + not separator + or scheme.casefold() != "bearer" + or not credential + or not compare_digest(credential.encode(), self._token) + ): + raise AuthenticationRejectedError + return AuthenticationResult(subject=self._principal, credential_id="static-bearer") + + async def readiness(self) -> ProviderReadiness: + return ProviderReadiness(ready=True) + + +__all__ = ( + "AuthenticationProvider", + "AuthenticationRejectedError", + "AuthenticationRequest", + "AuthenticationResult", + "AuthenticationUnavailableError", + "ProviderReadiness", + "StaticBearerAuthenticationProvider", +) diff --git a/src/powercontext/server/authz/__init__.py b/src/powercontext/server/authz/__init__.py index b49ba1e65..76a91ba4e 100644 --- a/src/powercontext/server/authz/__init__.py +++ b/src/powercontext/server/authz/__init__.py @@ -17,6 +17,7 @@ from powercontext.server.authz.authzen import AuthZenAuthorizationProvider from powercontext.server.authz.casbin import CasbinAuthorizationProvider from powercontext.server.authz.errors import ( + AccessBindingNotFoundError, AccessConflictError, AccessControlError, AccessDeniedError, @@ -28,28 +29,40 @@ DEFAULT_DEPLOYMENT_ID, PUBLIC_ACCESS_ACTIONS, AccessAction, - AccessArtifactReference, AccessAuditEvent, AccessBinding, AccessBindingState, AccessDecision, AccessResourceType, AccessRole, + AccessSubjectRef, + ArtifactIdentity, + ArtifactOwnerRelation, + CandidateOwnerAttestation, + GroupRef, MemoryEntrySelector, PrincipalRef, ResourceRef, ) from powercontext.server.authz.service import ( AccessAuditContext, + AccessAuditPage, AccessAuditStore, + AccessBindingPage, AccessControlService, AccessProviderCapabilities, AccessRequest, + AuditSearchRequest, AuthorizationProvider, AuthorizedResourceFilter, AuthorizedResourcePage, + BindingSearchRequest, BuiltinAuthorizationProvider, CreateBinding, + HandoffReceiverReassignment, + ReassignHandoffReceiver, + RelationshipReader, + RelationshipStore, RelationshipWriter, ResourceSearchRequest, access_control_for_mode, @@ -59,11 +72,13 @@ "DEFAULT_DEPLOYMENT_ID", "PUBLIC_ACCESS_ACTIONS", "AccessAction", - "AccessArtifactReference", "AccessAuditContext", "AccessAuditEvent", + "AccessAuditPage", "AccessAuditStore", "AccessBinding", + "AccessBindingNotFoundError", + "AccessBindingPage", "AccessBindingState", "AccessConflictError", "AccessControlError", @@ -76,16 +91,27 @@ "AccessRequest", "AccessResourceType", "AccessRole", + "AccessSubjectRef", "AccessUnavailableError", + "ArtifactIdentity", + "ArtifactOwnerRelation", + "AuditSearchRequest", "AuthZenAuthorizationProvider", "AuthorizationProvider", "AuthorizedResourceFilter", "AuthorizedResourcePage", + "BindingSearchRequest", "BuiltinAuthorizationProvider", + "CandidateOwnerAttestation", "CasbinAuthorizationProvider", "CreateBinding", + "GroupRef", + "HandoffReceiverReassignment", "MemoryEntrySelector", "PrincipalRef", + "ReassignHandoffReceiver", + "RelationshipReader", + "RelationshipStore", "RelationshipWriter", "ResourceRef", "ResourceSearchRequest", diff --git a/src/powercontext/server/authz/authzen.py b/src/powercontext/server/authz/authzen.py index 13e04647e..de5c576bd 100644 --- a/src/powercontext/server/authz/authzen.py +++ b/src/powercontext/server/authz/authzen.py @@ -119,7 +119,7 @@ def _access_request(request: AccessRequest) -> dict[str, object]: "subject": { "type": request.subject.type, "id": request.subject.id, - "properties": {"issuer": request.subject.issuer}, + "properties": ({} if request.subject.description is None else {"description": request.subject.description}), }, "action": {"name": request.action.value}, "resource": _resource(request.resource), @@ -137,11 +137,10 @@ def _resource(resource: ResourceRef) -> dict[str, object]: properties["deployment_id"] = resource.deployment_id if resource.scope_id is not None: properties["scope_id"] = resource.scope_id - if resource.reference is not None: - properties["reference"] = { - "family": resource.reference.family, - "artifact_id": resource.reference.artifact_id, - "revision": resource.reference.revision, + if resource.identity is not None: + properties["identity"] = { + "family": resource.identity.family, + "artifact_id": resource.identity.artifact_id, } if resource.selector is not None: properties["selector"] = _selector(resource.selector) @@ -152,7 +151,6 @@ def _selector(selector: MemoryEntrySelector) -> dict[str, str]: return { "type": selector.type, "entry_id": selector.entry_id, - "entry_version_id": selector.entry_version_id, } diff --git a/src/powercontext/server/authz/casbin.py b/src/powercontext/server/authz/casbin.py index 8875f8136..1df682990 100644 --- a/src/powercontext/server/authz/casbin.py +++ b/src/powercontext/server/authz/casbin.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Embedded Casbin adapter over the canonical PowerContext Binding Store.""" +"""Embedded Casbin decision adapter over canonical Access relationships.""" from __future__ import annotations @@ -25,11 +25,13 @@ from powercontext.server.authz.models import ( DEFAULT_DEPLOYMENT_ID, ROLE_ACTIONS, + ROLE_CHILD_ACTIONS, AccessAction, AccessBinding, AccessDecision, AccessResourceType, - PrincipalRef, + AccessRole, + AccessSubjectRef, ResourceRef, ) from powercontext.server.authz.service import ( @@ -37,173 +39,168 @@ AccessRequest, AuthorizedResourceFilter, ResourceSearchRequest, + contextual_policy_revision, ) _MODEL = """ [request_definition] -r = sub, act, obj, scope, deployment +r = act, obj, scope, deployment [policy_definition] -p = sub, act, obj, scope, deployment +p = act, obj, scope, deployment [policy_effect] e = some(where (p.eft == allow)) [matchers] -m = r.sub == p.sub && (p.act == "*" || r.act == p.act) && (p.obj == "*" || r.obj == p.obj) && (p.scope == "*" || r.scope == p.scope) && r.deployment == p.deployment +m = r.act == p.act && (p.obj == "*" || r.obj == p.obj) && (p.scope == "*" || r.scope == p.scope) && r.deployment == p.deployment """ class CasbinAuthorizationProvider: - """Evaluate canonical role bindings with an embedded Casbin policy model. - - The relational Binding Store is the persistent Casbin adapter: each decision materializes only - the current Principal's active, opaque relationships into a short-lived enforcer. This avoids - copying business content or maintaining a second policy shadow while preserving the same CAS, - idempotency, expiry, audit, and safe-list semantics as the built-in reference provider. - """ + """Evaluate only active direct/group relationships with a fresh Casbin enforcer.""" def __init__( self, repository: AccessRepository, *, - bootstrap_administrators: Sequence[PrincipalRef] = (), deployment_id: str = DEFAULT_DEPLOYMENT_ID, clock: Callable[[], datetime] | None = None, ) -> None: self._repository = repository - self._bootstrap_administrators = frozenset(bootstrap_administrators) self._deployment_id = deployment_id self._clock = clock or (lambda: datetime.now(UTC)) async def check(self, request: AccessRequest, /) -> AccessDecision: - decisions = await self.check_batch((request,)) - return decisions[0] + return (await self.check_batch((request,)))[0] - async def check_batch( - self, - requests: Sequence[AccessRequest], - /, - ) -> tuple[AccessDecision, ...]: + async def check_batch(self, requests: Sequence[AccessRequest], /) -> tuple[AccessDecision, ...]: revision = await self._repository.policy_revision() if not requests: return () principal = requests[0].subject if any(request.subject != principal for request in requests): raise AccessInvalidRequestError("batch-subject") - bindings = await self._repository.active_bindings(principal, now=self._clock()) - enforcer = _enforcer( - principal, - bindings, - bootstrap=principal in self._bootstrap_administrators, - deployment_id=self._deployment_id, - ) + revision = contextual_policy_revision(revision, requests[0].context.subject_groups) + subjects: tuple[AccessSubjectRef, ...] = (principal, *requests[0].context.subject_groups) + bindings = await self._repository.active_bindings(subjects, now=self._clock()) decisions: list[AccessDecision] = [] for request in requests: if request.action is AccessAction.ACCESS_SELF: decisions.append(AccessDecision(True, "authenticated", revision)) continue + matched = _matching_binding(bindings, request.action, request.resource) + owner = ( + await self._repository.get_artifact_owner(request.resource) + if request.resource.type is AccessResourceType.ARTIFACT + else None + ) + if request.resource.type is AccessResourceType.ARTIFACT and owner is None: + decisions.append(AccessDecision(False, "artifact-owner-pending", revision)) + continue + owner_allow = ( + owner is not None + and owner.owner == principal + and request.action in ROLE_ACTIONS[AccessRole.ARTIFACT_OWNER] + ) + enforcer = _enforcer( + bindings, + owner_allow=owner_allow, + requested=request.resource, + deployment_id=self._deployment_id, + ) allowed = bool(enforcer.enforce(*_casbin_request(request, self._deployment_id))) decisions.append( AccessDecision( - allowed=allowed, - reason_code="casbin-policy" if allowed else "no-matching-policy", - policy_revision=revision, + allowed, + "casbin-policy" if allowed else "no-matching-policy", + revision, + matched_subject=(principal if owner_allow else None if matched is None else matched.subject), + matched_binding_id=None if matched is None else matched.binding_id, ) ) return tuple(decisions) - async def resolve_resource_filter( - self, - request: ResourceSearchRequest, - /, - ) -> AuthorizedResourceFilter: - revision = await self._repository.policy_revision() - if request.subject in self._bootstrap_administrators: - return AuthorizedResourceFilter( - exact_resources=(ResourceRef.server(self._deployment_id),) - if request.resource_type is AccessResourceType.SERVER - else (), - parent_constraints=(ResourceRef.server(self._deployment_id),), - policy_revision=revision, - ) - bindings = await self._repository.active_bindings(request.subject, now=self._clock()) + async def resolve_resource_filter(self, request: ResourceSearchRequest, /) -> AuthorizedResourceFilter: + revision = contextual_policy_revision( + await self._repository.policy_revision(), + request.context.subject_groups, + ) + subjects: tuple[AccessSubjectRef, ...] = (request.subject, *request.context.subject_groups) + bindings = await self._repository.active_bindings(subjects, now=self._clock()) exact: dict[str, ResourceRef] = {} parents: dict[str, ResourceRef] = {} for binding in bindings: - if request.action not in ROLE_ACTIONS[binding.role]: - continue resource = binding.resource - if resource.type is request.resource_type and (request.family is None or resource.family == request.family): + if ( + resource.type is request.resource_type + and request.action in ROLE_ACTIONS[binding.role] + and (request.family is None or resource.family == request.family) + ): exact[resource.key] = resource - elif _resource_is_parent(resource, request.resource_type): + elif _is_parent(resource, request.resource_type) and request.action in ROLE_CHILD_ACTIONS.get( + binding.role, frozenset() + ): parents[resource.key] = resource + if ( + request.resource_type is AccessResourceType.ARTIFACT + and request.action in ROLE_ACTIONS[AccessRole.ARTIFACT_OWNER] + ): + for resource in await self._repository.list_owned_resources(request.subject): + if request.family is None or resource.family == request.family: + exact[resource.key] = resource return AuthorizedResourceFilter( exact_resources=tuple(exact[key] for key in sorted(exact)), parent_constraints=tuple(parents[key] for key in sorted(parents)), + complete=True, policy_revision=revision, - ) - - async def get_binding(self, binding_id: str) -> AccessBinding | None: - return await self._repository.get_binding(binding_id) - - async def list_bindings( - self, - *, - subject: PrincipalRef | None = None, - resource: ResourceRef | None = None, - include_revoked: bool = False, - ) -> tuple[AccessBinding, ...]: - return await self._repository.list_bindings( - subject=subject, - resource=resource, - include_revoked=include_revoked, - ) - - async def create_binding(self, binding: AccessBinding) -> AccessBinding: - return await self._repository.create_binding(binding) - - async def revoke_binding( - self, - binding_id: str, - *, - expected_version: int, - revoked_at: datetime, - revoked_by: PrincipalRef, - ) -> AccessBinding: - return await self._repository.revoke_binding( - binding_id, - expected_version=expected_version, - revoked_at=revoked_at, - revoked_by=revoked_by, + max_direct_resource_keys=10_000, ) def _enforcer( - principal: PrincipalRef, bindings: Sequence[AccessBinding], *, - bootstrap: bool, + owner_allow: bool, + requested: ResourceRef, deployment_id: str, ) -> casbin.Enforcer: model = casbin.Model() model.load_model_from_text(_MODEL) enforcer = casbin.Enforcer(model) policies: list[list[str]] = [] - if bootstrap: - policies.append([principal.key, "*", "*", "*", deployment_id]) for binding in bindings: - obj, scope, deployment = _casbin_policy_resource(binding.resource, deployment_id) - policies.extend([principal.key, action.value, obj, scope, deployment] for action in ROLE_ACTIONS[binding.role]) + resource = binding.resource + policies.extend( + [action.value, resource.key, resource.scope_id or "", resource.deployment_id or deployment_id] + for action in ROLE_ACTIONS[binding.role] + ) + policies.extend( + [action.value, "*", resource.scope_id or "*", resource.deployment_id or deployment_id] + for action in ROLE_CHILD_ACTIONS.get(binding.role, frozenset()) + ) + if owner_allow: + policies.append([ + AccessAction.ARTIFACT_READ.value, + requested.key, + requested.scope_id or "", + requested.deployment_id or deployment_id, + ]) + for action in ROLE_ACTIONS[AccessRole.ARTIFACT_OWNER] - {AccessAction.ARTIFACT_READ}: + policies.append([ + action.value, + requested.key, + requested.scope_id or "", + requested.deployment_id or deployment_id, + ]) if policies: enforcer.add_policies(policies) return enforcer -def _casbin_request(request: AccessRequest, deployment_id: str) -> tuple[str, str, str, str, str]: +def _casbin_request(request: AccessRequest, deployment_id: str) -> tuple[str, str, str, str]: resource = request.resource return ( - request.subject.key, request.action.value, resource.key, resource.scope_id or "", @@ -211,19 +208,28 @@ def _casbin_request(request: AccessRequest, deployment_id: str) -> tuple[str, st ) -def _casbin_policy_resource(resource: ResourceRef, deployment_id: str) -> tuple[str, str, str]: - if resource.type is AccessResourceType.SERVER: - return ( - "*" if resource.deployment_id == deployment_id else resource.key, - "*", - resource.deployment_id or deployment_id, - ) - if resource.type is AccessResourceType.SCOPE: - return "*", resource.scope_id or "", deployment_id - return resource.key, resource.scope_id or "", deployment_id +def _matching_binding( + bindings: Sequence[AccessBinding], + action: AccessAction, + resource: ResourceRef, +) -> AccessBinding | None: + for binding in bindings: + if binding.resource == resource and action in ROLE_ACTIONS[binding.role]: + return binding + if _covers(binding.resource, resource) and action in ROLE_CHILD_ACTIONS.get(binding.role, frozenset()): + return binding + return None + + +def _covers(parent: ResourceRef, child: ResourceRef) -> bool: + return parent.type is AccessResourceType.SERVER or ( + parent.type is AccessResourceType.SCOPE + and child.type is AccessResourceType.ARTIFACT + and parent.scope_id == child.scope_id + ) -def _resource_is_parent(resource: ResourceRef, requested_type: AccessResourceType) -> bool: +def _is_parent(resource: ResourceRef, requested_type: AccessResourceType) -> bool: return resource.type is AccessResourceType.SERVER or ( resource.type is AccessResourceType.SCOPE and requested_type is AccessResourceType.ARTIFACT ) diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py index b01b538c6..1c0623322 100644 --- a/src/powercontext/server/authz/composition.py +++ b/src/powercontext/server/authz/composition.py @@ -18,7 +18,8 @@ from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager -from typing import Literal +from datetime import UTC, datetime +from uuid import uuid4 from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile @@ -26,13 +27,24 @@ from powercontext.builtin.runtime.composition import BuiltinConfigurationError from powercontext.builtin.runtime.config import DatabaseConfig from powercontext.server.authz.casbin import CasbinAuthorizationProvider -from powercontext.server.authz.models import DEFAULT_DEPLOYMENT_ID, PrincipalRef +from powercontext.server.authz.models import ( + DEFAULT_DEPLOYMENT_ID, + AccessBinding, + AccessBindingState, + AccessRole, + PrincipalRef, + ResourceRef, +) from powercontext.server.authz.repository import ( ACCESS_TABLES, RelationalAccessRepository, ensure_access_policy_revision_columns, ) -from powercontext.server.authz.service import AccessControlService, BuiltinAuthorizationProvider +from powercontext.server.authz.service import ( + AccessControlService, + AccessProviderCapabilities, + BuiltinAuthorizationProvider, +) @asynccontextmanager @@ -41,14 +53,13 @@ async def open_builtin_access_control( *, bootstrap_administrators: Sequence[PrincipalRef] = (), deployment_id: str = DEFAULT_DEPLOYMENT_ID, - mode: Literal["legacy-static-admin", "enforced"] = "enforced", ) -> AsyncIterator[AccessControlService]: """Open a Server-owned Access schema without coupling it to Runtime domains.""" async with _open_access_repository(database) as repository: + await _bootstrap_server_roles(repository, bootstrap_administrators, deployment_id=deployment_id) provider = BuiltinAuthorizationProvider( repository, - bootstrap_administrators=bootstrap_administrators, deployment_id=deployment_id, ) yield AccessControlService( @@ -56,7 +67,13 @@ async def open_builtin_access_control( relationships=repository, audit=repository, deployment_id=deployment_id, - mode=mode, + provider_capabilities=AccessProviderCapabilities( + safe_resource_filtering=True, + multi_requirement_check=True, + relationship_management=True, + group_subjects=False, + ), + static_scope_principal=bootstrap_administrators[0] if len(bootstrap_administrators) == 1 else None, ) @@ -66,22 +83,27 @@ async def open_casbin_access_control( *, bootstrap_administrators: Sequence[PrincipalRef] = (), deployment_id: str = DEFAULT_DEPLOYMENT_ID, - mode: Literal["legacy-static-admin", "enforced"] = "enforced", ) -> AsyncIterator[AccessControlService]: """Open the writable embedded Casbin adapter over the canonical Access schema.""" async with _open_access_repository(database) as repository: + await _bootstrap_server_roles(repository, bootstrap_administrators, deployment_id=deployment_id) provider = CasbinAuthorizationProvider( repository, - bootstrap_administrators=bootstrap_administrators, deployment_id=deployment_id, ) yield AccessControlService( provider, - relationships=provider, + relationships=repository, audit=repository, deployment_id=deployment_id, - mode=mode, + provider_capabilities=AccessProviderCapabilities( + safe_resource_filtering=True, + multi_requirement_check=True, + relationship_management=True, + group_subjects=False, + ), + static_scope_principal=bootstrap_administrators[0] if len(bootstrap_administrators) == 1 else None, ) @@ -103,4 +125,32 @@ async def _open_access_repository( yield RelationalAccessRepository(profile.database) +async def _bootstrap_server_roles( + repository: RelationalAccessRepository, + principals: Sequence[PrincipalRef], + *, + deployment_id: str, +) -> None: + resource = ResourceRef.server(deployment_id) + for principal in principals: + for role in (AccessRole.SERVER_OBSERVER, AccessRole.SERVER_ADMIN): + key = f"static-preset:{deployment_id}:{principal.id}:{role.value}" + await repository.create_binding( + AccessBinding( + binding_id=str(uuid4()), + subject=principal, + resource=resource, + role=role, + granted_by=principal, + reason="static bearer preset", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key=key, + ) + ) + + __all__ = ("open_builtin_access_control", "open_casbin_access_control") diff --git a/src/powercontext/server/authz/errors.py b/src/powercontext/server/authz/errors.py index 872c8f40a..477fc2157 100644 --- a/src/powercontext/server/authz/errors.py +++ b/src/powercontext/server/authz/errors.py @@ -35,6 +35,13 @@ def __init__(self) -> None: super().__init__("the Principal is not authorized for this operation") +class AccessBindingNotFoundError(AccessControlError, LookupError): + """A caller with server administration requested an unknown Binding.""" + + def __init__(self) -> None: + super().__init__("the Access Binding was not found") + + class AccessUnavailableError(AccessControlError, RuntimeError): """A required authorization dependency is unavailable.""" @@ -42,8 +49,10 @@ def __init__(self, code: str = "access_unavailable") -> None: self.code = code messages = { "access_unavailable": "the authorization service is unavailable", + "artifact_owner_pending": "the Artifact owner relationship is pending", "multi_requirement_check_unavailable": "multi-requirement Access checks are unavailable", "relationship_management_unavailable": "Access relationship management is unavailable", + "resource_filter_limit_exceeded": "the complete authorized resource filter exceeds its configured limit", "safe_resource_filtering_unavailable": "safe Access resource filtering is unavailable", } super().__init__(messages.get(code, messages["access_unavailable"])) @@ -55,8 +64,12 @@ class AccessConflictError(AccessControlError, RuntimeError): def __init__(self, code: str) -> None: self.code = code messages = { + "artifact-owner": "the logical Artifact already has a different owner", "binding-version": "the Access Binding version is stale", + "candidate-owner": "the Candidate is already locked to a different proposed owner", + "handoff_receiver_conflict": "the logical Handoff already has an active receiver", "idempotency-key": "the Access Binding idempotency key was reused with different input", + "access_cursor_stale": "the Access cursor belongs to an older policy revision", } super().__init__(messages.get(code, "the Access Binding conflicts with current state")) @@ -70,16 +83,22 @@ def __init__(self, code: str) -> None: "action-resource": "the action is not valid for this Access resource", "artifact-family": "the Artifact Family is not registered for Access sharing", "artifact-family-disabled": "the Artifact Family Access Profile is disabled", - "artifact-reference": "an Artifact resource requires one exact ArtifactReference", + "artifact-identity": "an Artifact resource requires one logical Artifact identity", "artifact-selector": "the Artifact Family does not accept this selector", "artifact-state": "the Artifact resource is not in a shareable lifecycle state", "binding-role": "the role cannot be bound to this resource type", + "binding-subject": "the role cannot be assigned to this subject type", "binding-expired": "expires_at must be later than the current Server time", + "batch-subject": "all Access batch checks must use the same authenticated Principal", + "candidate-owner": "the Candidate owner attestation is invalid", "cursor": "the Access cursor is invalid", "deployment": "the Server resource does not identify this deployment", - "handoff-reference": "a Handoff resource requires one exact Handoff ArtifactReference", + "group": "the Access group is invalid", + "group-subjects-unavailable": "the configured Providers cannot safely authorize group subjects", + "handoff-identity": "a Handoff resource requires one logical Handoff identity", "idempotency-key": "the Access Binding idempotency key is invalid", - "memory-entry-selector": "a Memory Access resource requires one exact Memory Entry Version selector", + "limit": "the Access page size is invalid", + "memory-entry-selector": "a Memory Access resource requires one logical Memory Entry selector", "principal": "the Access Principal is invalid", "resource": "the Access resource is invalid", "receiver-principal": "an accepted Handoff receiver must match the authenticated Principal", @@ -89,6 +108,7 @@ def __init__(self, code: str) -> None: __all__ = ( + "AccessBindingNotFoundError", "AccessConflictError", "AccessControlError", "AccessDeniedError", diff --git a/src/powercontext/server/authz/models.py b/src/powercontext/server/authz/models.py index 1ce822289..c58b3004a 100644 --- a/src/powercontext/server/authz/models.py +++ b/src/powercontext/server/authz/models.py @@ -17,9 +17,10 @@ from __future__ import annotations import json -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import StrEnum +from typing import TypeAlias from powercontext.server.authz.errors import AccessInvalidRequestError @@ -39,10 +40,11 @@ class AccessAction(StrEnum): SCOPE_DELEGATE = "scope.delegate" SCOPE_ADMIN = "scope.admin" ARTIFACT_READ = "artifact.read" - HANDOFF_EVIDENCE_READ = "handoff.evidence.read" + ARTIFACT_WRITE = "artifact.write" + ARTIFACT_SHARE = "artifact.share" + HANDOFF_EVIDENCE_INSPECT = "handoff.evidence.inspect" HANDOFF_ACKNOWLEDGE = "handoff.acknowledge" PROMPT_USE = "prompt.use" - SKILL_PUBLISH = "skill.publish" PUBLIC_ACCESS_ACTIONS = tuple(action for action in AccessAction if action is not AccessAction.ACCESS_SELF) @@ -63,7 +65,7 @@ class AccessRole(StrEnum): HANDOFF_RECEIVER = "handoff.receiver" ARTIFACT_VIEWER = "artifact.viewer" PROMPT_USER = "prompt.user" - SKILL_PUBLISHER = "skill.publisher" + ARTIFACT_OWNER = "artifact.owner" SCOPE_VIEWER = "scope.viewer" SCOPE_CONTRIBUTOR = "scope.contributor" SCOPE_REVIEWER = "scope.reviewer" @@ -82,51 +84,70 @@ class AccessBindingState(StrEnum): @dataclass(frozen=True, slots=True) class PrincipalRef: - """Stable opaque identity established by authentication.""" + """Canonical user or service identity established by authentication.""" type: str - issuer: str id: str + description: str | None = field(default=None, compare=False) def __post_init__(self) -> None: - if not ( - _valid_text(self.type, maximum=64) - and _valid_text(self.issuer, maximum=255) - and _valid_text(self.id, maximum=255) - ): + if self.type not in {"user", "service"} or not _valid_text(self.id, maximum=255): + raise AccessInvalidRequestError("principal") + if self.description is not None and not _valid_text(self.description, maximum=255): raise AccessInvalidRequestError("principal") @property def key(self) -> str: - return _canonical_json({"id": self.id, "issuer": self.issuer, "type": self.type}) + """Return the deployment-wide identity key; display metadata is excluded.""" + + return self.id @dataclass(frozen=True, slots=True) -class AccessArtifactReference: - """Exact immutable Artifact identity used by one Access resource.""" +class GroupRef: + """Canonical group identity resolved by a trusted identity source.""" + + type: str + id: str + description: str | None = field(default=None, compare=False) + + def __post_init__(self) -> None: + if self.type != "group" or not _valid_text(self.id, maximum=255): + raise AccessInvalidRequestError("group") + if self.description is not None and not _valid_text(self.description, maximum=255): + raise AccessInvalidRequestError("group") + + @property + def key(self) -> str: + """Return the deployment-wide identity key; display metadata is excluded.""" + + return self.id + + +AccessSubjectRef: TypeAlias = PrincipalRef | GroupRef + + +@dataclass(frozen=True, slots=True) +class ArtifactIdentity: + """Version-independent identity of one logical Artifact.""" family: str artifact_id: str - revision: int def __post_init__(self) -> None: if not _valid_text(self.family, maximum=128) or not _valid_text(self.artifact_id, maximum=128): - raise AccessInvalidRequestError("artifact-reference") - if isinstance(self.revision, bool) or not isinstance(self.revision, int) or self.revision < 1: - raise AccessInvalidRequestError("artifact-reference") + raise AccessInvalidRequestError("artifact-identity") @dataclass(frozen=True, slots=True) class MemoryEntrySelector: - """Exact Memory Entry Version selected inside one Memory Revision.""" + """Version-independent selector for one logical Memory Entry.""" entry_id: str - entry_version_id: str - type: str = "memory_entry" def __post_init__(self) -> None: - if not _valid_text(self.entry_id, maximum=128) or not _valid_text(self.entry_version_id, maximum=128): + if self.type != "memory_entry" or not _valid_text(self.entry_id, maximum=128): raise AccessInvalidRequestError("memory-entry-selector") @@ -137,7 +158,7 @@ class ResourceRef: type: AccessResourceType deployment_id: str | None = None scope_id: str | None = None - reference: AccessArtifactReference | None = None + identity: ArtifactIdentity | None = None selector: MemoryEntrySelector | None = None def __post_init__(self) -> None: @@ -145,20 +166,18 @@ def __post_init__(self) -> None: valid = ( _valid_text(self.deployment_id, maximum=128) and self.scope_id is None - and self.reference is None + and self.identity is None and self.selector is None ) elif self.type is AccessResourceType.SCOPE: valid = ( self.deployment_id is None and _valid_text(self.scope_id, maximum=256) - and self.reference is None + and self.identity is None and self.selector is None ) else: - valid = ( - self.deployment_id is None and _valid_text(self.scope_id, maximum=256) and self.reference is not None - ) + valid = self.deployment_id is None and _valid_text(self.scope_id, maximum=256) and self.identity is not None if not valid: raise AccessInvalidRequestError("resource") @@ -177,31 +196,22 @@ def artifact( *, family: str, artifact_id: str, - revision: int, selector: MemoryEntrySelector | None = None, ) -> ResourceRef: return cls( type=AccessResourceType.ARTIFACT, scope_id=scope_id, - reference=AccessArtifactReference( - family=family, - artifact_id=artifact_id, - revision=revision, - ), + identity=ArtifactIdentity(family=family, artifact_id=artifact_id), selector=selector, ) @property def family(self) -> str | None: - return None if self.reference is None else self.reference.family + return None if self.identity is None else self.identity.family @property def artifact_id(self) -> str | None: - return None if self.reference is None else self.reference.artifact_id - - @property - def revision(self) -> int | None: - return None if self.reference is None else self.reference.revision + return None if self.identity is None else self.identity.artifact_id @property def key(self) -> str: @@ -210,13 +220,12 @@ def key(self) -> str: elif self.type is AccessResourceType.SCOPE: value = {"scope_id": self.scope_id, "type": self.type.value} else: - if self.reference is None: - raise AccessInvalidRequestError("artifact-reference") + if self.identity is None: + raise AccessInvalidRequestError("artifact-identity") value = { - "reference": { - "artifact_id": self.reference.artifact_id, - "family": self.reference.family, - "revision": self.reference.revision, + "identity": { + "artifact_id": self.identity.artifact_id, + "family": self.identity.family, }, "scope_id": self.scope_id, "selector": ( @@ -224,7 +233,6 @@ def key(self) -> str: if self.selector is None else { "entry_id": self.selector.entry_id, - "entry_version_id": self.selector.entry_version_id, "type": self.selector.type, } ), @@ -239,11 +247,13 @@ def parent_scope(self) -> ResourceRef | None: @dataclass(frozen=True, slots=True) class AccessDecision: - """One low-sensitivity authorization result.""" + """One low-sensitivity authorization result plus internal attribution.""" allowed: bool reason_code: str policy_revision: str | None + matched_subject: AccessSubjectRef | None = None + matched_binding_id: str | None = None @dataclass(frozen=True, slots=True) @@ -251,7 +261,7 @@ class AccessBinding: """One persisted role assignment.""" binding_id: str - subject: PrincipalRef + subject: AccessSubjectRef resource: ResourceRef role: AccessRole granted_by: PrincipalRef @@ -269,6 +279,41 @@ def active_at(self, now: datetime) -> bool: return self.state is AccessBindingState.ACTIVE and (self.expires_at is None or self.expires_at > now) +@dataclass(frozen=True, slots=True) +class ArtifactOwnerRelation: + """The single direct owner of one logical Artifact.""" + + resource: ResourceRef + owner: PrincipalRef + established_at: datetime + policy_revision: str + idempotency_key: str + + +@dataclass(frozen=True, slots=True) +class CandidateOwnerAttestation: + """Server-attested proposed owner locked to one Review Candidate.""" + + scope_id: str + candidate_id: str + family: str + proposed_owner: PrincipalRef + target: ResourceRef | None + idempotency_key: str + + def __post_init__(self) -> None: + if not _valid_text(self.scope_id, maximum=256) or not _valid_text(self.candidate_id, maximum=128): + raise AccessInvalidRequestError("candidate-owner") + if not _valid_text(self.family, maximum=128): + raise AccessInvalidRequestError("candidate-owner") + if self.target is not None and ( + self.target.type is not AccessResourceType.ARTIFACT + or self.target.scope_id != self.scope_id + or self.target.family != self.family + ): + raise AccessInvalidRequestError("candidate-owner") + + @dataclass(frozen=True, slots=True) class AccessAuditEvent: """Data-minimized authorization or relationship audit record.""" @@ -285,71 +330,74 @@ class AccessAuditEvent: allowed: bool reason_code: str policy_revision: str | None + matched_subject: AccessSubjectRef | None = None binding_id: str | None = None - target: PrincipalRef | None = None + target: AccessSubjectRef | None = None role: AccessRole | None = None + expected_version: int | None = None + result_version: int | None = None +# Parent-to-child implications are separate so management roles never become +# accidental content roles. ROLE_ACTIONS: dict[AccessRole, frozenset[AccessAction]] = { - AccessRole.HANDOFF_VIEWER: frozenset({AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_READ}), + AccessRole.HANDOFF_VIEWER: frozenset({AccessAction.ARTIFACT_READ, AccessAction.HANDOFF_EVIDENCE_INSPECT}), AccessRole.HANDOFF_RECEIVER: frozenset({ AccessAction.ARTIFACT_READ, - AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_EVIDENCE_INSPECT, AccessAction.HANDOFF_ACKNOWLEDGE, }), AccessRole.ARTIFACT_VIEWER: frozenset({AccessAction.ARTIFACT_READ}), AccessRole.PROMPT_USER: frozenset({AccessAction.ARTIFACT_READ, AccessAction.PROMPT_USE}), - AccessRole.SKILL_PUBLISHER: frozenset({AccessAction.ARTIFACT_READ, AccessAction.SKILL_PUBLISH}), + AccessRole.ARTIFACT_OWNER: frozenset({ + AccessAction.ARTIFACT_READ, + AccessAction.ARTIFACT_WRITE, + AccessAction.ARTIFACT_SHARE, + AccessAction.HANDOFF_EVIDENCE_INSPECT, + }), + AccessRole.SCOPE_VIEWER: frozenset({AccessAction.SCOPE_READ}), + AccessRole.SCOPE_CONTRIBUTOR: frozenset({AccessAction.SCOPE_READ, AccessAction.SCOPE_CONTRIBUTE}), + AccessRole.SCOPE_REVIEWER: frozenset({AccessAction.SCOPE_READ, AccessAction.SCOPE_REVIEW}), + AccessRole.SCOPE_DELEGATOR: frozenset({AccessAction.SCOPE_READ, AccessAction.SCOPE_DELEGATE}), + AccessRole.SCOPE_ADMIN: frozenset({AccessAction.SCOPE_ADMIN}), + AccessRole.SERVER_OBSERVER: frozenset({AccessAction.SERVER_OBSERVE}), + AccessRole.SERVER_ADMIN: frozenset({AccessAction.SERVER_ADMIN}), +} + + +ROLE_CHILD_ACTIONS: dict[AccessRole, frozenset[AccessAction]] = { AccessRole.SCOPE_VIEWER: frozenset({ - AccessAction.SCOPE_READ, AccessAction.ARTIFACT_READ, - AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_EVIDENCE_INSPECT, AccessAction.PROMPT_USE, }), AccessRole.SCOPE_CONTRIBUTOR: frozenset({ - AccessAction.SCOPE_READ, - AccessAction.SCOPE_CONTRIBUTE, AccessAction.ARTIFACT_READ, - AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_EVIDENCE_INSPECT, AccessAction.HANDOFF_ACKNOWLEDGE, AccessAction.PROMPT_USE, }), AccessRole.SCOPE_REVIEWER: frozenset({ - AccessAction.SCOPE_READ, - AccessAction.SCOPE_REVIEW, AccessAction.ARTIFACT_READ, - AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_EVIDENCE_INSPECT, AccessAction.PROMPT_USE, }), AccessRole.SCOPE_DELEGATOR: frozenset({ - AccessAction.SCOPE_READ, - AccessAction.SCOPE_DELEGATE, AccessAction.ARTIFACT_READ, - AccessAction.HANDOFF_EVIDENCE_READ, + AccessAction.HANDOFF_EVIDENCE_INSPECT, AccessAction.PROMPT_USE, }), - AccessRole.SCOPE_ADMIN: frozenset({ - AccessAction.SCOPE_READ, - AccessAction.SCOPE_CONTRIBUTE, - AccessAction.SCOPE_REVIEW, - AccessAction.SCOPE_DELEGATE, - AccessAction.SCOPE_ADMIN, - AccessAction.ARTIFACT_READ, - AccessAction.HANDOFF_EVIDENCE_READ, - AccessAction.HANDOFF_ACKNOWLEDGE, - AccessAction.PROMPT_USE, - AccessAction.SKILL_PUBLISH, - }), - AccessRole.SERVER_OBSERVER: frozenset({AccessAction.SERVER_OBSERVE}), - AccessRole.SERVER_ADMIN: frozenset(AccessAction), + AccessRole.SCOPE_ADMIN: frozenset({AccessAction.ARTIFACT_SHARE}), + AccessRole.SERVER_ADMIN: frozenset({AccessAction.SCOPE_ADMIN, AccessAction.ARTIFACT_SHARE}), } + ROLE_RESOURCE_TYPES: dict[AccessRole, AccessResourceType] = { AccessRole.HANDOFF_VIEWER: AccessResourceType.ARTIFACT, AccessRole.HANDOFF_RECEIVER: AccessResourceType.ARTIFACT, AccessRole.ARTIFACT_VIEWER: AccessResourceType.ARTIFACT, AccessRole.PROMPT_USER: AccessResourceType.ARTIFACT, - AccessRole.SKILL_PUBLISHER: AccessResourceType.ARTIFACT, + AccessRole.ARTIFACT_OWNER: AccessResourceType.ARTIFACT, AccessRole.SCOPE_VIEWER: AccessResourceType.SCOPE, AccessRole.SCOPE_CONTRIBUTOR: AccessResourceType.SCOPE, AccessRole.SCOPE_REVIEWER: AccessResourceType.SCOPE, @@ -360,6 +408,13 @@ class AccessAuditEvent: } +ROLE_SUBJECT_TYPES: dict[AccessRole, frozenset[str]] = { + role: frozenset({"user", "service", "group"}) for role in AccessRole +} +ROLE_SUBJECT_TYPES[AccessRole.HANDOFF_RECEIVER] = frozenset({"user", "service"}) +ROLE_SUBJECT_TYPES[AccessRole.ARTIFACT_OWNER] = frozenset({"user", "service"}) + + def _valid_text(value: object, *, maximum: int) -> bool: return isinstance(value, str) and bool(value.strip()) and value == value.strip() and len(value) <= maximum @@ -372,15 +427,21 @@ def _canonical_json(value: object) -> str: "DEFAULT_DEPLOYMENT_ID", "PUBLIC_ACCESS_ACTIONS", "ROLE_ACTIONS", + "ROLE_CHILD_ACTIONS", "ROLE_RESOURCE_TYPES", + "ROLE_SUBJECT_TYPES", "AccessAction", - "AccessArtifactReference", "AccessAuditEvent", "AccessBinding", "AccessBindingState", "AccessDecision", "AccessResourceType", "AccessRole", + "AccessSubjectRef", + "ArtifactIdentity", + "ArtifactOwnerRelation", + "CandidateOwnerAttestation", + "GroupRef", "MemoryEntrySelector", "PrincipalRef", "ResourceRef", diff --git a/src/powercontext/server/authz/profiles.py b/src/powercontext/server/authz/profiles.py index 9ad8de179..850d9e171 100644 --- a/src/powercontext/server/authz/profiles.py +++ b/src/powercontext/server/authz/profiles.py @@ -20,7 +20,13 @@ from typing import Literal from powercontext.server.authz.errors import AccessInvalidRequestError -from powercontext.server.authz.models import AccessAction, AccessResourceType, AccessRole, ResourceRef +from powercontext.server.authz.models import ( + ROLE_SUBJECT_TYPES, + AccessAction, + AccessResourceType, + AccessRole, + ResourceRef, +) @dataclass(frozen=True, slots=True) @@ -29,64 +35,85 @@ class ArtifactFamilyAccessProfile: family: str enabled: bool - share_unit: Literal["revision", "memory_entry"] + share_unit: Literal["artifact", "memory_entry"] shareable_states: frozenset[str] - actions: frozenset[AccessAction] + base_action: AccessAction + additional_actions: frozenset[AccessAction] grantable_roles: frozenset[AccessRole] selector: Literal["forbidden", "memory_entry"] + transitivity: Literal["none", "independent_evidence"] = "none" + mutation_semantics: frozenset[AccessAction] = frozenset() + + @property + def actions(self) -> frozenset[AccessAction]: + return frozenset({self.base_action, *self.additional_actions}) + + @property + def subject_compatibility(self) -> dict[AccessRole, frozenset[str]]: + return {role: ROLE_SUBJECT_TYPES[role] for role in self.grantable_roles} ARTIFACT_FAMILY_PROFILES: dict[str, ArtifactFamilyAccessProfile] = { "handoff": ArtifactFamilyAccessProfile( family="handoff", enabled=True, - share_unit="revision", + share_unit="artifact", shareable_states=frozenset({"committed"}), - actions=frozenset({ - AccessAction.ARTIFACT_READ, - AccessAction.HANDOFF_EVIDENCE_READ, + base_action=AccessAction.ARTIFACT_READ, + additional_actions=frozenset({ + AccessAction.HANDOFF_EVIDENCE_INSPECT, AccessAction.HANDOFF_ACKNOWLEDGE, }), grantable_roles=frozenset({AccessRole.HANDOFF_VIEWER, AccessRole.HANDOFF_RECEIVER}), selector="forbidden", + transitivity="independent_evidence", + mutation_semantics=frozenset({AccessAction.ARTIFACT_WRITE}), ), "memory": ArtifactFamilyAccessProfile( family="memory", enabled=True, share_unit="memory_entry", - shareable_states=frozenset({"active"}), - actions=frozenset({AccessAction.ARTIFACT_READ}), + shareable_states=frozenset({"active", "retired"}), + base_action=AccessAction.ARTIFACT_READ, + additional_actions=frozenset(), grantable_roles=frozenset({AccessRole.ARTIFACT_VIEWER}), selector="memory_entry", + mutation_semantics=frozenset({AccessAction.ARTIFACT_WRITE}), ), "experience": ArtifactFamilyAccessProfile( family="experience", enabled=True, - share_unit="revision", + share_unit="artifact", shareable_states=frozenset({"approved"}), - actions=frozenset({AccessAction.ARTIFACT_READ}), + base_action=AccessAction.ARTIFACT_READ, + additional_actions=frozenset(), grantable_roles=frozenset({AccessRole.ARTIFACT_VIEWER}), selector="forbidden", + mutation_semantics=frozenset({AccessAction.ARTIFACT_WRITE}), ), "skill": ArtifactFamilyAccessProfile( family="skill", enabled=True, - share_unit="revision", + share_unit="artifact", shareable_states=frozenset({"approved"}), - actions=frozenset({AccessAction.ARTIFACT_READ, AccessAction.SKILL_PUBLISH}), - grantable_roles=frozenset({AccessRole.ARTIFACT_VIEWER, AccessRole.SKILL_PUBLISHER}), + base_action=AccessAction.ARTIFACT_READ, + additional_actions=frozenset(), + grantable_roles=frozenset({AccessRole.ARTIFACT_VIEWER}), selector="forbidden", + mutation_semantics=frozenset({AccessAction.ARTIFACT_WRITE}), ), # Prompt authorization vocabulary is reserved, but this deployment does not yet # implement an immutable approved Prompt lifecycle or exact get/use operations. "prompt": ArtifactFamilyAccessProfile( family="prompt", enabled=False, - share_unit="revision", + share_unit="artifact", shareable_states=frozenset({"approved"}), - actions=frozenset(), + base_action=AccessAction.ARTIFACT_READ, + additional_actions=frozenset({AccessAction.PROMPT_USE}), grantable_roles=frozenset(), selector="forbidden", + mutation_semantics=frozenset({AccessAction.ARTIFACT_WRITE}), ), } @@ -94,9 +121,9 @@ class ArtifactFamilyAccessProfile: def artifact_family_profile(resource: ResourceRef) -> ArtifactFamilyAccessProfile: """Validate an exact Artifact resource and return its enabled profile.""" - if resource.type is not AccessResourceType.ARTIFACT or resource.reference is None: - raise AccessInvalidRequestError("artifact-reference") - profile = ARTIFACT_FAMILY_PROFILES.get(resource.reference.family) + if resource.type is not AccessResourceType.ARTIFACT or resource.identity is None: + raise AccessInvalidRequestError("artifact-identity") + profile = ARTIFACT_FAMILY_PROFILES.get(resource.identity.family) if profile is None: raise AccessInvalidRequestError("artifact-family") if not profile.enabled: @@ -128,7 +155,7 @@ def validate_action_resource(action: AccessAction, resource: ResourceRef, *, dep raise AccessInvalidRequestError("action-resource") return profile = artifact_family_profile(resource) - if action not in profile.actions: + if action not in profile.actions | profile.mutation_semantics | {AccessAction.ARTIFACT_SHARE}: raise AccessInvalidRequestError("action-resource") @@ -154,10 +181,24 @@ def validate_binding_role(resource: ResourceRef, role: AccessRole, *, deployment raise AccessInvalidRequestError("binding-role") +def validate_binding_subject(resource: ResourceRef, role: AccessRole, subject_type: str) -> None: + """Reject subject kinds a role cannot represent.""" + + if role is AccessRole.ARTIFACT_OWNER: + raise AccessInvalidRequestError("binding-role") + if subject_type not in ROLE_SUBJECT_TYPES[role]: + raise AccessInvalidRequestError("binding-subject") + if resource.type is AccessResourceType.ARTIFACT: + profile = artifact_family_profile(resource) + if subject_type not in profile.subject_compatibility[role]: + raise AccessInvalidRequestError("binding-subject") + + __all__ = ( "ARTIFACT_FAMILY_PROFILES", "ArtifactFamilyAccessProfile", "artifact_family_profile", "validate_action_resource", "validate_binding_role", + "validate_binding_subject", ) diff --git a/src/powercontext/server/authz/repository.py b/src/powercontext/server/authz/repository.py index 1c45afb4e..2f77bec02 100644 --- a/src/powercontext/server/authz/repository.py +++ b/src/powercontext/server/authz/repository.py @@ -12,7 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Dialect-neutral persistence for Server-owned Access relationships.""" +"""Dialect-neutral persistence for terminal Access relationships. + +The table names intentionally describe the final relationship model instead of +reusing the earlier experimental, revision-bound schema. This lets an operator +evaluate the unmerged implementation without altering obsolete Access tables. +""" from __future__ import annotations @@ -32,12 +37,11 @@ Text, UniqueConstraint, insert, + or_, select, - text, update, ) from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.tables import identity_string @@ -55,42 +59,45 @@ AccessBindingState, AccessResourceType, AccessRole, + AccessSubjectRef, + ArtifactOwnerRelation, + CandidateOwnerAttestation, + GroupRef, MemoryEntrySelector, PrincipalRef, ResourceRef, ) +from powercontext.server.authz.service import BindingSearchRequest, HandoffReceiverReassignment, ReassignHandoffReceiver ACCESS_METADATA = MetaData() ACCESS_POLICY_HEADS_TABLE = Table( - "pc_access_policy_heads", + "pc_access_relationship_heads", ACCESS_METADATA, Column("name", identity_string(32), primary_key=True), Column("revision", Integer, nullable=False), - CheckConstraint("revision >= 0", name="ck_pc_access_policy_heads_revision_nonnegative"), + CheckConstraint("revision >= 0", name="ck_pc_access_relationship_heads_revision_nonnegative"), ) ACCESS_BINDINGS_TABLE = Table( - "pc_access_bindings", + "pc_access_relationships", ACCESS_METADATA, Column("binding_id", identity_string(64), primary_key=True), - Column("subject_type", identity_string(64), nullable=False), - Column("subject_issuer", identity_string(255), nullable=False), + Column("subject_type", identity_string(16), nullable=False), Column("subject_id", identity_string(255), nullable=False), + Column("subject_description", Text), + Column("resource_key_hash", identity_string(64), nullable=False), Column("resource_type", identity_string(16), nullable=False), Column("deployment_id", identity_string(128)), Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH)), Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), - Column("revision", Integer), Column("selector_type", identity_string(32)), Column("selector_entry_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), - Column("selector_entry_version_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), Column("role", identity_string(32), nullable=False), - Column("granted_by_type", identity_string(64), nullable=False), - Column("granted_by_issuer", identity_string(255), nullable=False), + Column("granted_by_type", identity_string(16), nullable=False), Column("granted_by_id", identity_string(255), nullable=False), - Column("grantor_key_hash", identity_string(64), nullable=False), + Column("granted_by_description", Text), Column("reason", Text), Column("created_at", identity_string(32), nullable=False), Column("expires_at", identity_string(32)), @@ -98,21 +105,64 @@ Column("version", Integer, nullable=False), Column("policy_revision", identity_string(MAX_POLICY_REVISION_LENGTH), nullable=False), Column("idempotency_key", identity_string(255), nullable=False), - Column("idempotency_key_hash", identity_string(64), nullable=False), Column("revoked_at", identity_string(32)), - Column("revoked_by_type", identity_string(64)), - Column("revoked_by_issuer", identity_string(255)), + Column("revoked_by_type", identity_string(16)), Column("revoked_by_id", identity_string(255)), - UniqueConstraint( - "grantor_key_hash", - "idempotency_key_hash", - name="uq_pc_access_bindings_grantor_idempotency", - ), - CheckConstraint("version > 0", name="ck_pc_access_bindings_version_positive"), + Column("revoked_by_description", Text), + CheckConstraint("version > 0", name="ck_pc_access_relationships_version_positive"), +) + +ACCESS_OWNERS_TABLE = Table( + "pc_access_artifact_owners", + ACCESS_METADATA, + Column("resource_key_hash", identity_string(64), primary_key=True), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), nullable=False), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), nullable=False), + Column("selector_type", identity_string(32)), + Column("selector_entry_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), + Column("owner_type", identity_string(16), nullable=False), + Column("owner_id", identity_string(255), nullable=False), + Column("owner_description", Text), + Column("established_at", identity_string(32), nullable=False), + Column("policy_revision", identity_string(MAX_POLICY_REVISION_LENGTH), nullable=False), + Column("idempotency_key", identity_string(255), nullable=False), +) + +ACCESS_RECEIVER_LEASES_TABLE = Table( + "pc_access_handoff_receiver_leases", + ACCESS_METADATA, + Column("resource_key_hash", identity_string(64), primary_key=True), + Column("binding_id", identity_string(64), unique=True), +) + +ACCESS_CANDIDATE_OWNERS_TABLE = Table( + "pc_access_candidate_owners", + ACCESS_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("candidate_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), nullable=False), + Column("owner_type", identity_string(16), nullable=False), + Column("owner_id", identity_string(255), nullable=False), + Column("owner_description", Text), + Column("target_artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), + Column("idempotency_key", identity_string(255), nullable=False), +) + +ACCESS_IDEMPOTENCY_TABLE = Table( + "pc_access_idempotency", + ACCESS_METADATA, + Column("actor_id", identity_string(255), primary_key=True), + Column("idempotency_key_hash", identity_string(64), primary_key=True), + Column("operation", identity_string(64), nullable=False), + Column("payload_hash", identity_string(64), nullable=False), + Column("result_binding_id", identity_string(64)), + Column("secondary_binding_id", identity_string(64)), + UniqueConstraint("actor_id", "idempotency_key_hash", name="uq_pc_access_idempotency_actor_key"), ) ACCESS_AUDIT_EVENTS_TABLE = Table( - "pc_access_audit_events", + "pc_access_audit", ACCESS_METADATA, Column("cursor", Integer, primary_key=True, autoincrement=True), Column("event_id", identity_string(64), nullable=False, unique=True), @@ -120,148 +170,170 @@ Column("request_id", identity_string(128)), Column("transport", identity_string(16), nullable=False), Column("operation", identity_string(128), nullable=False), - Column("principal_type", identity_string(64), nullable=False), - Column("principal_issuer", identity_string(255), nullable=False), + Column("principal_type", identity_string(16), nullable=False), Column("principal_id", identity_string(255), nullable=False), + Column("principal_description", Text), Column("action", identity_string(64), nullable=False), Column("resource_type", identity_string(16), nullable=False), Column("deployment_id", identity_string(128)), Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH)), Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), - Column("revision", Integer), Column("selector_type", identity_string(32)), Column("selector_entry_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), - Column("selector_entry_version_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), Column("allowed", Boolean, nullable=False), Column("reason_code", identity_string(64), nullable=False), Column("policy_revision", identity_string(MAX_POLICY_REVISION_LENGTH)), + Column("matched_subject_type", identity_string(16)), + Column("matched_subject_id", identity_string(255)), + Column("matched_subject_description", Text), Column("binding_id", identity_string(64)), - Column("target_type", identity_string(64)), - Column("target_issuer", identity_string(255)), + Column("target_type", identity_string(16)), Column("target_id", identity_string(255)), + Column("target_description", Text), Column("role", identity_string(32)), + Column("expected_version", Integer), + Column("result_version", Integer), ) -ACCESS_TABLES = (ACCESS_POLICY_HEADS_TABLE, ACCESS_BINDINGS_TABLE, ACCESS_AUDIT_EVENTS_TABLE) -_POLICY_HEAD = "authorization" -_MYSQL_POLICY_REVISION_LENGTH_SQL = text( - """ - SELECT character_maximum_length - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = :table_name - AND column_name = 'policy_revision' - """ +ACCESS_TABLES = ( + ACCESS_POLICY_HEADS_TABLE, + ACCESS_BINDINGS_TABLE, + ACCESS_OWNERS_TABLE, + ACCESS_CANDIDATE_OWNERS_TABLE, + ACCESS_RECEIVER_LEASES_TABLE, + ACCESS_IDEMPOTENCY_TABLE, + ACCESS_AUDIT_EVENTS_TABLE, ) -_MYSQL_POLICY_REVISION_MIGRATIONS = { - "pc_access_bindings": ( - "ALTER TABLE pc_access_bindings MODIFY COLUMN policy_revision " - f"VARCHAR({MAX_POLICY_REVISION_LENGTH}) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL" - ), - "pc_access_audit_events": ( - "ALTER TABLE pc_access_audit_events MODIFY COLUMN policy_revision " - f"VARCHAR({MAX_POLICY_REVISION_LENGTH}) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL" - ), -} - - -async def ensure_access_policy_revision_columns(connection: AsyncConnection, /) -> None: - """Widen legacy MySQL-compatible Access revision columns without shrinking newer schemas.""" - - if connection.dialect.name == "sqlite": - # SQLite's VARCHAR length is descriptive and does not constrain stored text. - return - if connection.dialect.name != "mysql": - raise ValueError(f"unsupported Access schema migration dialect: {connection.dialect.name}") # noqa: TRY003 - - for table_name, migration_sql in _MYSQL_POLICY_REVISION_MIGRATIONS.items(): - current_length = await connection.scalar( - _MYSQL_POLICY_REVISION_LENGTH_SQL, - {"table_name": table_name}, - ) - if current_length is not None and int(current_length) < MAX_POLICY_REVISION_LENGTH: - await connection.exec_driver_sql(migration_sql) +_POLICY_HEAD = "authorization" + + +async def ensure_access_policy_revision_columns(_connection: object, /) -> None: + """Keep the composition hook; the terminal schema needs no in-place migration.""" class RelationalAccessRepository: - """Persist bindings, policy revisions, and data-minimized audit events.""" + """Persist logical bindings, direct ownership and minimized audit events.""" def __init__(self, database: AsyncDatabase) -> None: self._database = database async def policy_revision(self) -> str: async with self._database.transaction() as connection: - row = ( - await connection.execute( - select(ACCESS_POLICY_HEADS_TABLE.c.revision).where(ACCESS_POLICY_HEADS_TABLE.c.name == _POLICY_HEAD) + revision = await connection.scalar( + select(ACCESS_POLICY_HEADS_TABLE.c.revision).where(ACCESS_POLICY_HEADS_TABLE.c.name == _POLICY_HEAD) + ) + return str(revision or 0) + + async def active_bindings( + self, + subjects: Sequence[AccessSubjectRef], + *, + now: datetime, + ) -> tuple[AccessBinding, ...]: + if not subjects: + return () + statement = select(ACCESS_BINDINGS_TABLE).where( + or_( + *( + (ACCESS_BINDINGS_TABLE.c.subject_type == subject.type) + & (ACCESS_BINDINGS_TABLE.c.subject_id == subject.id) + for subject in subjects ) - ).scalar_one_or_none() - return str(row or 0) + ), + ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value, + ) + async with self._database.transaction() as connection: + rows = (await connection.execute(statement)).mappings().all() + return tuple(binding for row in rows if (binding := _decode_binding(row)).active_at(now)) - async def active_bindings(self, subject: PrincipalRef, *, now: datetime) -> tuple[AccessBinding, ...]: + async def get_binding(self, binding_id: str, /) -> AccessBinding | None: async with self._database.transaction() as connection: - rows = ( + row = await _binding_by_id(connection, binding_id) + return None if row is None else _decode_binding(row) + + async def list_bindings(self, request: BindingSearchRequest, /) -> tuple[AccessBinding, ...]: + statement = select(ACCESS_BINDINGS_TABLE).where(*_boundary_predicates(request.management_resource)) + if request.subject is not None: + statement = statement.where( + ACCESS_BINDINGS_TABLE.c.subject_type == request.subject.type, + ACCESS_BINDINGS_TABLE.c.subject_id == request.subject.id, + ) + if request.role is not None: + statement = statement.where(ACCESS_BINDINGS_TABLE.c.role == request.role.value) + if request.state is not None: + statement = statement.where(ACCESS_BINDINGS_TABLE.c.state == request.state.value) + if request.visible_roles: + statement = statement.where( + ACCESS_BINDINGS_TABLE.c.role.in_(tuple(role.value for role in request.visible_roles)) + ) + if request.cursor is not None: + statement = statement.where(ACCESS_BINDINGS_TABLE.c.binding_id > request.cursor) + statement = statement.order_by(ACCESS_BINDINGS_TABLE.c.binding_id).limit(request.limit) + async with self._database.transaction() as connection: + rows = (await connection.execute(statement)).mappings().all() + return tuple(_decode_binding(row) for row in rows) + + async def establish_artifact_owner( + self, + relation: ArtifactOwnerRelation, + /, + ) -> ArtifactOwnerRelation: + if relation.resource.type is not AccessResourceType.ARTIFACT: + raise AccessInvalidRequestError("artifact-identity") + resource_hash = _digest(relation.resource.key) + async with self._database.transaction() as connection: + existing = ( ( await connection.execute( - select(ACCESS_BINDINGS_TABLE).where( - ACCESS_BINDINGS_TABLE.c.subject_type == subject.type, - ACCESS_BINDINGS_TABLE.c.subject_issuer == subject.issuer, - ACCESS_BINDINGS_TABLE.c.subject_id == subject.id, - ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value, - ) + select(ACCESS_OWNERS_TABLE).where(ACCESS_OWNERS_TABLE.c.resource_key_hash == resource_hash) ) ) .mappings() - .all() + .one_or_none() ) - return tuple(binding for row in rows if (binding := _decode_binding(row)).active_at(now)) + if existing is not None: + owner = _decode_owner(existing) + if owner.owner == relation.owner and owner.idempotency_key == relation.idempotency_key: + return owner + raise AccessConflictError("artifact-owner") + revision = await self._increment_policy_revision(connection) + established = replace(relation, policy_revision=str(revision)) + try: + await connection.execute(insert(ACCESS_OWNERS_TABLE).values(_owner_row(established))) + except IntegrityError as error: + raise AccessConflictError("artifact-owner") from error + return established - async def get_binding(self, binding_id: str) -> AccessBinding | None: + async def get_artifact_owner(self, resource: ResourceRef, /) -> ArtifactOwnerRelation | None: + if resource.type is not AccessResourceType.ARTIFACT: + return None async with self._database.transaction() as connection: row = ( ( await connection.execute( - select(ACCESS_BINDINGS_TABLE).where(ACCESS_BINDINGS_TABLE.c.binding_id == binding_id) + select(ACCESS_OWNERS_TABLE).where( + ACCESS_OWNERS_TABLE.c.resource_key_hash == _digest(resource.key) + ) ) ) .mappings() .one_or_none() ) - return None if row is None else _decode_binding(row) + return None if row is None else _decode_owner(row) - async def list_bindings( + async def attest_candidate_owner( self, - *, - subject: PrincipalRef | None = None, - resource: ResourceRef | None = None, - include_revoked: bool = False, - ) -> tuple[AccessBinding, ...]: - statement = select(ACCESS_BINDINGS_TABLE) - if subject is not None: - statement = statement.where( - ACCESS_BINDINGS_TABLE.c.subject_type == subject.type, - ACCESS_BINDINGS_TABLE.c.subject_issuer == subject.issuer, - ACCESS_BINDINGS_TABLE.c.subject_id == subject.id, - ) - if resource is not None: - statement = statement.where(*_binding_resource_predicates(resource)) - if not include_revoked: - statement = statement.where(ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value) - statement = statement.order_by(ACCESS_BINDINGS_TABLE.c.created_at, ACCESS_BINDINGS_TABLE.c.binding_id) - async with self._database.transaction() as connection: - rows = (await connection.execute(statement)).mappings().all() - return tuple(_decode_binding(row) for row in rows) - - async def create_binding(self, binding: AccessBinding) -> AccessBinding: + attestation: CandidateOwnerAttestation, + /, + ) -> CandidateOwnerAttestation: async with self._database.transaction() as connection: existing = ( ( await connection.execute( - select(ACCESS_BINDINGS_TABLE).where( - ACCESS_BINDINGS_TABLE.c.grantor_key_hash == _digest(binding.granted_by.key), - ACCESS_BINDINGS_TABLE.c.idempotency_key_hash - == _idempotency_digest(binding.resource, binding.idempotency_key), + select(ACCESS_CANDIDATE_OWNERS_TABLE).where( + ACCESS_CANDIDATE_OWNERS_TABLE.c.scope_id == attestation.scope_id, + ACCESS_CANDIDATE_OWNERS_TABLE.c.candidate_id == attestation.candidate_id, ) ) ) @@ -269,42 +341,127 @@ async def create_binding(self, binding: AccessBinding) -> AccessBinding: .one_or_none() ) if existing is not None: - decoded = _decode_binding(existing) - if _same_creation(decoded, binding): + decoded = _decode_candidate_owner(existing) + if decoded == attestation: return decoded - raise AccessConflictError("idempotency-key") + raise AccessConflictError("candidate-owner") + try: + await connection.execute( + insert(ACCESS_CANDIDATE_OWNERS_TABLE).values(_candidate_owner_row(attestation)) + ) + except IntegrityError as error: + raise AccessConflictError("candidate-owner") from error + return attestation + + async def get_candidate_owner( + self, + scope_id: str, + candidate_id: str, + /, + ) -> CandidateOwnerAttestation | None: + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select(ACCESS_CANDIDATE_OWNERS_TABLE).where( + ACCESS_CANDIDATE_OWNERS_TABLE.c.scope_id == scope_id, + ACCESS_CANDIDATE_OWNERS_TABLE.c.candidate_id == candidate_id, + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _decode_candidate_owner(row) + + async def list_owned_resources(self, owner: PrincipalRef, /) -> tuple[ResourceRef, ...]: + async with self._database.transaction() as connection: + rows = ( + ( + await connection.execute( + select(ACCESS_OWNERS_TABLE).where( + ACCESS_OWNERS_TABLE.c.owner_type == owner.type, + ACCESS_OWNERS_TABLE.c.owner_id == owner.id, + ) + ) + ) + .mappings() + .all() + ) + return tuple(_decode_resource(row, artifact_only=True) for row in rows) + + async def create_binding(self, binding: AccessBinding, /) -> AccessBinding: + payload_hash = _creation_hash(binding) + async with self._database.transaction() as connection: + replay = await _idempotent_result( + connection, + actor=binding.granted_by, + key=binding.idempotency_key, + operation="binding.create", + payload_hash=payload_hash, + ) + if replay is not None: + row = await _binding_by_id(connection, replay[0]) + if row is None: + raise AccessConflictError("idempotency-key") + return _decode_binding(row) + if binding.role is AccessRole.HANDOFF_RECEIVER: + await _claim_receiver_lease(connection, binding, now=binding.created_at) revision = await self._increment_policy_revision(connection) created = replace(binding, policy_revision=str(revision)) try: await connection.execute(insert(ACCESS_BINDINGS_TABLE).values(_binding_row(created))) + await _record_idempotency( + connection, + actor=binding.granted_by, + key=binding.idempotency_key, + operation="binding.create", + payload_hash=payload_hash, + result_binding_id=binding.binding_id, + ) except IntegrityError as error: raise AccessConflictError("idempotency-key") from error - return created + return created async def revoke_binding( self, binding_id: str, + /, *, expected_version: int, + idempotency_key: str, revoked_at: datetime, revoked_by: PrincipalRef, ) -> AccessBinding: + payload_hash = _digest(f"{binding_id}\0{expected_version}") async with self._database.transaction() as connection: - row = ( - ( - await connection.execute( - select(ACCESS_BINDINGS_TABLE).where(ACCESS_BINDINGS_TABLE.c.binding_id == binding_id) - ) - ) - .mappings() - .one_or_none() + replay = await _idempotent_result( + connection, + actor=revoked_by, + key=idempotency_key, + operation="binding.revoke", + payload_hash=payload_hash, ) - if row is None: + if replay is not None: + row = await _binding_by_id(connection, replay[0]) + if row is None: + raise AccessConflictError("idempotency-key") + return _decode_binding(row) + current_row = await _binding_by_id(connection, binding_id, for_update=True) + if current_row is None: raise AccessConflictError("binding-version") - current = _decode_binding(row) + current = _decode_binding(current_row) if current.version != expected_version or current.state is not AccessBindingState.ACTIVE: raise AccessConflictError("binding-version") revision = await self._increment_policy_revision(connection) + revoked = replace( + current, + state=AccessBindingState.REVOKED, + version=expected_version + 1, + policy_revision=str(revision), + revoked_at=revoked_at, + revoked_by=revoked_by, + ) result = await connection.execute( update(ACCESS_BINDINGS_TABLE) .where( @@ -312,51 +469,176 @@ async def revoke_binding( ACCESS_BINDINGS_TABLE.c.version == expected_version, ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value, ) - .values( - state=AccessBindingState.REVOKED.value, - version=expected_version + 1, - policy_revision=str(revision), - revoked_at=_timestamp(revoked_at), - revoked_by_type=revoked_by.type, - revoked_by_issuer=revoked_by.issuer, - revoked_by_id=revoked_by.id, - ) + .values(_binding_mutation_row(revoked)) ) if result.rowcount != 1: raise AccessConflictError("binding-version") - return replace( - current, + if current.role is AccessRole.HANDOFF_RECEIVER: + await connection.execute( + update(ACCESS_RECEIVER_LEASES_TABLE) + .where(ACCESS_RECEIVER_LEASES_TABLE.c.binding_id == binding_id) + .values(binding_id=None) + ) + await _record_idempotency( + connection, + actor=revoked_by, + key=idempotency_key, + operation="binding.revoke", + payload_hash=payload_hash, + result_binding_id=binding_id, + ) + return revoked + + async def reassign_handoff_receiver( + self, + request: ReassignHandoffReceiver, + /, + *, + actor: PrincipalRef, + changed_at: datetime, + ) -> HandoffReceiverReassignment: + payload_hash = _digest( + "\0".join(( + request.binding_id, + str(request.expected_version), + request.subject.type, + request.subject.id, + request.reason or "", + "" if request.expires_at is None else _timestamp(request.expires_at), + )) + ) + async with self._database.transaction() as connection: + replay = await _idempotent_result( + connection, + actor=actor, + key=request.idempotency_key, + operation="handoff.receiver.reassign", + payload_hash=payload_hash, + ) + if replay is not None: + old_row = await _binding_by_id(connection, replay[0]) + new_row = None if replay[1] is None else await _binding_by_id(connection, replay[1]) + if old_row is None or new_row is None: + raise AccessConflictError("idempotency-key") + return HandoffReceiverReassignment(_decode_binding(old_row), _decode_binding(new_row)) + old_row = await _binding_by_id(connection, request.binding_id, for_update=True) + if old_row is None: + raise AccessConflictError("binding-version") + old = _decode_binding(old_row) + if ( + old.role is not AccessRole.HANDOFF_RECEIVER + or old.version != request.expected_version + or old.state is not AccessBindingState.ACTIVE + ): + raise AccessConflictError("binding-version") + lease = ( + ( + await connection.execute( + select(ACCESS_RECEIVER_LEASES_TABLE) + .where(ACCESS_RECEIVER_LEASES_TABLE.c.resource_key_hash == _digest(old.resource.key)) + .with_for_update() + ) + ) + .mappings() + .one_or_none() + ) + if lease is None or lease["binding_id"] != old.binding_id: + raise AccessConflictError("handoff_receiver_conflict") + revision = await self._increment_policy_revision(connection) + revoked = replace( + old, state=AccessBindingState.REVOKED, - version=expected_version + 1, + version=old.version + 1, policy_revision=str(revision), - revoked_at=revoked_at, - revoked_by=revoked_by, + revoked_at=changed_at, + revoked_by=actor, ) + created = AccessBinding( + binding_id=f"bind_{sha256(f'{request.idempotency_key}:{old.binding_id}'.encode()).hexdigest()[:32]}", + subject=request.subject, + resource=old.resource, + role=AccessRole.HANDOFF_RECEIVER, + granted_by=actor, + reason=request.reason, + created_at=changed_at, + expires_at=request.expires_at, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision=str(revision), + idempotency_key=request.idempotency_key, + ) + result = await connection.execute( + update(ACCESS_BINDINGS_TABLE) + .where( + ACCESS_BINDINGS_TABLE.c.binding_id == old.binding_id, + ACCESS_BINDINGS_TABLE.c.version == request.expected_version, + ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value, + ) + .values(_binding_mutation_row(revoked)) + ) + if result.rowcount != 1: + raise AccessConflictError("binding-version") + try: + await connection.execute(insert(ACCESS_BINDINGS_TABLE).values(_binding_row(created))) + lease_result = await connection.execute( + update(ACCESS_RECEIVER_LEASES_TABLE) + .where( + ACCESS_RECEIVER_LEASES_TABLE.c.resource_key_hash == _digest(old.resource.key), + ACCESS_RECEIVER_LEASES_TABLE.c.binding_id == old.binding_id, + ) + .values(binding_id=created.binding_id) + ) + if lease_result.rowcount != 1: + raise AccessConflictError("handoff_receiver_conflict") + await _record_idempotency( + connection, + actor=actor, + key=request.idempotency_key, + operation="handoff.receiver.reassign", + payload_hash=payload_hash, + result_binding_id=old.binding_id, + secondary_binding_id=created.binding_id, + ) + except IntegrityError as error: + raise AccessConflictError("idempotency-key") from error + return HandoffReceiverReassignment(revoked, created) - async def append_audit(self, event: AccessAuditEvent) -> AccessAuditEvent: + async def append_audit(self, event: AccessAuditEvent, /) -> AccessAuditEvent: async with self._database.transaction() as connection: await connection.execute(insert(ACCESS_AUDIT_EVENTS_TABLE).values(_audit_row(event))) - cursor = ( - await connection.execute( - select(ACCESS_AUDIT_EVENTS_TABLE.c.cursor).where( - ACCESS_AUDIT_EVENTS_TABLE.c.event_id == event.event_id - ) - ) - ).scalar_one() + cursor = await connection.scalar( + select(ACCESS_AUDIT_EVENTS_TABLE.c.cursor).where(ACCESS_AUDIT_EVENTS_TABLE.c.event_id == event.event_id) + ) + if cursor is None: + raise RuntimeError("Access audit insert did not return a cursor") # noqa: TRY003 return replace(event, cursor=int(cursor)) async def list_audit( self, *, - resource: ResourceRef | None = None, + resource: ResourceRef, after: int | None = None, limit: int = 100, + action: AccessAction | None = None, + subject: AccessSubjectRef | None = None, + allowed: bool | None = None, + occurred_after: datetime | None = None, + occurred_before: datetime | None = None, ) -> tuple[AccessAuditEvent, ...]: - statement = select(ACCESS_AUDIT_EVENTS_TABLE).where( - ACCESS_AUDIT_EVENTS_TABLE.c.action != AccessAction.ACCESS_SELF.value - ) - if resource is not None: - statement = statement.where(*_audit_resource_predicates(resource)) + statement = select(ACCESS_AUDIT_EVENTS_TABLE).where(*_boundary_predicates(resource, audit=True)) + if action is not None: + statement = statement.where(ACCESS_AUDIT_EVENTS_TABLE.c.action == action.value) + if subject is not None: + statement = statement.where( + ACCESS_AUDIT_EVENTS_TABLE.c.principal_type == subject.type, + ACCESS_AUDIT_EVENTS_TABLE.c.principal_id == subject.id, + ) + if allowed is not None: + statement = statement.where(ACCESS_AUDIT_EVENTS_TABLE.c.allowed == allowed) + if occurred_after is not None: + statement = statement.where(ACCESS_AUDIT_EVENTS_TABLE.c.occurred_at >= occurred_after.isoformat()) + if occurred_before is not None: + statement = statement.where(ACCESS_AUDIT_EVENTS_TABLE.c.occurred_at < occurred_before.isoformat()) if after is not None: statement = statement.where(ACCESS_AUDIT_EVENTS_TABLE.c.cursor > after) statement = statement.order_by(ACCESS_AUDIT_EVENTS_TABLE.c.cursor).limit(limit) @@ -366,11 +648,11 @@ async def list_audit( @staticmethod async def _increment_policy_revision(connection: Any) -> int: - current = ( - await connection.execute( - select(ACCESS_POLICY_HEADS_TABLE.c.revision).where(ACCESS_POLICY_HEADS_TABLE.c.name == _POLICY_HEAD) - ) - ).scalar_one_or_none() + current = await connection.scalar( + select(ACCESS_POLICY_HEADS_TABLE.c.revision) + .where(ACCESS_POLICY_HEADS_TABLE.c.name == _POLICY_HEAD) + .with_for_update() + ) if current is None: try: await connection.execute(insert(ACCESS_POLICY_HEADS_TABLE).values(name=_POLICY_HEAD, revision=1)) @@ -383,14 +665,126 @@ async def _increment_policy_revision(connection: Any) -> int: ACCESS_POLICY_HEADS_TABLE.c.name == _POLICY_HEAD, ACCESS_POLICY_HEADS_TABLE.c.revision == current, ) - .values(revision=current + 1) + .values(revision=int(current) + 1) ) if result.rowcount != 1: raise AccessConflictError("binding-version") return int(current) + 1 -def _resource_predicates(table: Table, resource: ResourceRef) -> Sequence[Any]: +async def _binding_by_id(connection: Any, binding_id: str, *, for_update: bool = False) -> Mapping[Any, Any] | None: + statement = select(ACCESS_BINDINGS_TABLE).where(ACCESS_BINDINGS_TABLE.c.binding_id == binding_id) + if for_update: + statement = statement.with_for_update() + return (await connection.execute(statement)).mappings().one_or_none() + + +async def _idempotent_result( + connection: Any, + *, + actor: PrincipalRef, + key: str, + operation: str, + payload_hash: str, +) -> tuple[str, str | None] | None: + row = ( + ( + await connection.execute( + select(ACCESS_IDEMPOTENCY_TABLE).where( + ACCESS_IDEMPOTENCY_TABLE.c.actor_id == actor.id, + ACCESS_IDEMPOTENCY_TABLE.c.idempotency_key_hash == _digest(key), + ) + ) + ) + .mappings() + .one_or_none() + ) + if row is None: + return None + if row["operation"] != operation or row["payload_hash"] != payload_hash: + raise AccessConflictError("idempotency-key") + result = row["result_binding_id"] + if result is None: + raise AccessConflictError("idempotency-key") + secondary = row["secondary_binding_id"] + return str(result), None if secondary is None else str(secondary) + + +async def _record_idempotency( + connection: Any, + *, + actor: PrincipalRef, + key: str, + operation: str, + payload_hash: str, + result_binding_id: str, + secondary_binding_id: str | None = None, +) -> None: + await connection.execute( + insert(ACCESS_IDEMPOTENCY_TABLE).values( + actor_id=actor.id, + idempotency_key_hash=_digest(key), + operation=operation, + payload_hash=payload_hash, + result_binding_id=result_binding_id, + secondary_binding_id=secondary_binding_id, + ) + ) + + +async def _claim_receiver_lease(connection: Any, binding: AccessBinding, *, now: datetime) -> None: + resource_hash = _digest(binding.resource.key) + lease = ( + ( + await connection.execute( + select(ACCESS_RECEIVER_LEASES_TABLE) + .where(ACCESS_RECEIVER_LEASES_TABLE.c.resource_key_hash == resource_hash) + .with_for_update() + ) + ) + .mappings() + .one_or_none() + ) + if lease is None: + try: + await connection.execute( + insert(ACCESS_RECEIVER_LEASES_TABLE).values( + resource_key_hash=resource_hash, + binding_id=binding.binding_id, + ) + ) + except IntegrityError as error: + raise AccessConflictError("handoff_receiver_conflict") from error + else: + return + prior_id = None if lease["binding_id"] is None else str(lease["binding_id"]) + prior_row = None if prior_id is None else await _binding_by_id(connection, prior_id, for_update=True) + if prior_row is not None and _decode_binding(prior_row).active_at(now): + raise AccessConflictError("handoff_receiver_conflict") + result = await connection.execute( + update(ACCESS_RECEIVER_LEASES_TABLE) + .where( + ACCESS_RECEIVER_LEASES_TABLE.c.resource_key_hash == resource_hash, + ACCESS_RECEIVER_LEASES_TABLE.c.binding_id.is_(None) + if prior_id is None + else ACCESS_RECEIVER_LEASES_TABLE.c.binding_id == prior_id, + ) + .values(binding_id=binding.binding_id) + ) + if result.rowcount != 1: + raise AccessConflictError("handoff_receiver_conflict") + + +def _boundary_predicates(resource: ResourceRef, *, audit: bool = False) -> tuple[Any, ...]: + table = ACCESS_AUDIT_EVENTS_TABLE if audit else ACCESS_BINDINGS_TABLE + if resource.type is AccessResourceType.SERVER: + return () + if resource.type is AccessResourceType.SCOPE: + return (table.c.scope_id == resource.scope_id,) + return _resource_predicates(table, resource) + + +def _resource_predicates(table: Table, resource: ResourceRef) -> tuple[Any, ...]: selector = resource.selector return ( table.c.resource_type == resource.type.value, @@ -398,49 +792,32 @@ def _resource_predicates(table: Table, resource: ResourceRef) -> Sequence[Any]: table.c.scope_id == resource.scope_id, table.c.family == resource.family, table.c.artifact_id == resource.artifact_id, - table.c.revision == resource.revision, table.c.selector_type == (None if selector is None else selector.type), table.c.selector_entry_id == (None if selector is None else selector.entry_id), - table.c.selector_entry_version_id == (None if selector is None else selector.entry_version_id), ) -def _binding_resource_predicates(resource: ResourceRef) -> Sequence[Any]: - if resource.type is AccessResourceType.SERVER: - return () - if resource.type is AccessResourceType.SCOPE: - return (ACCESS_BINDINGS_TABLE.c.scope_id == resource.scope_id,) - return _resource_predicates(ACCESS_BINDINGS_TABLE, resource) - - -def _audit_resource_predicates(resource: ResourceRef) -> Sequence[Any]: - if resource.type is AccessResourceType.SCOPE: - return (ACCESS_AUDIT_EVENTS_TABLE.c.scope_id == resource.scope_id,) - return _resource_predicates(ACCESS_AUDIT_EVENTS_TABLE, resource) +def _resource_row(resource: ResourceRef) -> dict[str, object | None]: + selector = resource.selector + return { + "resource_type": resource.type.value, + "deployment_id": resource.deployment_id, + "scope_id": resource.scope_id, + "family": resource.family, + "artifact_id": resource.artifact_id, + "selector_type": None if selector is None else selector.type, + "selector_entry_id": None if selector is None else selector.entry_id, + } def _binding_row(binding: AccessBinding) -> dict[str, object | None]: - revoked_by = binding.revoked_by - selector = binding.resource.selector return { "binding_id": binding.binding_id, - "subject_type": binding.subject.type, - "subject_issuer": binding.subject.issuer, - "subject_id": binding.subject.id, - "resource_type": binding.resource.type.value, - "deployment_id": binding.resource.deployment_id, - "scope_id": binding.resource.scope_id, - "family": binding.resource.family, - "artifact_id": binding.resource.artifact_id, - "revision": binding.resource.revision, - "selector_type": None if selector is None else selector.type, - "selector_entry_id": None if selector is None else selector.entry_id, - "selector_entry_version_id": None if selector is None else selector.entry_version_id, + **_subject_row("subject", binding.subject), + "resource_key_hash": _digest(binding.resource.key), + **_resource_row(binding.resource), "role": binding.role.value, - "granted_by_type": binding.granted_by.type, - "granted_by_issuer": binding.granted_by.issuer, - "granted_by_id": binding.granted_by.id, - "grantor_key_hash": _digest(binding.granted_by.key), + **_subject_row("granted_by", binding.granted_by), "reason": binding.reason, "created_at": _timestamp(binding.created_at), "expires_at": None if binding.expires_at is None else _timestamp(binding.expires_at), @@ -448,21 +825,26 @@ def _binding_row(binding: AccessBinding) -> dict[str, object | None]: "version": binding.version, "policy_revision": binding.policy_revision, "idempotency_key": binding.idempotency_key, - "idempotency_key_hash": _idempotency_digest(binding.resource, binding.idempotency_key), "revoked_at": None if binding.revoked_at is None else _timestamp(binding.revoked_at), - "revoked_by_type": None if revoked_by is None else revoked_by.type, - "revoked_by_issuer": None if revoked_by is None else revoked_by.issuer, - "revoked_by_id": None if revoked_by is None else revoked_by.id, + **_optional_subject_row("revoked_by", binding.revoked_by), + } + + +def _binding_mutation_row(binding: AccessBinding) -> dict[str, object | None]: + return { + "state": binding.state.value, + "version": binding.version, + "policy_revision": binding.policy_revision, + "revoked_at": None if binding.revoked_at is None else _timestamp(binding.revoked_at), + **_optional_subject_row("revoked_by", binding.revoked_by), } def _decode_binding(row: Mapping[Any, Any]) -> AccessBinding: - resource = _decode_resource(row) - revoked_by = _optional_principal(row, "revoked_by") return AccessBinding( binding_id=str(row["binding_id"]), - subject=_principal(row, "subject"), - resource=resource, + subject=_subject(row, "subject"), + resource=_decode_resource(row), role=AccessRole(str(row["role"])), granted_by=_principal(row, "granted_by"), reason=None if row["reason"] is None else str(row["reason"]), @@ -473,40 +855,83 @@ def _decode_binding(row: Mapping[Any, Any]) -> AccessBinding: policy_revision=str(row["policy_revision"]), idempotency_key=str(row["idempotency_key"]), revoked_at=None if row["revoked_at"] is None else _parse_timestamp(row["revoked_at"]), - revoked_by=revoked_by, + revoked_by=_optional_principal(row, "revoked_by"), + ) + + +def _owner_row(relation: ArtifactOwnerRelation) -> dict[str, object | None]: + resource = relation.resource + selector = resource.selector + return { + "resource_key_hash": _digest(resource.key), + "scope_id": resource.scope_id, + "family": resource.family, + "artifact_id": resource.artifact_id, + "selector_type": None if selector is None else selector.type, + "selector_entry_id": None if selector is None else selector.entry_id, + **_subject_row("owner", relation.owner), + "established_at": _timestamp(relation.established_at), + "policy_revision": relation.policy_revision, + "idempotency_key": relation.idempotency_key, + } + + +def _decode_owner(row: Mapping[Any, Any]) -> ArtifactOwnerRelation: + return ArtifactOwnerRelation( + resource=_decode_resource(row, artifact_only=True), + owner=_principal(row, "owner"), + established_at=_parse_timestamp(row["established_at"]), + policy_revision=str(row["policy_revision"]), + idempotency_key=str(row["idempotency_key"]), + ) + + +def _candidate_owner_row(attestation: CandidateOwnerAttestation) -> dict[str, object | None]: + return { + "scope_id": attestation.scope_id, + "candidate_id": attestation.candidate_id, + "family": attestation.family, + **_subject_row("owner", attestation.proposed_owner), + "target_artifact_id": None if attestation.target is None else attestation.target.artifact_id, + "idempotency_key": attestation.idempotency_key, + } + + +def _decode_candidate_owner(row: Mapping[Any, Any]) -> CandidateOwnerAttestation: + scope_id = str(row["scope_id"]) + family = str(row["family"]) + target_id = row["target_artifact_id"] + return CandidateOwnerAttestation( + scope_id=scope_id, + candidate_id=str(row["candidate_id"]), + family=family, + proposed_owner=_principal(row, "owner"), + target=( + None if target_id is None else ResourceRef.artifact(scope_id, family=family, artifact_id=str(target_id)) + ), + idempotency_key=str(row["idempotency_key"]), ) def _audit_row(event: AccessAuditEvent) -> dict[str, object | None]: - target = event.target - selector = event.resource.selector return { "event_id": event.event_id, "occurred_at": _timestamp(event.occurred_at), "request_id": event.request_id, "transport": event.transport, "operation": event.operation, - "principal_type": event.principal.type, - "principal_issuer": event.principal.issuer, - "principal_id": event.principal.id, + **_subject_row("principal", event.principal), "action": event.action.value, - "resource_type": event.resource.type.value, - "deployment_id": event.resource.deployment_id, - "scope_id": event.resource.scope_id, - "family": event.resource.family, - "artifact_id": event.resource.artifact_id, - "revision": event.resource.revision, - "selector_type": None if selector is None else selector.type, - "selector_entry_id": None if selector is None else selector.entry_id, - "selector_entry_version_id": None if selector is None else selector.entry_version_id, + **_resource_row(event.resource), "allowed": event.allowed, "reason_code": event.reason_code, "policy_revision": event.policy_revision, + **_optional_subject_row("matched_subject", event.matched_subject), "binding_id": event.binding_id, - "target_type": None if target is None else target.type, - "target_issuer": None if target is None else target.issuer, - "target_id": None if target is None else target.id, + **_optional_subject_row("target", event.target), "role": None if event.role is None else event.role.value, + "expected_version": event.expected_version, + "result_version": event.result_version, } @@ -524,49 +949,90 @@ def _decode_audit(row: Mapping[Any, Any]) -> AccessAuditEvent: allowed=bool(row["allowed"]), reason_code=str(row["reason_code"]), policy_revision=None if row["policy_revision"] is None else str(row["policy_revision"]), + matched_subject=_optional_subject(row, "matched_subject"), binding_id=None if row["binding_id"] is None else str(row["binding_id"]), - target=_optional_principal(row, "target"), + target=_optional_subject(row, "target"), role=None if row["role"] is None else AccessRole(str(row["role"])), + expected_version=None if row["expected_version"] is None else int(row["expected_version"]), + result_version=None if row["result_version"] is None else int(row["result_version"]), ) -def _decode_resource(row: Mapping[Any, Any]) -> ResourceRef: - resource_type = AccessResourceType(str(row["resource_type"])) +def _decode_resource(row: Mapping[Any, Any], *, artifact_only: bool = False) -> ResourceRef: + resource_type = AccessResourceType.ARTIFACT if artifact_only else AccessResourceType(str(row["resource_type"])) if resource_type is AccessResourceType.SERVER: - deployment_id = row["deployment_id"] - if deployment_id is None: - raise AccessInvalidRequestError("resource") - return ResourceRef.server(str(deployment_id)) + return ResourceRef.server(str(row["deployment_id"])) if resource_type is AccessResourceType.SCOPE: return ResourceRef.scope(str(row["scope_id"])) - selector_type = row.get("selector_type") + selector_type = row["selector_type"] selector = ( None if selector_type is None - else MemoryEntrySelector( - entry_id=str(row["selector_entry_id"]), - entry_version_id=str(row["selector_entry_version_id"]), - ) + else MemoryEntrySelector(type=str(selector_type), entry_id=str(row["selector_entry_id"])) ) return ResourceRef.artifact( str(row["scope_id"]), family=str(row["family"]), artifact_id=str(row["artifact_id"]), - revision=int(row["revision"]), selector=selector, ) -def _principal(row: Mapping[Any, Any], prefix: str) -> PrincipalRef: - return PrincipalRef( - type=str(row[f"{prefix}_type"]), - issuer=str(row[f"{prefix}_issuer"]), - id=str(row[f"{prefix}_id"]), +def _subject_row(prefix: str, subject: AccessSubjectRef) -> dict[str, object | None]: + return { + f"{prefix}_type": subject.type, + f"{prefix}_id": subject.id, + f"{prefix}_description": subject.description, + } + + +def _optional_subject_row(prefix: str, subject: AccessSubjectRef | None) -> dict[str, object | None]: + return ( + {f"{prefix}_type": None, f"{prefix}_id": None, f"{prefix}_description": None} + if subject is None + else _subject_row(prefix, subject) ) +def _subject(row: Mapping[Any, Any], prefix: str) -> AccessSubjectRef: + subject_type = str(row[f"{prefix}_type"]) + values = { + "type": subject_type, + "id": str(row[f"{prefix}_id"]), + "description": None if row[f"{prefix}_description"] is None else str(row[f"{prefix}_description"]), + } + return GroupRef(**values) if subject_type == "group" else PrincipalRef(**values) + + +def _optional_subject(row: Mapping[Any, Any], prefix: str) -> AccessSubjectRef | None: + return None if row[f"{prefix}_type"] is None else _subject(row, prefix) + + +def _principal(row: Mapping[Any, Any], prefix: str) -> PrincipalRef: + value = _subject(row, prefix) + if not isinstance(value, PrincipalRef): + raise AccessInvalidRequestError("principal") + return value + + def _optional_principal(row: Mapping[Any, Any], prefix: str) -> PrincipalRef | None: - return None if row[f"{prefix}_type"] is None else _principal(row, prefix) + value = _optional_subject(row, prefix) + if value is not None and not isinstance(value, PrincipalRef): + raise AccessInvalidRequestError("principal") + return value + + +def _creation_hash(binding: AccessBinding) -> str: + return _digest( + "\0".join(( + binding.subject.type, + binding.subject.id, + binding.resource.key, + binding.role.value, + binding.reason or "", + "" if binding.expires_at is None else _timestamp(binding.expires_at), + )) + ) def _timestamp(value: datetime) -> str: @@ -579,26 +1045,8 @@ def _parse_timestamp(value: object) -> datetime: return datetime.fromisoformat(str(value)) -def _same_creation(existing: AccessBinding, requested: AccessBinding) -> bool: - return ( - existing.subject == requested.subject - and existing.resource == requested.resource - and existing.role is requested.role - and existing.reason == requested.reason - and existing.expires_at == requested.expires_at - ) - - def _digest(value: str) -> str: return sha256(value.encode("utf-8")).hexdigest() -def _idempotency_digest(resource: ResourceRef, idempotency_key: str) -> str: - return _digest(f"{resource.key}\0{idempotency_key}") - - -__all__ = ( - "ACCESS_TABLES", - "RelationalAccessRepository", - "ensure_access_policy_revision_columns", -) +__all__ = ("ACCESS_TABLES", "RelationalAccessRepository", "ensure_access_policy_revision_columns") diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index 50885647a..54ddd2778 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -16,15 +16,21 @@ from __future__ import annotations +import hashlib +import hmac +import json +import secrets from base64 import b64decode, urlsafe_b64encode from collections.abc import Awaitable, Callable, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime -from typing import Literal, Protocol, TypeVar +from typing import Protocol, TypeVar from uuid import uuid4 from powercontext.limits import MAX_POLICY_REVISION_LENGTH from powercontext.server.authz.errors import ( + AccessBindingNotFoundError, + AccessConflictError, AccessControlError, AccessDeniedError, AccessIdentityRequiredError, @@ -34,6 +40,7 @@ from powercontext.server.authz.models import ( DEFAULT_DEPLOYMENT_ID, ROLE_ACTIONS, + ROLE_CHILD_ACTIONS, AccessAction, AccessAuditEvent, AccessBinding, @@ -41,6 +48,10 @@ AccessDecision, AccessResourceType, AccessRole, + AccessSubjectRef, + ArtifactOwnerRelation, + CandidateOwnerAttestation, + GroupRef, PrincipalRef, ResourceRef, ) @@ -49,6 +60,7 @@ artifact_family_profile, validate_action_resource, validate_binding_role, + validate_binding_subject, ) _T = TypeVar("_T") @@ -57,11 +69,13 @@ @dataclass(frozen=True, slots=True) class AuthorizedResourceFilter: - """Bounded identities and parent constraints authorized before repository access.""" + """Complete bounded identities and parent constraints produced before data access.""" exact_resources: tuple[ResourceRef, ...] parent_constraints: tuple[ResourceRef, ...] + complete: bool policy_revision: str | None + max_direct_resource_keys: int @dataclass(frozen=True, slots=True) @@ -73,6 +87,22 @@ class AuthorizedResourcePage: next_cursor: str | None = None +@dataclass(frozen=True, slots=True) +class AccessBindingPage: + """One authorized page of canonical Binding metadata.""" + + items: tuple[AccessBinding, ...] + next_cursor: str | None = None + + +@dataclass(frozen=True, slots=True) +class AccessAuditPage: + """One authorized page from the append-only audit boundary.""" + + items: tuple[AccessAuditEvent, ...] + next_cursor: str | None = None + + @dataclass(frozen=True, slots=True) class AccessProviderCapabilities: """Enforcement features that one configured Provider can safely supply.""" @@ -80,15 +110,20 @@ class AccessProviderCapabilities: safe_resource_filtering: bool multi_requirement_check: bool relationship_management: bool + group_subjects: bool = False + multi_principal: bool = False + max_direct_resource_keys: int = _MAX_AUTHORIZED_FILTER_IDENTITIES @dataclass(frozen=True, slots=True) class AccessAuditContext: - """Low-sensitivity request facts attached to a decision audit event.""" + """Trusted request facts attached to one decision.""" transport: str operation: str request_id: str | None = None + actor: PrincipalRef | None = None + subject_groups: tuple[GroupRef, ...] = () @dataclass(frozen=True, slots=True) @@ -103,7 +138,7 @@ class AccessRequest: @dataclass(frozen=True, slots=True) class ResourceSearchRequest: - """Normalized request for a safe, provider-owned resource filter.""" + """Normalized request for a safe Provider-owned resource filter.""" subject: PrincipalRef action: AccessAction @@ -112,11 +147,38 @@ class ResourceSearchRequest: context: AccessAuditContext +@dataclass(frozen=True, slots=True) +class BindingSearchRequest: + """Bounded relationship query after its management boundary is authorized.""" + + management_resource: ResourceRef + subject: AccessSubjectRef | None = None + role: AccessRole | None = None + state: AccessBindingState | None = None + cursor: str | None = None + limit: int = 100 + visible_roles: tuple[AccessRole, ...] = () + + +@dataclass(frozen=True, slots=True) +class AuditSearchRequest: + """Bounded audit query under one required administration boundary.""" + + resource: ResourceRef + action: AccessAction | None = None + subject: AccessSubjectRef | None = None + allowed: bool | None = None + occurred_after: datetime | None = None + occurred_before: datetime | None = None + cursor: str | None = None + limit: int = 100 + + @dataclass(frozen=True, slots=True) class CreateBinding: """Validated intent to create one immutable Access Binding.""" - subject: PrincipalRef + subject: AccessSubjectRef resource: ResourceRef role: AccessRole idempotency_key: str @@ -124,12 +186,38 @@ class CreateBinding: expires_at: datetime | None = None def __post_init__(self) -> None: - if not self.idempotency_key or len(self.idempotency_key) > 255: - raise AccessInvalidRequestError("idempotency-key") + _validate_idempotency_key(self.idempotency_key) + if self.reason is not None and len(self.reason) > 1_024: + raise AccessInvalidRequestError("reason") + + +@dataclass(frozen=True, slots=True) +class ReassignHandoffReceiver: + """Atomic compare-and-swap receiver reassignment.""" + + binding_id: str + expected_version: int + subject: PrincipalRef + idempotency_key: str + reason: str | None = None + expires_at: datetime | None = None + + def __post_init__(self) -> None: + _validate_idempotency_key(self.idempotency_key) + if self.expected_version < 1: + raise AccessInvalidRequestError("binding-version") if self.reason is not None and len(self.reason) > 1_024: raise AccessInvalidRequestError("reason") +@dataclass(frozen=True, slots=True) +class HandoffReceiverReassignment: + """Both sides of one atomic receiver reassignment.""" + + revoked_binding: AccessBinding + created_binding: AccessBinding + + class AuthorizationProvider(Protocol): """Replaceable decision interface suitable for embedded or remote PDPs.""" @@ -139,7 +227,7 @@ async def check_batch( self, requests: Sequence[AccessRequest], /, - ) -> tuple[AccessDecision, ...]: ... + ) -> Sequence[AccessDecision]: ... async def resolve_resource_filter( self, @@ -148,77 +236,107 @@ async def resolve_resource_filter( ) -> AuthorizedResourceFilter: ... +class RelationshipReader(Protocol): + """Read canonical relationship metadata without reading business content.""" + + async def get_binding(self, binding_id: str, /) -> AccessBinding | None: ... + + async def list_bindings(self, request: BindingSearchRequest, /) -> tuple[AccessBinding, ...]: ... + + async def get_artifact_owner(self, resource: ResourceRef, /) -> ArtifactOwnerRelation | None: ... + + async def get_candidate_owner(self, scope_id: str, candidate_id: str, /) -> CandidateOwnerAttestation | None: ... + + class RelationshipWriter(Protocol): - """Replaceable relationship mutation interface paired with a Provider.""" + """Idempotent relationship mutations paired with a decision Provider.""" - async def get_binding(self, binding_id: str) -> AccessBinding | None: ... + async def establish_artifact_owner(self, relation: ArtifactOwnerRelation, /) -> ArtifactOwnerRelation: ... - async def list_bindings( + async def attest_candidate_owner( self, - *, - subject: PrincipalRef | None = None, - resource: ResourceRef | None = None, - include_revoked: bool = False, - ) -> tuple[AccessBinding, ...]: ... + attestation: CandidateOwnerAttestation, + /, + ) -> CandidateOwnerAttestation: ... - async def create_binding(self, binding: AccessBinding) -> AccessBinding: ... + async def create_binding(self, binding: AccessBinding, /) -> AccessBinding: ... async def revoke_binding( self, binding_id: str, + /, *, expected_version: int, + idempotency_key: str, revoked_at: datetime, revoked_by: PrincipalRef, ) -> AccessBinding: ... + async def reassign_handoff_receiver( + self, + request: ReassignHandoffReceiver, + /, + *, + actor: PrincipalRef, + changed_at: datetime, + ) -> HandoffReceiverReassignment: ... + + +class RelationshipStore(RelationshipReader, RelationshipWriter, Protocol): + """Complete relationship management capability used by public Access APIs.""" + class AccessAuditStore(Protocol): """Append-only audit boundary that can use a dedicated compliance backend.""" - async def append_audit(self, event: AccessAuditEvent) -> AccessAuditEvent: ... + async def append_audit(self, event: AccessAuditEvent, /) -> AccessAuditEvent: ... async def list_audit( self, *, - resource: ResourceRef | None = None, + resource: ResourceRef, after: int | None = None, limit: int = 100, + action: AccessAction | None = None, + subject: AccessSubjectRef | None = None, + allowed: bool | None = None, + occurred_after: datetime | None = None, + occurred_before: datetime | None = None, ) -> tuple[AccessAuditEvent, ...]: ... -class AccessRepository(RelationshipWriter, AccessAuditStore, Protocol): - """Built-in Provider read requirements.""" +class AccessRepository(RelationshipReader, RelationshipWriter, AccessAuditStore, Protocol): + """Read requirements used by the built-in Provider.""" async def policy_revision(self) -> str: ... - async def active_bindings(self, subject: PrincipalRef, *, now: datetime) -> tuple[AccessBinding, ...]: ... + async def active_bindings( + self, + subjects: Sequence[AccessSubjectRef], + *, + now: datetime, + ) -> tuple[AccessBinding, ...]: ... + + async def list_owned_resources(self, owner: PrincipalRef, /) -> tuple[ResourceRef, ...]: ... class BuiltinAuthorizationProvider: - """Small hierarchical RBAC profile backed by immutable Access Bindings.""" + """Hierarchical RBAC profile backed by canonical immutable relationships.""" def __init__( self, repository: AccessRepository, *, - bootstrap_administrators: Sequence[PrincipalRef] = (), deployment_id: str = DEFAULT_DEPLOYMENT_ID, clock: Callable[[], datetime] | None = None, ) -> None: self._repository = repository - self._bootstrap_administrators = frozenset(bootstrap_administrators) self._deployment_id = deployment_id self._clock = clock or (lambda: datetime.now(UTC)) async def check(self, request: AccessRequest, /) -> AccessDecision: - revision = await self._repository.policy_revision() - if request.action is AccessAction.ACCESS_SELF: - return AccessDecision(True, "authenticated", revision) - if request.subject in self._bootstrap_administrators: - return AccessDecision(True, "bootstrap-admin", revision) - bindings = await self._repository.active_bindings(request.subject, now=self._clock()) - return _binding_decision(bindings, request.action, request.resource, policy_revision=revision) + decisions = await self.check_batch((request,)) + return decisions[0] async def check_batch( self, @@ -231,45 +349,77 @@ async def check_batch( principal = requests[0].subject if any(request.subject != principal for request in requests): raise AccessInvalidRequestError("batch-subject") - if principal in self._bootstrap_administrators: - return tuple(AccessDecision(True, "bootstrap-admin", revision) for _ in requests) - bindings = await self._repository.active_bindings(principal, now=self._clock()) - return tuple( - AccessDecision(True, "authenticated", revision) - if request.action is AccessAction.ACCESS_SELF - else _binding_decision(bindings, request.action, request.resource, policy_revision=revision) - for request in requests - ) + revision = contextual_policy_revision(revision, requests[0].context.subject_groups) + subjects: tuple[AccessSubjectRef, ...] = (principal, *requests[0].context.subject_groups) + bindings = await self._repository.active_bindings(subjects, now=self._clock()) + decisions: list[AccessDecision] = [] + for request in requests: + if request.action is AccessAction.ACCESS_SELF: + decisions.append(AccessDecision(True, "authenticated", revision)) + continue + owner = ( + await self._repository.get_artifact_owner(request.resource) + if request.resource.type is AccessResourceType.ARTIFACT + else None + ) + if request.resource.type is AccessResourceType.ARTIFACT and owner is None: + decisions.append(AccessDecision(False, "artifact-owner-pending", revision)) + continue + if ( + owner is not None + and owner.owner == principal + and request.action in ROLE_ACTIONS[AccessRole.ARTIFACT_OWNER] + ): + decisions.append( + AccessDecision( + True, + "artifact-owner", + revision, + matched_subject=principal, + ) + ) + continue + decisions.append(_binding_decision(bindings, request.action, request.resource, policy_revision=revision)) + return tuple(decisions) async def resolve_resource_filter( self, request: ResourceSearchRequest, /, ) -> AuthorizedResourceFilter: - revision = await self._repository.policy_revision() - if request.subject in self._bootstrap_administrators: - return AuthorizedResourceFilter( - exact_resources=(ResourceRef.server(self._deployment_id),) - if request.resource_type is AccessResourceType.SERVER - else (), - parent_constraints=(ResourceRef.server(self._deployment_id),), - policy_revision=revision, - ) - bindings = await self._repository.active_bindings(request.subject, now=self._clock()) + revision = contextual_policy_revision( + await self._repository.policy_revision(), + request.context.subject_groups, + ) + subjects: tuple[AccessSubjectRef, ...] = (request.subject, *request.context.subject_groups) + bindings = await self._repository.active_bindings(subjects, now=self._clock()) exact: dict[str, ResourceRef] = {} parents: dict[str, ResourceRef] = {} for binding in bindings: - if request.action not in ROLE_ACTIONS[binding.role]: - continue resource = binding.resource - if resource.type is request.resource_type and (request.family is None or resource.family == request.family): + if ( + resource.type is request.resource_type + and request.action in ROLE_ACTIONS[binding.role] + and (request.family is None or resource.family == request.family) + ): exact[resource.key] = resource - elif _resource_is_parent(resource, request.resource_type): + elif _resource_is_parent(resource, request.resource_type) and _parent_binding_grants( + binding, request.action, request.resource_type + ): parents[resource.key] = resource + if ( + request.resource_type is AccessResourceType.ARTIFACT + and request.action in ROLE_ACTIONS[AccessRole.ARTIFACT_OWNER] + ): + for resource in await self._repository.list_owned_resources(request.subject): + if request.family is None or resource.family == request.family: + exact[resource.key] = resource return AuthorizedResourceFilter( exact_resources=tuple(exact[key] for key in sorted(exact)), parent_constraints=tuple(parents[key] for key in sorted(parents)), + complete=True, policy_revision=revision, + max_direct_resource_keys=_MAX_AUTHORIZED_FILTER_IDENTITIES, ) @@ -280,24 +430,66 @@ def __init__( self, provider: AuthorizationProvider, *, - relationships: RelationshipWriter | None, + relationships: RelationshipStore | None, audit: AccessAuditStore, deployment_id: str = DEFAULT_DEPLOYMENT_ID, - mode: Literal["legacy-static-admin", "enforced"] = "enforced", provider_capabilities: AccessProviderCapabilities | None = None, clock: Callable[[], datetime] | None = None, + cursor_secret: bytes | None = None, + static_scope_principal: PrincipalRef | None = None, ) -> None: self.provider = provider self.relationships = relationships self.audit = audit self.deployment_id = deployment_id - self.mode = mode + self.mode = "enforced" self.provider_capabilities = provider_capabilities or AccessProviderCapabilities( safe_resource_filtering=True, multi_requirement_check=True, relationship_management=relationships is not None, ) self._clock = clock or (lambda: datetime.now(UTC)) + self._cursor_secret = cursor_secret or secrets.token_bytes(32) + self._static_scope_principal = static_scope_principal + + async def bootstrap_static_scope( + self, + principal: PrincipalRef | None, + scope_id: str, + *, + context: AccessAuditContext, + ) -> None: + """Idempotently materialize the fixed static preset before scope content access.""" + + actor = _required_principal(principal) + if self._static_scope_principal is None or actor != self._static_scope_principal: + return + resource = ResourceRef.scope(scope_id) + now = self._clock() + for role in ( + AccessRole.SCOPE_VIEWER, + AccessRole.SCOPE_CONTRIBUTOR, + AccessRole.SCOPE_REVIEWER, + AccessRole.SCOPE_DELEGATOR, + ): + key = f"static-preset:{self.deployment_id}:{actor.id}:{scope_id}:{role.value}" + binding = AccessBinding( + binding_id=str(uuid4()), + subject=actor, + resource=resource, + role=role, + granted_by=actor, + reason="static bearer preset", + created_at=now, + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key=key, + ) + created = await _access_call(self._relationship_writer().create_binding(binding)) + if created.binding_id == binding.binding_id: + await _access_call(self._record_relationship(created, principal=actor, context=context)) async def check( self, @@ -325,6 +517,8 @@ async def require( context: AccessAuditContext, ) -> AccessDecision: decision = await self.check(principal, action, resource, context=context) + if not decision.allowed and decision.reason_code == "artifact-owner-pending": + raise AccessUnavailableError("artifact_owner_pending") if not decision.allowed: raise AccessDeniedError return decision @@ -345,9 +539,9 @@ async def check_batch( AccessRequest(subject=actor, action=action, resource=resource, context=context) for action, resource in checks ) - decisions = await _access_call(self.provider.check_batch(requests)) + decisions = tuple(await _access_call(self.provider.check_batch(requests))) if len(decisions) != len(checks): - raise AccessUnavailableError + raise AccessUnavailableError() for decision in decisions: _validate_provider_decision(decision) for (action, resource), decision in zip(checks, decisions, strict=True): @@ -363,10 +557,26 @@ async def require_all( context: AccessAuditContext, ) -> tuple[AccessDecision, ...]: decisions = await self.check_batch(principal, checks, context=context) + if any(not decision.allowed and decision.reason_code == "artifact-owner-pending" for decision in decisions): + raise AccessUnavailableError("artifact_owner_pending") if not all(decision.allowed for decision in decisions): raise AccessDeniedError return decisions + async def require_any( + self, + principal: PrincipalRef | None, + checks: Sequence[tuple[AccessAction, ResourceRef]], + *, + context: AccessAuditContext, + ) -> tuple[AccessDecision, ...]: + decisions = await self.check_batch(principal, checks, context=context) + if not any(decision.allowed for decision in decisions): + if any(decision.reason_code == "artifact-owner-pending" for decision in decisions): + raise AccessUnavailableError("artifact_owner_pending") + raise AccessDeniedError + return decisions + async def list_resources( self, principal: PrincipalRef | None, @@ -377,6 +587,7 @@ async def list_resources( cursor: str | None = None, limit: int = 100, context: AccessAuditContext, + query_resources: Callable[[AuthorizedResourceFilter], Awaitable[Sequence[ResourceRef]]] | None = None, ) -> AuthorizedResourcePage: if not self.provider_capabilities.safe_resource_filtering: raise AccessUnavailableError("safe_resource_filtering_unavailable") @@ -384,55 +595,228 @@ async def list_resources( raise AccessInvalidRequestError("limit") _validate_resource_list_query(action=action, resource_type=resource_type, family=family) actor = _required_principal(principal) - authorized_filter = await _access_call( - self.provider.resolve_resource_filter( - ResourceSearchRequest( - subject=actor, - action=action, - resource_type=resource_type, - family=family, - context=context, - ) - ) + request = ResourceSearchRequest( + subject=actor, + action=action, + resource_type=resource_type, + family=family, + context=context, ) + authorized_filter = await _access_call(self.provider.resolve_resource_filter(request)) _validate_resource_filter( authorized_filter, action=action, resource_type=resource_type, family=family, deployment_id=self.deployment_id, + provider_limit=self.provider_capabilities.max_direct_resource_keys, + ) + if authorized_filter.parent_constraints and query_resources is None: + raise AccessUnavailableError("safe_resource_filtering_unavailable") + resources = ( + authorized_filter.exact_resources + if query_resources is None + else tuple(await _access_call(query_resources(authorized_filter))) + ) + if any(not _resource_allowed_by_filter(resource, authorized_filter) for resource in resources): + raise AccessUnavailableError("safe_resource_filtering_unavailable") + ordered_by_key = {resource.key: resource for resource in resources} + ordered = tuple(ordered_by_key[key] for key in sorted(ordered_by_key)) + after_key = self._decode_cursor( + cursor, + actor=actor, + action=action, + resource_type=resource_type, + family=family, + policy_revision=authorized_filter.policy_revision, ) - ordered = tuple(sorted(authorized_filter.exact_resources, key=lambda resource: resource.key)) - after_key = _decode_cursor(cursor) visible = ordered if after_key is None else tuple(resource for resource in ordered if resource.key > after_key) items = visible[:limit] - next_cursor = _encode_cursor(items[-1].key) if len(visible) > len(items) else None + next_cursor = ( + self._encode_cursor( + items[-1].key, + actor=actor, + action=action, + resource_type=resource_type, + family=family, + policy_revision=authorized_filter.policy_revision, + ) + if len(visible) > len(items) + else None + ) return AuthorizedResourcePage(items=items, total=len(ordered), next_cursor=next_cursor) async def list_bindings( self, + principal: PrincipalRef | None, + request: BindingSearchRequest, *, - subject: PrincipalRef | None = None, - resource: ResourceRef | None = None, - include_revoked: bool = False, - ) -> tuple[AccessBinding, ...]: - relationships = self._relationships() - return await _access_call( - relationships.list_bindings( - subject=subject, - resource=resource, - include_revoked=include_revoked, + context: AccessAuditContext, + ) -> AccessBindingPage: + if request.limit < 1 or request.limit > 500: + raise AccessInvalidRequestError("limit") + actor = _required_principal(principal) + checks = _binding_list_checks(request.management_resource, deployment_id=self.deployment_id) + decisions = await self.require_any(actor, checks, context=context) + policy_revision = next( + (decision.policy_revision for decision in decisions if decision.allowed), + None, + ) + delegate_only = ( + request.management_resource.type is AccessResourceType.SCOPE + and decisions[0].allowed + and not any(decision.allowed for decision in decisions[1:]) + ) + visible_roles = ( + (AccessRole.HANDOFF_VIEWER, AccessRole.HANDOFF_RECEIVER) if delegate_only else request.visible_roles + ) + query = _binding_query_identity(request, visible_roles=visible_roles) + after = self._decode_scoped_cursor( + request.cursor, + actor=actor, + operation="access.bindings.list", + query=query, + policy_revision=policy_revision, + ) + rows = await _access_call( + self._relationship_reader().list_bindings( + replace(request, cursor=after, limit=request.limit + 1, visible_roles=visible_roles) + ) + ) + items = rows[: request.limit] + next_cursor = ( + self._encode_scoped_cursor( + items[-1].binding_id, + actor=actor, + operation="access.bindings.list", + query=query, + policy_revision=policy_revision, ) + if len(rows) > len(items) + else None ) + return AccessBindingPage(items=items, next_cursor=next_cursor) async def list_audit( self, + principal: PrincipalRef | None, + request: AuditSearchRequest, *, - resource: ResourceRef | None = None, - after: int | None = None, - limit: int = 100, - ) -> tuple[AccessAuditEvent, ...]: - return await _access_call(self.audit.list_audit(resource=resource, after=after, limit=limit)) + context: AccessAuditContext, + ) -> AccessAuditPage: + if request.limit < 1 or request.limit > 500: + raise AccessInvalidRequestError("limit") + if ( + request.occurred_after is not None + and request.occurred_before is not None + and request.occurred_after >= request.occurred_before + ): + raise AccessInvalidRequestError("time-range") + actor = _required_principal(principal) + if request.resource.type is AccessResourceType.SERVER: + checks = ((AccessAction.SERVER_ADMIN, request.resource),) + elif request.resource.type is AccessResourceType.SCOPE: + checks = ( + (AccessAction.SCOPE_ADMIN, request.resource), + (AccessAction.SERVER_ADMIN, ResourceRef.server(self.deployment_id)), + ) + else: + raise AccessInvalidRequestError("action-resource") + decisions = await self.require_any(actor, checks, context=context) + policy_revision = next( + (decision.policy_revision for decision in decisions if decision.allowed), + None, + ) + query = _audit_query_identity(request) + decoded_after = self._decode_scoped_cursor( + request.cursor, + actor=actor, + operation="access.audit.list", + query=query, + policy_revision=policy_revision, + ) + try: + after = None if decoded_after is None else int(decoded_after) + except ValueError as error: + raise AccessInvalidRequestError("cursor") from error + rows = await _access_call( + self.audit.list_audit( + resource=request.resource, + after=after, + limit=request.limit + 1, + action=request.action, + subject=request.subject, + allowed=request.allowed, + occurred_after=request.occurred_after, + occurred_before=request.occurred_before, + ) + ) + items = rows[: request.limit] + next_cursor = ( + self._encode_scoped_cursor( + str(items[-1].cursor), + actor=actor, + operation="access.audit.list", + query=query, + policy_revision=policy_revision, + ) + if len(rows) > len(items) + else None + ) + return AccessAuditPage(items=items, next_cursor=next_cursor) + + async def establish_artifact_owner( + self, + resource: ResourceRef, + owner: PrincipalRef, + *, + idempotency_key: str, + context: AccessAuditContext, + ) -> ArtifactOwnerRelation: + if resource.type is not AccessResourceType.ARTIFACT: + raise AccessInvalidRequestError("artifact-identity") + _validate_idempotency_key(idempotency_key) + relation = ArtifactOwnerRelation( + resource=resource, + owner=owner, + established_at=self._clock(), + policy_revision="pending", + idempotency_key=idempotency_key, + ) + established = await _access_call(self._relationship_writer().establish_artifact_owner(relation)) + await _access_call(self._record_owner(established, principal=owner, context=context)) + return established + + async def attest_candidate_owner( + self, + *, + scope_id: str, + candidate_id: str, + family: str, + proposed_owner: PrincipalRef, + target: ResourceRef | None, + idempotency_key: str, + ) -> CandidateOwnerAttestation: + _validate_idempotency_key(idempotency_key) + attestation = CandidateOwnerAttestation( + scope_id=scope_id, + candidate_id=candidate_id, + family=family, + proposed_owner=proposed_owner, + target=target, + idempotency_key=idempotency_key, + ) + return await _access_call(self._relationship_writer().attest_candidate_owner(attestation)) + + async def candidate_owner(self, scope_id: str, candidate_id: str) -> CandidateOwnerAttestation | None: + return await _access_call(self._relationship_reader().get_candidate_owner(scope_id, candidate_id)) + + async def artifact_owner(self, resource: ResourceRef) -> ArtifactOwnerRelation | None: + """Return owner metadata without reading the protected Artifact body.""" + + if resource.type is not AccessResourceType.ARTIFACT: + raise AccessInvalidRequestError("artifact-identity") + return await _access_call(self._relationship_reader().get_artifact_owner(resource)) async def create_binding( self, @@ -443,12 +827,23 @@ async def create_binding( validate_resource: Callable[[ResourceRef], Awaitable[None]] | None = None, ) -> AccessBinding: validate_binding_role(request.resource, request.role, deployment_id=self.deployment_id) + validate_binding_subject(request.resource, request.role, request.subject.type) + if isinstance(request.subject, GroupRef) and not self.provider_capabilities.group_subjects: + raise AccessInvalidRequestError("group-subjects-unavailable") now = self._clock() if request.expires_at is not None and request.expires_at <= now: raise AccessInvalidRequestError("binding-expired") - action, administrative_resource = _administrative_check(request.resource) actor = _required_principal(principal) - await self.require(actor, action, administrative_resource, context=context) + await self.require_any( + actor, + _administrative_checks(request.resource, deployment_id=self.deployment_id), + context=context, + ) + if ( + request.resource.type is AccessResourceType.ARTIFACT + and await _access_call(self._relationship_reader().get_artifact_owner(request.resource)) is None + ): + raise AccessUnavailableError("artifact_owner_pending") if validate_resource is not None: await validate_resource(request.resource) candidate = AccessBinding( @@ -465,8 +860,8 @@ async def create_binding( policy_revision="pending", idempotency_key=request.idempotency_key, ) - created = await _access_call(self._relationships().create_binding(candidate)) - await _access_call(self._record_relationship(created, principal=actor, action=action, context=context)) + created = await _access_call(self._relationship_writer().create_binding(candidate)) + await _access_call(self._record_relationship(created, principal=actor, context=context)) return created async def revoke_binding( @@ -475,27 +870,94 @@ async def revoke_binding( binding_id: str, *, expected_version: int, + idempotency_key: str, context: AccessAuditContext, ) -> AccessBinding: + _validate_idempotency_key(idempotency_key) actor = _required_principal(principal) - relationships = self._relationships() - binding = await _access_call(relationships.get_binding(binding_id)) + binding = await _access_call(self._relationship_reader().get_binding(binding_id)) if binding is None: + server = ResourceRef.server(self.deployment_id) + decision = await self.check(actor, AccessAction.SERVER_ADMIN, server, context=context) + if decision.allowed: + raise AccessBindingNotFoundError raise AccessDeniedError - action, administrative_resource = _administrative_check(binding.resource) - await self.require(actor, action, administrative_resource, context=context) + await self.require_any( + actor, + _administrative_checks(binding.resource, deployment_id=self.deployment_id), + context=context, + ) revoked = await _access_call( - relationships.revoke_binding( + self._relationship_writer().revoke_binding( binding_id, expected_version=expected_version, + idempotency_key=idempotency_key, revoked_at=self._clock(), revoked_by=actor, ) ) - await _access_call(self._record_relationship(revoked, principal=actor, action=action, context=context)) + await _access_call( + self._record_relationship( + revoked, + principal=actor, + context=context, + expected_version=expected_version, + ) + ) return revoked - def _relationships(self) -> RelationshipWriter: + async def reassign_handoff_receiver( + self, + principal: PrincipalRef | None, + request: ReassignHandoffReceiver, + *, + context: AccessAuditContext, + ) -> HandoffReceiverReassignment: + actor = _required_principal(principal) + current = await _access_call(self._relationship_reader().get_binding(request.binding_id)) + if current is None: + decision = await self.check( + actor, + AccessAction.SERVER_ADMIN, + ResourceRef.server(self.deployment_id), + context=context, + ) + if decision.allowed: + raise AccessBindingNotFoundError + raise AccessDeniedError + if current.role is not AccessRole.HANDOFF_RECEIVER or current.resource.family != "handoff": + raise AccessInvalidRequestError("binding-role") + if request.expires_at is not None and request.expires_at <= self._clock(): + raise AccessInvalidRequestError("binding-expired") + await self.require_any( + actor, + _administrative_checks(current.resource, deployment_id=self.deployment_id), + context=context, + ) + changed = await _access_call( + self._relationship_writer().reassign_handoff_receiver( + request, + actor=actor, + changed_at=self._clock(), + ) + ) + await _access_call( + self._record_relationship( + changed.revoked_binding, + principal=actor, + context=context, + expected_version=request.expected_version, + ) + ) + await _access_call(self._record_relationship(changed.created_binding, principal=actor, context=context)) + return changed + + def _relationship_reader(self) -> RelationshipReader: + if self.relationships is None or not self.provider_capabilities.relationship_management: + raise AccessUnavailableError("relationship_management_unavailable") + return self.relationships + + def _relationship_writer(self) -> RelationshipWriter: if self.relationships is None or not self.provider_capabilities.relationship_management: raise AccessUnavailableError("relationship_management_unavailable") return self.relationships @@ -523,6 +985,8 @@ async def _record_decision( allowed=decision.allowed, reason_code=decision.reason_code, policy_revision=decision.policy_revision, + matched_subject=decision.matched_subject, + binding_id=decision.matched_binding_id, ) ) @@ -531,8 +995,8 @@ async def _record_relationship( binding: AccessBinding, *, principal: PrincipalRef, - action: AccessAction, context: AccessAuditContext, + expected_version: int | None = None, ) -> None: await self.audit.append_audit( AccessAuditEvent( @@ -543,7 +1007,7 @@ async def _record_relationship( transport=context.transport, operation=context.operation, principal=principal, - action=action, + action=_administrative_checks(binding.resource, deployment_id=self.deployment_id)[0][0], resource=binding.resource, allowed=True, reason_code="binding-created" if binding.state is AccessBindingState.ACTIVE else "binding-revoked", @@ -551,22 +1015,176 @@ async def _record_relationship( binding_id=binding.binding_id, target=binding.subject, role=binding.role, + expected_version=expected_version, + result_version=binding.version, + ) + ) + + async def _record_owner( + self, + relation: ArtifactOwnerRelation, + *, + principal: PrincipalRef, + context: AccessAuditContext, + ) -> None: + await self.audit.append_audit( + AccessAuditEvent( + cursor=None, + event_id=str(uuid4()), + occurred_at=self._clock(), + request_id=context.request_id, + transport=context.transport, + operation=context.operation, + principal=principal, + action=AccessAction.ARTIFACT_WRITE, + resource=relation.resource, + allowed=True, + reason_code="artifact-owner-established", + policy_revision=relation.policy_revision, + target=relation.owner, + role=AccessRole.ARTIFACT_OWNER, ) ) + def _encode_cursor( + self, + after_key: str, + *, + actor: PrincipalRef, + action: AccessAction, + resource_type: AccessResourceType, + family: str | None, + policy_revision: str | None, + ) -> str: + payload = json.dumps( + { + "action": action.value, + "after": after_key, + "family": family, + "policy_revision": policy_revision, + "resource_type": resource_type.value, + "subject": actor.id, + }, + separators=(",", ":"), + sort_keys=True, + ).encode() + signature = hmac.digest(self._cursor_secret, payload, "sha256") + return urlsafe_b64encode(payload + signature).decode("ascii").rstrip("=") + + def _decode_cursor( + self, + cursor: str | None, + *, + actor: PrincipalRef, + action: AccessAction, + resource_type: AccessResourceType, + family: str | None, + policy_revision: str | None, + ) -> str | None: + if cursor is None: + return None + decoded = _decode_signed_cursor(cursor, secret=self._cursor_secret) + expected = { + "action": action.value, + "family": family, + "resource_type": resource_type.value, + "subject": actor.id, + } + if not isinstance(decoded, dict) or any(decoded.get(key) != value for key, value in expected.items()): + raise AccessInvalidRequestError("cursor") + if decoded.get("policy_revision") != policy_revision: + raise AccessConflictError("access_cursor_stale") + after = decoded.get("after") + if not isinstance(after, str) or not after: + raise AccessInvalidRequestError("cursor") + return after + + def _encode_scoped_cursor( + self, + after: str, + *, + actor: PrincipalRef, + operation: str, + query: dict[str, object], + policy_revision: str | None, + ) -> str: + payload = json.dumps( + { + "after": after, + "operation": operation, + "policy_revision": policy_revision, + "query": query, + "subject": actor.id, + }, + separators=(",", ":"), + sort_keys=True, + ).encode() + signature = hmac.digest(self._cursor_secret, payload, "sha256") + return urlsafe_b64encode(payload + signature).decode("ascii").rstrip("=") + + def _decode_scoped_cursor( + self, + cursor: str | None, + *, + actor: PrincipalRef, + operation: str, + query: dict[str, object], + policy_revision: str | None, + ) -> str | None: + if cursor is None: + return None + decoded = _decode_signed_cursor(cursor, secret=self._cursor_secret) + if ( + not isinstance(decoded, dict) + or decoded.get("operation") != operation + or decoded.get("query") != query + or decoded.get("subject") != actor.id + ): + raise AccessInvalidRequestError("cursor") + if decoded.get("policy_revision") != policy_revision: + raise AccessConflictError("access_cursor_stale") + after = decoded.get("after") + if not isinstance(after, str) or not after: + raise AccessInvalidRequestError("cursor") + return after + + +def _decode_signed_cursor(cursor: str, *, secret: bytes) -> object: + try: + padded = f"{cursor}{'=' * (-len(cursor) % 4)}" + value = b64decode(padded.encode("ascii"), altchars=b"-_", validate=True) + except (UnicodeDecodeError, ValueError, TypeError) as error: + raise AccessInvalidRequestError("cursor") from error + payload, signature = value[:-32], value[-32:] + if len(signature) != 32 or not hmac.compare_digest(signature, hmac.digest(secret, payload, "sha256")): + raise AccessInvalidRequestError("cursor") + try: + return json.loads(payload) + except (UnicodeDecodeError, ValueError, TypeError, json.JSONDecodeError) as error: + raise AccessInvalidRequestError("cursor") from error -def access_control_for_mode( - access_control: AccessControlService | None, - *, - mode: str, -) -> AccessControlService | None: + +def access_control_for_mode(access_control: AccessControlService | None, *, mode: str) -> AccessControlService | None: """Return the active PDP, failing closed when enforcement requires one.""" + if mode not in {"disabled", "enforced"}: + raise AccessUnavailableError() if access_control is None and mode == "enforced": - raise AccessUnavailableError + raise AccessUnavailableError() return access_control +def contextual_policy_revision(revision: str, groups: Sequence[GroupRef]) -> str: + """Bind cursors and decisions to the trusted membership assertion.""" + + if not groups: + return revision + identity = "\0".join(sorted(group.id for group in groups)).encode() + membership = hashlib.sha256(identity).hexdigest()[:24] + revision_prefix = revision[: MAX_POLICY_REVISION_LENGTH - len(membership) - 3] + return f"{revision_prefix}:g:{membership}" + + def _validate_resource_list_query( *, action: AccessAction, @@ -590,7 +1208,10 @@ def _validate_resource_list_query( raise AccessInvalidRequestError("action-resource") return if family is None: - if not any(profile.enabled and action in profile.actions for profile in ARTIFACT_FAMILY_PROFILES.values()): + if not any( + profile.enabled and action in profile.actions | profile.mutation_semantics | {AccessAction.ARTIFACT_SHARE} + for profile in ARTIFACT_FAMILY_PROFILES.values() + ): raise AccessInvalidRequestError("action-resource") return profile = ARTIFACT_FAMILY_PROFILES.get(family) @@ -598,10 +1219,30 @@ def _validate_resource_list_query( raise AccessInvalidRequestError("artifact-family") if not profile.enabled: raise AccessInvalidRequestError("artifact-family-disabled") - if action not in profile.actions: + if action not in profile.actions | profile.mutation_semantics | {AccessAction.ARTIFACT_SHARE}: raise AccessInvalidRequestError("action-resource") +def _binding_grants(binding: AccessBinding, action: AccessAction, requested: ResourceRef) -> bool: + if binding.resource == requested: + return action in ROLE_ACTIONS[binding.role] + return _parent_binding_grants(binding, action, requested.type) and _binding_covers(binding.resource, requested) + + +def _parent_binding_grants( + binding: AccessBinding, + action: AccessAction, + requested_type: AccessResourceType, +) -> bool: + if action not in ROLE_CHILD_ACTIONS.get(binding.role, frozenset()): + return False + if binding.resource.type is AccessResourceType.SCOPE: + return requested_type is AccessResourceType.ARTIFACT + if binding.resource.type is AccessResourceType.SERVER: + return requested_type in {AccessResourceType.SCOPE, AccessResourceType.ARTIFACT} + return False + + def _binding_covers(binding: ResourceRef, requested: ResourceRef) -> bool: if binding == requested: return True @@ -622,22 +1263,86 @@ def _binding_decision( policy_revision: str, ) -> AccessDecision: for binding in bindings: - if action in ROLE_ACTIONS[binding.role] and _binding_covers(binding.resource, resource): - return AccessDecision(True, "role-binding", policy_revision) + if _binding_grants(binding, action, resource): + return AccessDecision( + True, + "role-binding", + policy_revision, + matched_subject=binding.subject, + matched_binding_id=binding.binding_id, + ) return AccessDecision(False, "no-matching-binding", policy_revision) -def _administrative_check(resource: ResourceRef) -> tuple[AccessAction, ResourceRef]: +def _administrative_checks( + resource: ResourceRef, + *, + deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: if resource.type is AccessResourceType.SERVER: - return AccessAction.SERVER_ADMIN, resource + return ((AccessAction.SERVER_ADMIN, resource),) if resource.type is AccessResourceType.SCOPE: - return AccessAction.SCOPE_ADMIN, resource + return ( + (AccessAction.SCOPE_ADMIN, resource), + (AccessAction.SERVER_ADMIN, ResourceRef.server(deployment_id)), + ) parent = resource.parent_scope if parent is None: - raise AccessInvalidRequestError("artifact-reference") - profile = artifact_family_profile(resource) - action = AccessAction.SCOPE_DELEGATE if profile.family == "handoff" else AccessAction.SCOPE_ADMIN - return action, parent + raise AccessInvalidRequestError("artifact-identity") + checks = [(AccessAction.ARTIFACT_SHARE, resource), (AccessAction.SCOPE_ADMIN, parent)] + if artifact_family_profile(resource).family == "handoff": + checks.append((AccessAction.SCOPE_DELEGATE, parent)) + checks.append((AccessAction.SERVER_ADMIN, ResourceRef.server(deployment_id))) + return tuple(checks) + + +def _binding_list_checks( + resource: ResourceRef, + *, + deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + if resource.type is AccessResourceType.SERVER: + return ((AccessAction.SERVER_ADMIN, resource),) + if resource.type is AccessResourceType.SCOPE: + return ( + (AccessAction.SCOPE_DELEGATE, resource), + (AccessAction.SCOPE_ADMIN, resource), + (AccessAction.SERVER_ADMIN, ResourceRef.server(deployment_id)), + ) + return _administrative_checks(resource, deployment_id=deployment_id) + + +def _binding_query_identity( + request: BindingSearchRequest, + *, + visible_roles: tuple[AccessRole, ...], +) -> dict[str, object]: + return { + "management_resource": request.management_resource.key, + "role": None if request.role is None else request.role.value, + "state": None if request.state is None else request.state.value, + "subject": None if request.subject is None else [request.subject.type, request.subject.id], + "visible_roles": [role.value for role in visible_roles], + } + + +def _audit_query_identity(request: AuditSearchRequest) -> dict[str, object]: + return { + "action": None if request.action is None else request.action.value, + "allowed": request.allowed, + "resource": request.resource.key, + "subject": None if request.subject is None else [request.subject.type, request.subject.id], + "time_range": [ + None if request.occurred_after is None else request.occurred_after.isoformat(), + None if request.occurred_before is None else request.occurred_before.isoformat(), + ], + } + + +def _resource_allowed_by_filter(resource: ResourceRef, value: AuthorizedResourceFilter) -> bool: + return resource in value.exact_resources or any( + _binding_covers(parent, resource) for parent in value.parent_constraints + ) def _resource_is_parent(resource: ResourceRef, child_type: AccessResourceType) -> bool: @@ -653,8 +1358,14 @@ def _validate_resource_filter( resource_type: AccessResourceType, family: str | None, deployment_id: str, + provider_limit: int, ) -> None: - if len(value.exact_resources) + len(value.parent_constraints) > _MAX_AUTHORIZED_FILTER_IDENTITIES: + if not isinstance(value, AuthorizedResourceFilter) or not value.complete: + raise AccessUnavailableError("safe_resource_filtering_unavailable") + limit = min(provider_limit, value.max_direct_resource_keys, _MAX_AUTHORIZED_FILTER_IDENTITIES) + if limit < 1 or len(value.exact_resources) > limit: + raise AccessUnavailableError("resource_filter_limit_exceeded") + if len(value.parent_constraints) > _MAX_AUTHORIZED_FILTER_IDENTITIES: raise AccessUnavailableError("safe_resource_filtering_unavailable") if len({resource.key for resource in value.exact_resources}) != len(value.exact_resources): raise AccessUnavailableError("safe_resource_filtering_unavailable") @@ -669,7 +1380,7 @@ def _validate_resource_filter( def _validate_provider_decision(value: object) -> None: if not isinstance(value, AccessDecision) or not isinstance(value.allowed, bool): - raise AccessUnavailableError + raise AccessUnavailableError() reason = value.reason_code if ( not reason @@ -677,11 +1388,13 @@ def _validate_provider_decision(value: object) -> None: or not reason[0].isalnum() or any(not character.isascii() or not (character.isalnum() or character in "._-") for character in reason) ): - raise AccessUnavailableError + raise AccessUnavailableError() if value.policy_revision is not None and ( not value.policy_revision or len(value.policy_revision) > MAX_POLICY_REVISION_LENGTH ): - raise AccessUnavailableError + raise AccessUnavailableError() + if value.matched_subject is not None and not value.allowed: + raise AccessUnavailableError() def _required_principal(principal: PrincipalRef | None) -> PrincipalRef: @@ -690,18 +1403,9 @@ def _required_principal(principal: PrincipalRef | None) -> PrincipalRef: return principal -def _encode_cursor(resource_key: str) -> str: - return urlsafe_b64encode(resource_key.encode("utf-8")).decode("ascii").rstrip("=") - - -def _decode_cursor(cursor: str | None) -> str | None: - if cursor is None or cursor == "": - return None - try: - padded = f"{cursor}{'=' * (-len(cursor) % 4)}" - return b64decode(padded.encode("ascii"), altchars=b"-_", validate=True).decode("utf-8") - except (UnicodeDecodeError, ValueError) as error: - raise AccessInvalidRequestError("cursor") from error +def _validate_idempotency_key(value: str) -> None: + if not value or len(value) > 255 or value != value.strip(): + raise AccessInvalidRequestError("idempotency-key") async def _access_call(awaitable: Awaitable[_T]) -> _T: @@ -710,20 +1414,29 @@ async def _access_call(awaitable: Awaitable[_T]) -> _T: except AccessControlError: raise except Exception as error: - raise AccessUnavailableError from error + raise AccessUnavailableError() from error __all__ = ( "AccessAuditContext", + "AccessAuditPage", "AccessAuditStore", + "AccessBindingPage", "AccessControlService", "AccessProviderCapabilities", + "AccessRepository", "AccessRequest", + "AuditSearchRequest", "AuthorizationProvider", "AuthorizedResourceFilter", "AuthorizedResourcePage", + "BindingSearchRequest", "BuiltinAuthorizationProvider", "CreateBinding", + "HandoffReceiverReassignment", + "ReassignHandoffReceiver", + "RelationshipReader", + "RelationshipStore", "RelationshipWriter", "ResourceSearchRequest", "access_control_for_mode", diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index b5ea65df7..447e611dd 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -50,8 +50,8 @@ # operator can set instead of surfacing pydantic's internal validation dump. _MISSING_BEARER_CLI_MESSAGE = ( "authentication is enabled but no bearer token is configured; " - "set POWERCONTEXT_SERVER_AUTH_TOKEN=... or disable it with " - "POWERCONTEXT_SERVER_AUTH_ENABLED=false" + "set POWERCONTEXT_SERVER_AUTH_TOKEN=... or disable Access Control with " + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled" ) app = typer.Typer( diff --git a/src/powercontext/server/context.py b/src/powercontext/server/context.py index eca18b186..873fc08ab 100644 --- a/src/powercontext/server/context.py +++ b/src/powercontext/server/context.py @@ -18,11 +18,13 @@ from contextvars import ContextVar, Token +from powercontext.server.authentication import AuthenticationResult from powercontext.server.authz import PrincipalRef _internal_bridge: ContextVar[bool] = ContextVar("powercontext_internal_bridge", default=False) _request_id: ContextVar[str | None] = ContextVar("powercontext_request_id", default=None) _principal: ContextVar[PrincipalRef | None] = ContextVar("powercontext_principal", default=None) +_authentication: ContextVar[AuthenticationResult | None] = ContextVar("powercontext_authentication", default=None) def bind_request_id(request_id: str) -> Token[str | None]: @@ -49,6 +51,24 @@ def current_principal() -> PrincipalRef | None: return _principal.get() +def bind_authentication( + result: AuthenticationResult, +) -> tuple[Token[AuthenticationResult | None], Token[PrincipalRef | None]]: + """Bind one immutable trusted authentication result for the request lifetime.""" + + return _authentication.set(result), _principal.set(result.subject) + + +def reset_authentication(tokens: tuple[Token[AuthenticationResult | None], Token[PrincipalRef | None]]) -> None: + authentication_token, principal_token = tokens + _principal.reset(principal_token) + _authentication.reset(authentication_token) + + +def current_authentication() -> AuthenticationResult | None: + return _authentication.get() + + def bind_internal_bridge() -> Token[bool]: return _internal_bridge.set(True) @@ -62,12 +82,15 @@ def is_internal_bridge() -> bool: __all__ = [ + "bind_authentication", "bind_internal_bridge", "bind_principal", "bind_request_id", + "current_authentication", "current_principal", "current_request_id", "is_internal_bridge", + "reset_authentication", "reset_internal_bridge", "reset_principal", "reset_request_id", diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 87db35560..44e075ffb 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -33,7 +33,15 @@ from powercontext.builtin.artifacts.skill import ExternalSkillProvider, SkillGenerator from powercontext.builtin.inference import EmbeddingModel from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.runtime import BuiltinRuntime +from powercontext.builtin.runtime import ( + BuiltinRuntime, + ExperienceIncubationResult, + ListArtifactCandidatesRequest, + MemoryEntryRecord, + MemoryFlushResult, + ReviewedCandidate, +) +from powercontext.builtin.runtime.application import ScheduledExperienceRunner, ScheduledSourceRunner from powercontext.builtin.runtime.composition import open_builtin_runtime from powercontext.builtin.runtime.config import BuiltinConfig from powercontext.builtin.sources import CONTENT_SOURCE_NAME @@ -41,19 +49,24 @@ from powercontext.paths import default_scheduler_path from powercontext.server.access import HttpAccessLogMiddleware from powercontext.server.app import create_app +from powercontext.server.authentication import ( + AuthenticationProvider, + StaticBearerAuthenticationProvider, +) from powercontext.server.authz import ( AccessAction, AccessAuditContext, AccessControlService, + MemoryEntrySelector, PrincipalRef, ResourceRef, access_control_for_mode, ) -from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.composition import open_builtin_access_control, open_casbin_access_control from powercontext.server.context import current_principal, current_request_id from powercontext.server.mcp import mount_mcp from powercontext.server.metrics import CONTENT_TYPE_LATEST, HttpMetricsMiddleware, ServerMetrics -from powercontext.server.middleware import StaticBearerMiddleware +from powercontext.server.middleware import AuthenticationMiddleware from powercontext.server.settings import ServerSettings from powercontext.server.tracing import HttpTracingMiddleware, ServerTracing from powercontext.server.web import mount_web_ui @@ -98,14 +111,16 @@ def create_server_app( middleware: Sequence[Middleware] = (), tracing: ServerTracing | None = None, access_control: AccessControlService | None = None, + authentication_provider: AuthenticationProvider | None = None, ) -> FastAPI: """Build the Server process and mount MCP when configured.""" resolved = ServerSettings() if settings is None else settings - if resolved.access.mode == "enforced" and not resolved.auth.enabled and access_control is None: - raise ValueError( # noqa: TRY003 - "enforced Access Control requires authentication and an Authorization Provider" - ) + static_principal, configured_authentication, configured_access_control = _resolve_security_providers( + resolved, + access_control=access_control, + authentication_provider=authentication_provider, + ) config = BuiltinConfig( runtime=resolved.runtime, database=resolved.database, @@ -118,12 +133,6 @@ def create_server_app( if metrics is not None: metrics.set_ready(False) readiness_probe = _ServerReadinessProbe(metrics, tracing=resolved_tracing) - static_principal = PrincipalRef( - type="service", - issuer=f"powercontext:{resolved.access.deployment_id}:static", - id="server-token", - ) - configured_access_control = None if resolved.access.mode == "disabled" else access_control @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: @@ -131,6 +140,30 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: if isinstance(config.database, SQLiteConfig) and config.database.is_in_memory: _log_in_memory_database_warning() async with AsyncExitStack() as resources: + active_access_control = configured_access_control + if active_access_control is None and resolved.access.mode == "enforced": + administrators = ( + (static_principal,) + if resolved.auth.provider == "static-bearer" and resolved.access.static_preset + else () + ) + opener = ( + open_casbin_access_control + if resolved.authorization_provider == "casbin" + else open_builtin_access_control + ) + active_access_control = await resources.enter_async_context( + opener( + resolved.database, + bootstrap_administrators=administrators, + deployment_id=resolved.access.deployment_id, + ) + ) + scheduled_source_runner, scheduled_experience_runner = _scheduled_access_runners( + resolved, + active_access_control, + static_principal=static_principal, + ) runtime = await resources.enter_async_context( open_builtin_runtime( config, @@ -145,22 +178,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: instrumentation=resolved_tracing.instrumentation, scope_cache_observer=None if metrics is None else metrics.set_runtime_scopes, tracing=resolved_tracing, + scheduled_source_runner=scheduled_source_runner, + scheduled_experience_runner=scheduled_experience_runner, ) ) - active_access_control = configured_access_control - if active_access_control is None and resolved.auth.enabled and resolved.access.mode != "disabled": - administrators = (static_principal,) if resolved.access.bootstrap_static_principal else () - active_access_control = await resources.enter_async_context( - open_builtin_access_control( - resolved.database, - bootstrap_administrators=administrators, - deployment_id=resolved.access.deployment_id, - mode=resolved.access.mode, - ) - ) readiness_probe.bind(runtime) app.state.application = runtime app.state.access_control = active_access_control + app.state.authentication_provider = configured_authentication app.state.capabilities = await _server_capabilities(runtime) await readiness_probe() try: @@ -170,6 +195,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: readiness_probe.unbind() app.state.application = None app.state.access_control = configured_access_control + app.state.authentication_provider = configured_authentication app.state.capabilities = Capabilities( source_types=[], artifact_families=[], @@ -184,14 +210,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: _log_lifecycle("server.stopped", "PowerContext Server stopped") configured_middleware = list(middleware) - auth_token = resolved.auth.token - if resolved.auth.enabled and auth_token is not None: + if configured_authentication is not None: configured_middleware.insert( 0, Middleware( - StaticBearerMiddleware, - token=auth_token.get_secret_value(), - principal=static_principal, + AuthenticationMiddleware, + provider=configured_authentication, ), ) @@ -204,6 +228,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: handoff_report_enabled=resolved.handoff_report.enabled, access_control=configured_access_control, access_mode=resolved.access.mode, + authentication_provider=configured_authentication, agent_skill_targets=config.external_skills.agent_targets, ) _mount_optional_web_ui(app, resolved) @@ -242,6 +267,136 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: return app +def _resolve_security_providers( + settings: ServerSettings, + *, + access_control: AccessControlService | None, + authentication_provider: AuthenticationProvider | None, +) -> tuple[PrincipalRef, AuthenticationProvider | None, AccessControlService | None]: + static_principal = PrincipalRef( + type="service", + id=settings.auth.principal_id, + description=settings.auth.principal_description, + ) + if settings.access.mode == "disabled": + if access_control is not None or authentication_provider is not None: + raise ValueError("disabled Access Mode cannot load security Providers") # noqa: TRY003 + return static_principal, None, None + if settings.authorization_provider == "external" and access_control is None: + raise ValueError("the external Authorization Provider must be injected") # noqa: TRY003 + if authentication_provider is not None: + return static_principal, authentication_provider, access_control + if settings.auth.provider != "static-bearer" or settings.auth.token is None: + raise ValueError("the selected Authentication Provider must be injected") # noqa: TRY003 + authentication = StaticBearerAuthenticationProvider( + settings.auth.token.get_secret_value(), + static_principal, + ) + return static_principal, authentication, access_control + + +def _scheduled_access_runners( + settings: ServerSettings, + access: AccessControlService | None, + *, + static_principal: PrincipalRef, +) -> tuple[ScheduledSourceRunner | None, ScheduledExperienceRunner | None]: + source_scheduled = settings.runtime.schedule_seconds is not None + experience_scheduled = settings.runtime.experience_schedule_seconds is not None + if settings.access.mode == "disabled" or not (source_scheduled or experience_scheduled): + return None, None + if access is None: + raise ValueError("scheduled processing in enforced mode requires an Authorization Provider") # noqa: TRY003 + principal = _scheduled_principal(settings, static_principal=static_principal) + + async def process_sources(scope_id: str, runtime: BuiltinRuntime) -> MemoryFlushResult: + context = AccessAuditContext(transport="background", operation="process_source_window") + await access.bootstrap_static_scope(principal, scope_id, context=context) + await access.require(principal, AccessAction.SCOPE_CONTRIBUTE, ResourceRef.scope(scope_id), context=context) + memory = runtime.memory.for_scope(scope_id) + before = await memory.list(include_inactive=True) + before_keys = {_memory_resource(scope_id, entry).key for entry in before.entries} + await access.require_all( + principal, + tuple((AccessAction.ARTIFACT_WRITE, _memory_resource(scope_id, entry)) for entry in before.entries), + context=context, + ) + result = await memory.flush() + after = await memory.list(include_inactive=True) + for entry in after.entries: + resource = _memory_resource(scope_id, entry) + if resource.key not in before_keys: + await access.establish_artifact_owner( + resource, + principal, + idempotency_key=f"background-memory-owner:{scope_id}:{resource.artifact_id}:{entry.citation.entry_id}", + context=context, + ) + return result + + async def incubate_experience(scope_id: str, runtime: BuiltinRuntime) -> ExperienceIncubationResult: + context = AccessAuditContext(transport="background", operation="incubate_experience_candidates") + await access.bootstrap_static_scope(principal, scope_id, context=context) + await access.require(principal, AccessAction.SCOPE_CONTRIBUTE, ResourceRef.scope(scope_id), context=context) + before = await _pending_experience_candidates(runtime, scope_id) + result = await runtime.experience.for_scope(scope_id).incubate() + after = await _pending_experience_candidates(runtime, scope_id) + for candidate_id in after.keys() - before.keys(): + candidate = after[candidate_id] + await access.attest_candidate_owner( + scope_id=scope_id, + candidate_id=candidate.candidate_id, + family=candidate.family, + proposed_owner=principal, + target=None, + idempotency_key=f"background-candidate-owner:{scope_id}:{candidate.candidate_id}", + ) + return result + + return ( + process_sources if source_scheduled else None, + incubate_experience if experience_scheduled else None, + ) + + +def _scheduled_principal(settings: ServerSettings, *, static_principal: PrincipalRef) -> PrincipalRef: + if settings.access.background_principal_id is not None: + return PrincipalRef( + type="service", + id=settings.access.background_principal_id, + description=settings.access.background_principal_description, + ) + if settings.auth.provider == "static-bearer": + return static_principal + raise ValueError("scheduled processing in enforced mode requires ACCESS_BACKGROUND_PRINCIPAL_ID") # noqa: TRY003 + + +def _memory_resource(scope_id: str, entry: MemoryEntryRecord) -> ResourceRef: + citation = entry.citation + return ResourceRef.artifact( + scope_id, + family="memory", + artifact_id=citation.memory_ref.artifact_id, + selector=MemoryEntrySelector(entry_id=citation.entry_id), + ) + + +async def _pending_experience_candidates( + runtime: BuiltinRuntime, + scope_id: str, +) -> dict[str, ReviewedCandidate]: + candidates: dict[str, ReviewedCandidate] = {} + cursor: str | None = None + while True: + page = await runtime.review.for_scope(scope_id).list( + ListArtifactCandidatesRequest(family="experience", cursor=cursor, limit=100) + ) + candidates.update((candidate.candidate_id, candidate) for candidate in page.candidates) + cursor = page.next_cursor + if cursor is None: + return candidates + + def _mount_optional_web_ui(app: FastAPI, settings: ServerSettings) -> None: app.state.dashboard_started = False app.state.dashboard_startup_error = None @@ -253,7 +408,7 @@ def _mount_optional_web_ui(app: FastAPI, settings: ServerSettings) -> None: scopes={scope.scope_id: scope.display_name for scope in settings.dashboard.scopes}, dashboard_enabled=settings.dashboard.enabled, handoff_report_enabled=settings.handoff_report.enabled, - authentication_required=settings.auth.enabled, + authentication_required=settings.access.mode == "enforced", agent_skill_targets=settings.external_skills.agent_targets, ) if settings.dashboard.enabled: diff --git a/src/powercontext/server/middleware.py b/src/powercontext/server/middleware.py index a099ee5ad..3a2a94bfd 100644 --- a/src/powercontext/server/middleware.py +++ b/src/powercontext/server/middleware.py @@ -4,7 +4,7 @@ # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, @@ -12,81 +12,109 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""ASGI middleware provided by the PowerContext Server.""" +"""ASGI authentication middleware provided by the PowerContext Server.""" from __future__ import annotations -from secrets import compare_digest - from starlette.datastructures import Headers from starlette.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send from powercontext.http import ErrorDetail, ErrorResponse +from powercontext.server.authentication import ( + AuthenticationProvider, + AuthenticationRejectedError, + AuthenticationRequest, + AuthenticationUnavailableError, + StaticBearerAuthenticationProvider, +) from powercontext.server.authz import PrincipalRef -from powercontext.server.context import bind_principal, is_internal_bridge, reset_principal +from powercontext.server.context import bind_authentication, is_internal_bridge, reset_authentication _PUBLIC_PATHS = frozenset({"/", "/docs", "/handoff-reports", "/reviews", "/skills", "/health/live", "/health/ready"}) _PUBLIC_PATH_PREFIXES = ("/static/",) -class StaticBearerMiddleware: - """Require one configured bearer token for external HTTP requests.""" +class AuthenticationMiddleware: + """Authenticate every protected external HTTP request through one Provider.""" - def __init__(self, app: ASGIApp, *, token: str, principal: PrincipalRef | None = None) -> None: - if not token: - raise ValueError("Bearer token must not be empty") # noqa: TRY003 + def __init__(self, app: ASGIApp, *, provider: AuthenticationProvider) -> None: self.app = app - self._token = token.encode() - self._principal = principal or PrincipalRef(type="service", issuer="powercontext:static", id="server-token") + self._provider = provider async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if is_internal_bridge(): + if is_internal_bridge() or _is_public(scope): await self.app(scope, receive, send) return - if self._allows(scope): - if ( - scope["type"] != "http" - or scope["path"] in _PUBLIC_PATHS - or scope["path"].startswith(_PUBLIC_PATH_PREFIXES) - ): - await self.app(scope, receive, send) - return - principal_token = bind_principal(self._principal) - try: - await self.app(scope, receive, send) - finally: - reset_principal(principal_token) + try: + result = await self._provider.authenticate( + AuthenticationRequest( + transport="http", + headers=dict(Headers(scope=scope).items()), + client_host=_client_host(scope), + ) + ) + except AuthenticationRejectedError: + await _error_response("unauthorized", "A valid credential is required.", 401, scope, receive, send) return - - error = ErrorResponse( - error=ErrorDetail( - code="unauthorized", - message="A valid bearer token is required.", - details=None, + except AuthenticationUnavailableError: + await _error_response( + "authentication_unavailable", + "The authentication service is unavailable.", + 503, + scope, + receive, + send, + ) + return + except Exception: + await _error_response( + "authentication_unavailable", + "The authentication service is unavailable.", + 503, + scope, + receive, + send, ) - ) - response = JSONResponse( - content=error.model_dump(mode="json"), - status_code=401, - headers={"WWW-Authenticate": "Bearer"}, - ) - await response(scope, receive, send) - - def _allows(self, scope: Scope) -> bool: - if scope["type"] != "http" or scope["path"] in _PUBLIC_PATHS or scope["path"].startswith(_PUBLIC_PATH_PREFIXES): - return True - - authorization = Headers(scope=scope).get("authorization") - if authorization is None: - return False - scheme, separator, credential = authorization.partition(" ") - return ( - bool(separator) - and scheme.casefold() == "bearer" - and bool(credential) - and compare_digest(credential.encode(), self._token) - ) - - -__all__ = ["StaticBearerMiddleware"] + return + tokens = bind_authentication(result) + try: + await self.app(scope, receive, send) + finally: + reset_authentication(tokens) + + +class StaticBearerMiddleware(AuthenticationMiddleware): + """Convenience composition for a fixed static bearer Principal.""" + + def __init__(self, app: ASGIApp, *, token: str, principal: PrincipalRef | None = None) -> None: + resolved = principal or PrincipalRef(type="service", id="server-token") + super().__init__(app, provider=StaticBearerAuthenticationProvider(token, resolved)) + + +def _is_public(scope: Scope) -> bool: + return scope["type"] != "http" or scope["path"] in _PUBLIC_PATHS or scope["path"].startswith(_PUBLIC_PATH_PREFIXES) + + +def _client_host(scope: Scope) -> str | None: + client = scope.get("client") + return None if client is None else str(client[0]) + + +async def _error_response( + code: str, + message: str, + status_code: int, + scope: Scope, + receive: Receive, + send: Send, +) -> None: + response = JSONResponse( + content=ErrorResponse(error=ErrorDetail(code=code, message=message, details=None)).model_dump(mode="json"), + status_code=status_code, + headers={"WWW-Authenticate": "Bearer"} if status_code == 401 else None, + ) + await response(scope, receive, send) + + +__all__ = ["AuthenticationMiddleware", "StaticBearerMiddleware"] diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index c04d1fe59..4fc292e27 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -95,25 +95,31 @@ def validate_path(cls, value: str) -> str: return normalized -class BearerAuthConfig(BaseModel): - """Optional static bearer authentication for the local Server.""" +class AuthenticationConfig(BaseModel): + """Authentication Provider selection and provider-specific static settings.""" - enabled: bool = False + provider: Literal["static-bearer", "oidc", "trusted-header"] | None = None token: SecretStr | None = Field(default=None, repr=False) + principal_id: str = Field(default="server-token", min_length=1, max_length=255) + principal_description: str | None = Field(default="PowerContext static bearer", min_length=1, max_length=255) @model_validator(mode="after") - def require_token_when_enabled(self) -> BearerAuthConfig: - if self.enabled and (self.token is None or not self.token.get_secret_value()): + def validate_provider_settings(self) -> AuthenticationConfig: + if self.provider == "static-bearer" and (self.token is None or not self.token.get_secret_value()): raise MissingBearerTokenError("Bearer token is required when authentication is enabled") # noqa: TRY003 + if self.provider != "static-bearer" and self.token is not None: + raise ValueError("AUTH_TOKEN is only valid for AUTH_PROVIDER=static-bearer") # noqa: TRY003 return self class AccessControlConfig(BaseModel): - """Server authorization rollout and bootstrap behavior.""" + """Server security profile and deployment-local authorization identity.""" - mode: Literal["disabled", "legacy-static-admin", "enforced"] = "legacy-static-admin" - bootstrap_static_principal: bool = True + mode: Literal["disabled", "enforced"] = "disabled" + static_preset: bool = True deployment_id: str = Field(default="powercontext", min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$") + background_principal_id: str | None = Field(default=None, min_length=1, max_length=255) + background_principal_description: str | None = Field(default=None, min_length=1, max_length=255) class DashboardScopeConfig(BaseModel): @@ -185,8 +191,9 @@ class ServerSettings(BaseSettings): http: HttpConfig = Field(default_factory=HttpConfig) mcp: McpConfig = Field(default_factory=McpConfig) - auth: BearerAuthConfig = Field(default_factory=BearerAuthConfig) + auth: AuthenticationConfig = Field(default_factory=AuthenticationConfig) access: AccessControlConfig = Field(default_factory=AccessControlConfig) + authorization_provider: Literal["builtin", "casbin", "external"] | None = None allow_unauthenticated_non_loopback: bool = False dashboard: DashboardConfig = Field(default_factory=DashboardConfig) logging: ServerLoggingConfig = Field(default_factory=ServerLoggingConfig) @@ -219,9 +226,29 @@ def default_database_to_sqlite(cls, value: object) -> object: @model_validator(mode="after") def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: + if self.access.background_principal_description is not None and self.access.background_principal_id is None: + raise ValueError("ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION requires BACKGROUND_PRINCIPAL_ID") # noqa: TRY003 + if self.access.mode == "disabled": + if ( + self.auth.provider is not None + or self.auth.token is not None + or self.authorization_provider is not None + or self.access.background_principal_id is not None + ): + raise ValueError("ACCESS_MODE=disabled cannot configure authentication or authorization Providers") # noqa: TRY003 + elif self.auth.provider is None or self.authorization_provider is None: + raise ValueError("ACCESS_MODE=enforced requires authentication and authorization Providers") # noqa: TRY003 + elif ( + (self.runtime.schedule_seconds is not None or self.runtime.experience_schedule_seconds is not None) + and self.auth.provider != "static-bearer" + and self.access.background_principal_id is None + ): + raise ValueError( # noqa: TRY003 + "scheduled processing in a multi-user enforced deployment requires ACCESS_BACKGROUND_PRINCIPAL_ID" + ) if is_unauthenticated_non_loopback_bind( host=self.http.host, - auth_enabled=self.auth.enabled, + auth_enabled=self.access.mode == "enforced", allow_unauthenticated_non_loopback=self.allow_unauthenticated_non_loopback, ): raise UnauthenticatedNonLoopbackBindError(_UNSAFE_BIND_MESSAGE) @@ -230,7 +257,7 @@ def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: __all__ = [ "AccessControlConfig", - "BearerAuthConfig", + "AuthenticationConfig", "DashboardConfig", "DashboardScopeConfig", "HandoffReportConfig", diff --git a/src/powercontext/server/web.py b/src/powercontext/server/web.py index f6cb80c82..55f531941 100644 --- a/src/powercontext/server/web.py +++ b/src/powercontext/server/web.py @@ -144,7 +144,6 @@ async def publish( http_request, request, operation="dashboard_skill_projection_publish", - publish=True, ) resolved = await _dashboard_managed_skill(http_request, request, self._scope_ids) if isinstance(resolved, JSONResponse): @@ -395,11 +394,15 @@ async def _visible_dashboard_scopes( ) if access is None or not dashboard_scopes: return dashboard_scopes + principal = current_principal() + context = _dashboard_access_context("dashboard_scopes") + for item in dashboard_scopes: + await access.bootstrap_static_scope(principal, item.scope_id, context=context) checks = tuple((AccessAction.SCOPE_READ, ResourceRef.scope(item.scope_id)) for item in dashboard_scopes) decisions = await access.check_batch( - current_principal(), + principal, checks, - context=_dashboard_access_context("dashboard_scopes"), + context=context, ) return tuple(item for item, decision in zip(dashboard_scopes, decisions, strict=True) if decision.allowed) @@ -409,7 +412,6 @@ async def _authorize_dashboard_skill( selection: DashboardSkillProjectionRequest, *, operation: str, - publish: bool = False, ) -> None: access = access_control_for_mode( request.app.state.access_control, @@ -421,14 +423,11 @@ async def _authorize_dashboard_skill( selection.scope_id, family=selection.artifact.family, artifact_id=selection.artifact.artifact_id, - revision=selection.artifact.revision, ) checks = [ (AccessAction.SERVER_OBSERVE, ResourceRef.server(access.deployment_id)), (AccessAction.ARTIFACT_READ, resource), ] - if publish: - checks.append((AccessAction.SKILL_PUBLISH, resource)) await access.require_all( current_principal(), checks, diff --git a/tests/e2e/real_experience_skill/test_access_control.py b/tests/e2e/real_experience_skill/test_access_control.py index 009a06454..5cfef98b0 100644 --- a/tests/e2e/real_experience_skill/test_access_control.py +++ b/tests/e2e/real_experience_skill/test_access_control.py @@ -23,7 +23,7 @@ import pytest from dotenv import load_dotenv -from sqlalchemy import delete, func, select +from sqlalchemy import delete, func, or_, select from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile @@ -40,7 +40,14 @@ ResourceRef, ) from powercontext.server.authz.composition import open_builtin_access_control -from powercontext.server.authz.repository import ACCESS_AUDIT_EVENTS_TABLE, ACCESS_BINDINGS_TABLE +from powercontext.server.authz.repository import ( + ACCESS_AUDIT_EVENTS_TABLE, + ACCESS_BINDINGS_TABLE, + ACCESS_CANDIDATE_OWNERS_TABLE, + ACCESS_IDEMPOTENCY_TABLE, + ACCESS_OWNERS_TABLE, + ACCESS_RECEIVER_LEASES_TABLE, +) from powercontext.server.settings import ServerSettings pytestmark = pytest.mark.real_e2e @@ -55,21 +62,19 @@ def test_configured_database_persists_exact_skill_grant_and_revocation(pytestcon suffix = uuid4().hex scope_id = f"configured-real-access:{suffix}" deployment_id = f"configured-real-access-{suffix}" - admin = PrincipalRef(type="service", issuer=f"powercontext:{deployment_id}", id="admin") - receiver = PrincipalRef(type="user", issuer=f"powercontext:{deployment_id}", id="receiver") + admin = PrincipalRef(type="service", id=f"{deployment_id}:admin") + receiver = PrincipalRef(type="user", id=f"{deployment_id}:receiver") async def scenario() -> None: exact = ResourceRef.artifact( scope_id, family="skill", artifact_id=f"managed-skill-{suffix}", - revision=7, ) - adjacent = ResourceRef.artifact( + other = ResourceRef.artifact( scope_id, family="skill", - artifact_id=f"managed-skill-{suffix}", - revision=8, + artifact_id=f"other-skill-{suffix}", ) context = AccessAuditContext(transport="test", operation="configured-real-access") try: @@ -78,27 +83,33 @@ async def scenario() -> None: bootstrap_administrators=(admin,), deployment_id=deployment_id, ) as access: + await access.establish_artifact_owner( + exact, + admin, + idempotency_key=f"owner-skill-{suffix}", + context=context, + ) + await access.establish_artifact_owner( + other, + admin, + idempotency_key=f"owner-other-skill-{suffix}", + context=context, + ) binding = await access.create_binding( admin, CreateBinding( subject=receiver, resource=exact, - role=AccessRole.SKILL_PUBLISHER, - idempotency_key=f"publish-exact-skill-{suffix}", - ), - context=context, - ) - decisions = await access.require_all( - receiver, - ( - (AccessAction.ARTIFACT_READ, exact), - (AccessAction.SKILL_PUBLISH, exact), + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key=f"share-logical-skill-{suffix}", ), context=context, ) - assert all(decision.allowed for decision in decisions) + assert (await access.require(receiver, AccessAction.ARTIFACT_READ, exact, context=context)).allowed + with pytest.raises(AccessDeniedError): + await access.require(receiver, AccessAction.ARTIFACT_WRITE, exact, context=context) with pytest.raises(AccessDeniedError): - await access.require(receiver, AccessAction.ARTIFACT_READ, adjacent, context=context) + await access.require(receiver, AccessAction.ARTIFACT_READ, other, context=context) visible = await access.list_resources( receiver, @@ -114,6 +125,7 @@ async def scenario() -> None: admin, binding.binding_id, expected_version=binding.version, + idempotency_key=f"revoke-skill-share-{suffix}", context=context, ) assert revoked.version == binding.version + 1 @@ -129,23 +141,70 @@ async def scenario() -> None: ) ).total == 0 finally: - remaining = await _purge_scope(settings.database, scope_id) + remaining = await _purge_scope( + settings.database, + scope_id=scope_id, + deployment_id=deployment_id, + actor_ids=(admin.id, receiver.id), + ) assert remaining == 0 asyncio.run(scenario()) -async def _purge_scope(database: DatabaseConfig, scope_id: str) -> int: +async def _purge_scope( + database: DatabaseConfig, + *, + scope_id: str, + deployment_id: str, + actor_ids: tuple[str, ...], +) -> int: async with _profile(database) as profile, profile.database.transaction() as connection: await connection.execute( - delete(ACCESS_AUDIT_EVENTS_TABLE).where(ACCESS_AUDIT_EVENTS_TABLE.c.scope_id == scope_id) + delete(ACCESS_AUDIT_EVENTS_TABLE).where( + or_( + ACCESS_AUDIT_EVENTS_TABLE.c.scope_id == scope_id, + ACCESS_AUDIT_EVENTS_TABLE.c.deployment_id == deployment_id, + ) + ) + ) + await connection.execute( + delete(ACCESS_RECEIVER_LEASES_TABLE).where( + ACCESS_RECEIVER_LEASES_TABLE.c.binding_id.in_( + select(ACCESS_BINDINGS_TABLE.c.binding_id).where( + or_( + ACCESS_BINDINGS_TABLE.c.scope_id == scope_id, + ACCESS_BINDINGS_TABLE.c.deployment_id == deployment_id, + ) + ) + ) + ) + ) + await connection.execute( + delete(ACCESS_CANDIDATE_OWNERS_TABLE).where(ACCESS_CANDIDATE_OWNERS_TABLE.c.scope_id == scope_id) + ) + await connection.execute(delete(ACCESS_OWNERS_TABLE).where(ACCESS_OWNERS_TABLE.c.scope_id == scope_id)) + await connection.execute( + delete(ACCESS_BINDINGS_TABLE).where( + or_( + ACCESS_BINDINGS_TABLE.c.scope_id == scope_id, + ACCESS_BINDINGS_TABLE.c.deployment_id == deployment_id, + ) + ) + ) + await connection.execute( + delete(ACCESS_IDEMPOTENCY_TABLE).where(ACCESS_IDEMPOTENCY_TABLE.c.actor_id.in_(actor_ids)) ) - await connection.execute(delete(ACCESS_BINDINGS_TABLE).where(ACCESS_BINDINGS_TABLE.c.scope_id == scope_id)) binding_count = int( await connection.scalar( select(func.count()) .select_from(ACCESS_BINDINGS_TABLE) - .where(ACCESS_BINDINGS_TABLE.c.scope_id == scope_id) + .where( + or_( + ACCESS_BINDINGS_TABLE.c.scope_id == scope_id, + ACCESS_BINDINGS_TABLE.c.deployment_id == deployment_id, + ) + ) ) or 0 ) @@ -153,11 +212,38 @@ async def _purge_scope(database: DatabaseConfig, scope_id: str) -> int: await connection.scalar( select(func.count()) .select_from(ACCESS_AUDIT_EVENTS_TABLE) - .where(ACCESS_AUDIT_EVENTS_TABLE.c.scope_id == scope_id) + .where( + or_( + ACCESS_AUDIT_EVENTS_TABLE.c.scope_id == scope_id, + ACCESS_AUDIT_EVENTS_TABLE.c.deployment_id == deployment_id, + ) + ) + ) + or 0 + ) + owner_count = int( + await connection.scalar( + select(func.count()).select_from(ACCESS_OWNERS_TABLE).where(ACCESS_OWNERS_TABLE.c.scope_id == scope_id) + ) + or 0 + ) + candidate_count = int( + await connection.scalar( + select(func.count()) + .select_from(ACCESS_CANDIDATE_OWNERS_TABLE) + .where(ACCESS_CANDIDATE_OWNERS_TABLE.c.scope_id == scope_id) + ) + or 0 + ) + idempotency_count = int( + await connection.scalar( + select(func.count()) + .select_from(ACCESS_IDEMPOTENCY_TABLE) + .where(ACCESS_IDEMPOTENCY_TABLE.c.actor_id.in_(actor_ids)) ) or 0 ) - return binding_count + audit_count + return binding_count + audit_count + owner_count + candidate_count + idempotency_count @asynccontextmanager diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py index 0606c336c..14cf5b59a 100644 --- a/tests/e2e/test_access_control_http.py +++ b/tests/e2e/test_access_control_http.py @@ -21,10 +21,13 @@ import httpx import pytest -from starlette.middleware import Middleware +from pydantic import SecretStr from powercontext.builtin.artifacts.handoff import HandoffDraft, HandoffGenerationRequest, HandoffStatement +from powercontext.builtin.artifacts.memory import MemoryCandidateRequest, MemoryEntryInput from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime.config import RuntimeConfig +from powercontext.builtin.sources import ContentSource from powercontext.client import ForbiddenResponseError, PowerContextClient from powercontext.http import ( AccessAction, @@ -38,22 +41,24 @@ FinalizeHandoffRequest, HandoffSelection, ListAccessResourcesRequest, + ListMemoryEntriesRequest, RevokeAccessBindingRequest, ) +from powercontext.server.authentication import StaticBearerAuthenticationProvider from powercontext.server.authz import AccessControlService, PrincipalRef from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.factory import create_server_app -from powercontext.server.middleware import StaticBearerMiddleware from powercontext.server.settings import ( AccessControlConfig, + AuthenticationConfig, DashboardConfig, McpConfig, MetricsConfig, ServerSettings, ) -ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") -RECEIVER = PrincipalRef(type="user", issuer="https://identity.example", id="bob") +ADMIN = PrincipalRef(type="service", id="admin") +RECEIVER = PrincipalRef(type="user", id="bob") DEPLOYMENT_ID = "access-control-http-e2e" @@ -68,7 +73,16 @@ async def generate(self, request: HandoffGenerationRequest, /) -> HandoffDraft: ) -def test_exact_handoff_grant_and_revoke_cross_the_public_server_boundary(tmp_path: Path) -> None: +class _ContentMemoryPipeline: + async def extract(self, request: MemoryCandidateRequest, /) -> tuple[MemoryEntryInput, ...]: + return tuple( + MemoryEntryInput(kind="fact", text=source.content, sources=(source,)) + for source in request.sources + if isinstance(source, ContentSource) + ) + + +def test_logical_handoff_grant_and_revoke_cross_the_public_server_boundary(tmp_path: Path) -> None: async def scenario() -> None: database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}") async with open_builtin_access_control( @@ -84,39 +98,53 @@ async def scenario() -> None: CaptureContentSourceRequest( scope_id="access-e2e", source_id="handoff-boundary", - content="The receiver must see only one explicitly shared Handoff Revision.", + content="The receiver may read every Revision of one explicitly shared logical Handoff.", ) ) activation = await admin.activate_handoff( ActivateHandoffRequest( scope_id="access-e2e", boundary_source=captured.source, - objective="Transfer one exact committed Handoff.", + objective="Transfer one committed logical Handoff.", ) ) assert activation.draft is not None prepared = await admin.finalize_handoff( FinalizeHandoffRequest(scope_id="access-e2e", draft=activation.draft) ) - committed = await admin.commit_handoff(CommitHandoffRequest(scope_id="access-e2e", handoff=prepared)) + first_committed = await admin.commit_handoff( + CommitHandoffRequest(scope_id="access-e2e", handoff=prepared) + ) resource = { "type": "artifact", "scope_id": "access-e2e", - "reference": committed.reference.model_dump(mode="json"), + "identity": { + "family": first_committed.reference.family, + "artifact_id": first_committed.reference.artifact_id, + }, "selector": None, } binding = await admin.create_access_binding( CreateAccessBindingRequest.model_validate({ "subject": { "type": RECEIVER.type, - "issuer": RECEIVER.issuer, "id": RECEIVER.id, }, "resource": resource, "role": "handoff.receiver", - "idempotency_key": "share-exact-handoff-with-bob", + "idempotency_key": "share-logical-handoff-with-bob", }) ) + revised_draft = activation.draft.model_copy( + update={"objective": "Transfer the next Revision through the existing logical share."} + ) + revised_prepared = await admin.finalize_handoff( + FinalizeHandoffRequest(scope_id="access-e2e", draft=revised_draft) + ) + committed = await admin.commit_handoff( + CommitHandoffRequest(scope_id="access-e2e", handoff=revised_prepared) + ) + assert committed.reference.revision == first_committed.reference.revision + 1 async with _client( _app(database, access_control, RECEIVER, "receiver-token", tmp_path / "receiver-scheduler.db"), @@ -126,10 +154,10 @@ async def scenario() -> None: ContinueHandoffRequest( scope_id="access-e2e", selection=HandoffSelection.EXACT, - revision=committed.reference, + revision=first_committed.reference, ) ) - assert exact.selected_revision == committed.reference + assert exact.selected_revision == first_committed.reference receipt = await receiver.acknowledge_handoff( AcknowledgeHandoffRequest.model_validate({ "scope_id": "access-e2e", @@ -142,15 +170,15 @@ async def scenario() -> None: "capability": "confirmed", "authorization": "confirmed", }, - "revision": committed.reference, + "revision": first_committed.reference, }) ) - assert receipt.resolution.selected_revision == committed.reference + assert receipt.resolution.selected_revision == first_committed.reference - with pytest.raises(ForbiddenResponseError): - await receiver.continue_handoff( - ContinueHandoffRequest(scope_id="access-e2e", selection=HandoffSelection.LATEST) - ) + latest = await receiver.continue_handoff( + ContinueHandoffRequest(scope_id="access-e2e", selection=HandoffSelection.LATEST) + ) + assert latest.selected_revision == committed.reference visible = await receiver.list_access_resources( ListAccessResourcesRequest( action=AccessAction.ARTIFACT_READ, @@ -166,7 +194,11 @@ async def scenario() -> None: "admin-token", ) as admin: revoked = await admin.revoke_access_binding( - RevokeAccessBindingRequest(binding_id=binding.binding_id, expected_version=binding.version) + RevokeAccessBindingRequest( + binding_id=binding.binding_id, + expected_version=binding.version, + idempotency_key="revoke-receiver-binding", + ) ) assert revoked.state == "revoked" @@ -195,6 +227,56 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_scheduled_memory_processing_uses_the_static_service_principal_as_owner(tmp_path: Path) -> None: + async def scenario() -> None: + token = "scheduled-static-token" # noqa: S105 - test credential. + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'scheduled-runtime.db'}"), + runtime=RuntimeConfig(schedule_seconds=0.02), + access=AccessControlConfig( + mode="enforced", + deployment_id="scheduled-access-e2e", + ), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr(token)), + authorization_provider="builtin", + dashboard=DashboardConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + mcp=McpConfig(enabled=False), + ), + scheduler_path=tmp_path / "scheduled-access.db", + candidate_pipeline=_ContentMemoryPipeline(), + ) + async with _client(app, token) as client: + await client.capture_content_source( + CaptureContentSourceRequest( + scope_id="scheduled-access", + source_id="scheduled-source", + content="The scheduled service owns this extracted Memory entry.", + ) + ) + for _ in range(100): + entries = await client.list_memory_entries(ListMemoryEntriesRequest(scope_id="scheduled-access")) + if entries.entries: + break + await asyncio.sleep(0.02) + assert len(entries.entries) == 1 + + visible = await client.list_access_resources( + ListAccessResourcesRequest( + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="memory", + ) + ) + assert visible.total == 1 + resource = visible.items[0].model_dump(mode="json") + assert resource["identity"]["family"] == "memory" + assert resource["selector"]["entry_id"] == entries.entries[0].citation.entry_id + + asyncio.run(scenario()) + + def _app( database: SQLiteConfig, access_control: AccessControlService, @@ -202,14 +284,17 @@ def _app( token: str, scheduler_path: Path, ): + authentication = StaticBearerAuthenticationProvider(token, principal) return create_server_app( settings=ServerSettings( database=database, access=AccessControlConfig( mode="enforced", - bootstrap_static_principal=False, + static_preset=False, deployment_id=DEPLOYMENT_ID, ), + auth=AuthenticationConfig(provider="oidc"), + authorization_provider="external", dashboard=DashboardConfig(enabled=False), metrics=MetricsConfig(enabled=False), mcp=McpConfig(enabled=False), @@ -217,7 +302,7 @@ def _app( scheduler_path=scheduler_path, handoff_pipeline=_DeterministicHandoffPipeline(), access_control=access_control, - middleware=(Middleware(StaticBearerMiddleware, token=token, principal=principal),), + authentication_provider=authentication, ) diff --git a/tests/e2e/test_claude_code_service_chain.py b/tests/e2e/test_claude_code_service_chain.py index caa5ea4ea..ccc552e56 100644 --- a/tests/e2e/test_claude_code_service_chain.py +++ b/tests/e2e/test_claude_code_service_chain.py @@ -37,7 +37,7 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import InferenceConfig from powercontext.server.factory import create_server_app -from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings PROJECT_ROOT = Path(__file__).resolve().parents[2] CLAUDE_PLUGIN = PROJECT_ROOT / "integrations" / "claude-code" / "plugins" / "powercontext" @@ -83,10 +83,12 @@ def test_claude_sessions_and_codex_share_one_project_memory( ) app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig( - enabled=authentication_enabled, + auth=AuthenticationConfig( + provider="static-bearer" if authentication_enabled else None, token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), + access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), + authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=True), @@ -153,10 +155,12 @@ def test_claude_plugin_mcp_supports_explicit_memory_and_handoff_workflows( ) -> None: app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig( - enabled=authentication_enabled, + auth=AuthenticationConfig( + provider="static-bearer" if authentication_enabled else None, token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), + access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), + authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'mcp.db'}"), mcp=McpConfig(enabled=True), ), diff --git a/tests/e2e/test_codex_service_chain.py b/tests/e2e/test_codex_service_chain.py index 48a94bd96..0668de84e 100644 --- a/tests/e2e/test_codex_service_chain.py +++ b/tests/e2e/test_codex_service_chain.py @@ -42,7 +42,7 @@ SearchMemoryRequest, ) from powercontext.server.factory import create_server_app -from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings PROJECT_ROOT = Path(__file__).resolve().parents[2] CODEX_PLUGIN = PROJECT_ROOT / "integrations" / "codex" / "plugins" / "powercontext" @@ -74,10 +74,12 @@ def test_codex_hook_http_sdk_and_mcp_share_one_composed_context( ) app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig( - enabled=authentication_enabled, + auth=AuthenticationConfig( + provider="static-bearer" if authentication_enabled else None, token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), + access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), + authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=True), diff --git a/tests/e2e/test_handoff_runtime.py b/tests/e2e/test_handoff_runtime.py index a05936480..8c6ff9ac0 100644 --- a/tests/e2e/test_handoff_runtime.py +++ b/tests/e2e/test_handoff_runtime.py @@ -20,6 +20,7 @@ import pytest +from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.handoff import HandoffScopeMismatchError from powercontext.builtin.artifacts.memory import MemoryEntryInput from powercontext.builtin.persistence.sqlite import SQLiteConfig @@ -243,6 +244,47 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_handoff_resolution_authorizes_each_evidence_target_before_reading_it() -> None: + async def scenario() -> None: + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + source = await runtime.sources.for_scope("project").capture( + CaptureSource(source_id="visible", content="Visible evidence.", metadata={}) + ) + hidden = HandoffArtifactCitation( + artifact_ref=ArtifactRef(family="experience", artifact_id="not-readable", revision=1) + ) + prepared = PreparedHandoff( + scope_id="project", + base=None, + content=HandoffDraft( + objective="Continue with independently authorized evidence.", + state=( + HandoffStatement( + text="One citation is visible and one is hidden.", + citations=(HandoffSourceCitation(source_ref=source.source_ref), hidden), + ), + ), + disposition="continuable", + ).as_content(), + ) + inspected = [] + + async def authorize(citation) -> bool: + inspected.append(citation) + return citation != hidden + + resolution = await runtime.handoff.for_scope("project").continue_from( + prepared, + evidence_authorizer=authorize, + ) + + assert inspected == list(prepared.content.state[0].citations) + assert resolution.evidence_checks[0].status == "unavailable" + assert resolution.evidence_checks[0].unavailable_evidence == (hidden,) + + asyncio.run(scenario()) + + def test_handoff_runtime_rejects_stale_and_cross_scope_use() -> None: async def scenario() -> None: async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: diff --git a/tests/e2e/test_langgraph_chain.py b/tests/e2e/test_langgraph_chain.py index d52e066f9..bd3c26d63 100644 --- a/tests/e2e/test_langgraph_chain.py +++ b/tests/e2e/test_langgraph_chain.py @@ -46,7 +46,7 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import InferenceConfig from powercontext.server.factory import create_server_app -from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings pytest.importorskip("powercontext_langgraph") @@ -98,10 +98,12 @@ def _model_node(state: ChainState) -> dict[str, list[BaseMessage]]: def test_langgraph_write_then_recall_over_real_http(tmp_path: Path, authentication_enabled: bool) -> None: app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig( - enabled=authentication_enabled, + auth=AuthenticationConfig( + provider="static-bearer" if authentication_enabled else None, token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), + access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), + authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=False), diff --git a/tests/e2e/test_runtime_server.py b/tests/e2e/test_runtime_server.py index 7802c1215..a65f4fe87 100644 --- a/tests/e2e/test_runtime_server.py +++ b/tests/e2e/test_runtime_server.py @@ -85,11 +85,11 @@ OCEANBASE_URL = os.environ.get("POWERCONTEXT_TEST_OCEANBASE_URL") _ACCESS_READINESS_CHECKS = { - "access_mode": "legacy-static-admin", + "access_mode": "disabled", + "authentication_provider": "disabled", "access_provider": "disabled", "access_resource_kinds": "server,scope,artifact", "access_artifact_families": "experience:enabled,handoff:enabled,memory:enabled,prompt:disabled,skill:enabled", - "access_skill_publication": "disabled", } EMBEDDING_PROFILE = EmbeddingProfile( profile_id="database-e2e-v1", diff --git a/tests/e2e/test_statistics_flow.py b/tests/e2e/test_statistics_flow.py index d3b516549..ce939606c 100644 --- a/tests/e2e/test_statistics_flow.py +++ b/tests/e2e/test_statistics_flow.py @@ -45,7 +45,7 @@ StatsPeriod, ) from powercontext.server.factory import create_server_app -from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings _AUTH_TOKEN = "statistics-e2e-token" # noqa: S105 - non-secret test credential. _OCEANBASE_URL = os.environ.get("POWERCONTEXT_TEST_OCEANBASE_URL") @@ -70,7 +70,9 @@ def _settings(database_kind: str, database: Path) -> ServerSettings: persistence = SQLiteConfig(url=f"sqlite+aiosqlite:///{database}") return ServerSettings( database=persistence, - auth=BearerAuthConfig(enabled=True, token=SecretStr(_AUTH_TOKEN)), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr(_AUTH_TOKEN)), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=False), ) diff --git a/tests/e2e/test_workbuddy_service_chain.py b/tests/e2e/test_workbuddy_service_chain.py index efe315210..74d4b59c7 100644 --- a/tests/e2e/test_workbuddy_service_chain.py +++ b/tests/e2e/test_workbuddy_service_chain.py @@ -36,7 +36,7 @@ from powercontext.builtin.runtime import InferenceConfig from powercontext.cli.workbuddy import install_workbuddy_plugin from powercontext.server.factory import create_server_app -from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings PROJECT_ROOT = Path(__file__).resolve().parents[2] WORKBUDDY_PLUGIN = PROJECT_ROOT / "integrations" / "workbuddy" / "plugins" / "powercontext" @@ -70,10 +70,12 @@ def test_workbuddy_hook_and_mcp_share_one_service_configuration( ) app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig( - enabled=authentication_enabled, + auth=AuthenticationConfig( + provider="static-bearer" if authentication_enabled else None, token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), + access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), + authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=True), diff --git a/tests/test_access_adapters.py b/tests/test_access_adapters.py index b871b958e..8702865df 100644 --- a/tests/test_access_adapters.py +++ b/tests/test_access_adapters.py @@ -26,10 +26,11 @@ from powercontext.server.authz import ( AccessAction, AccessAuditContext, + AccessBinding, + AccessBindingState, AccessControlService, AccessProviderCapabilities, AccessRequest, - AccessResourceType, AccessRole, AccessUnavailableError, AuthZenAuthorizationProvider, @@ -44,179 +45,75 @@ from powercontext.server.authz.composition import open_casbin_access_control from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository -NOW = datetime(2026, 9, 1, 12, tzinfo=UTC) -ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") -BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") -ALICE = PrincipalRef(type="user", issuer="https://identity.example", id="alice") -CAROL = PrincipalRef(type="user", issuer="https://identity.example", id="carol") +ADMIN = PrincipalRef(type="service", id="admin") +ALICE = PrincipalRef(type="user", id="alice", description="Alice") +BOB = PrincipalRef(type="user", id="bob") AUDIT = AccessAuditContext(transport="http", operation="adapter-conformance", request_id="req-adapter") -def test_builtin_and_casbin_adapters_share_the_same_access_semantics() -> None: +def test_builtin_and_casbin_adapters_share_terminal_semantics() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: repository = RelationalAccessRepository(profile.database) - builtin_provider = BuiltinAuthorizationProvider( - repository, - bootstrap_administrators=(ADMIN,), - clock=lambda: NOW, - ) - casbin_provider = CasbinAuthorizationProvider( - repository, - bootstrap_administrators=(ADMIN,), - clock=lambda: NOW, - ) - casbin_service = AccessControlService( - casbin_provider, - relationships=casbin_provider, - audit=repository, - clock=lambda: NOW, - ) - exact = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=3) - sibling = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=4) - binding = await casbin_service.create_binding( + await _seed_admin(repository) + builtin = BuiltinAuthorizationProvider(repository) + casbin = CasbinAuthorizationProvider(repository) + service = AccessControlService(builtin, relationships=repository, audit=repository) + handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff") + other = ResourceRef.artifact("scope-a", family="handoff", artifact_id="other") + await service.establish_artifact_owner(handoff, ALICE, idempotency_key="owner-handoff", context=AUDIT) + await service.establish_artifact_owner(other, ALICE, idempotency_key="owner-other", context=AUDIT) + await service.create_binding( ADMIN, CreateBinding( subject=BOB, - resource=exact, + resource=handoff, role=AccessRole.HANDOFF_RECEIVER, - idempotency_key="casbin-handoff-receiver", - ), - context=AUDIT, - ) - await casbin_service.create_binding( - ADMIN, - CreateBinding( - subject=ALICE, - resource=ResourceRef.server(), - role=AccessRole.SERVER_OBSERVER, - idempotency_key="casbin-server-observer", - ), - context=AUDIT, - ) - await casbin_service.create_binding( - ADMIN, - CreateBinding( - subject=CAROL, - resource=ResourceRef.server(), - role=AccessRole.SERVER_ADMIN, - idempotency_key="casbin-server-admin", + idempotency_key="receiver-bob", ), context=AUDIT, ) - vectors = _handoff_conformance_vectors(exact, sibling) + vectors = ( + (AccessAction.ARTIFACT_READ, handoff, True), + (AccessAction.HANDOFF_EVIDENCE_INSPECT, handoff, True), + (AccessAction.HANDOFF_ACKNOWLEDGE, handoff, True), + (AccessAction.ARTIFACT_WRITE, handoff, False), + (AccessAction.ARTIFACT_READ, other, False), + (AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), False), + ) for action, resource, expected in vectors: request = AccessRequest(subject=BOB, action=action, resource=resource, context=AUDIT) - builtin = await builtin_provider.check(request) - casbin = await casbin_provider.check(request) - assert builtin.allowed is casbin.allowed is expected - assert builtin.policy_revision == casbin.policy_revision - - administrative_vectors = ( - (ALICE, AccessAction.SERVER_OBSERVE, ResourceRef.server(), True), - (ALICE, AccessAction.SERVER_ADMIN, ResourceRef.server(), False), - (ALICE, AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), False), - (CAROL, AccessAction.SERVER_OBSERVE, ResourceRef.server(), True), - (CAROL, AccessAction.SERVER_ADMIN, ResourceRef.server(), True), - (CAROL, AccessAction.SCOPE_ADMIN, ResourceRef.scope("scope-a"), True), - ( - CAROL, - AccessAction.SKILL_PUBLISH, - ResourceRef.artifact( - "scope-a", - family="skill", - artifact_id="skill-a", - revision=1, - ), - True, - ), - ) - for subject, action, resource, expected in administrative_vectors: - request = AccessRequest(subject=subject, action=action, resource=resource, context=AUDIT) - builtin = await builtin_provider.check(request) - casbin = await casbin_provider.check(request) - assert builtin.allowed is casbin.allowed is expected - - builtin_filter = await builtin_provider.resolve_resource_filter( - _search_request(BOB, AccessAction.ARTIFACT_READ, family="handoff") - ) - casbin_filter = await casbin_provider.resolve_resource_filter( - _search_request(BOB, AccessAction.ARTIFACT_READ, family="handoff") - ) - assert builtin_filter == casbin_filter - - revoked = await casbin_service.revoke_binding( - ADMIN, - binding.binding_id, - expected_version=binding.version, - context=AUDIT, - ) - assert revoked.version == 2 - denied = AccessRequest(subject=BOB, action=AccessAction.ARTIFACT_READ, resource=exact, context=AUDIT) - assert (await builtin_provider.check(denied)).allowed is False - assert (await casbin_provider.check(denied)).allowed is False + builtin_decision = await builtin.check(request) + casbin_decision = await casbin.check(request) + assert builtin_decision.allowed is casbin_decision.allowed is expected + assert builtin_decision.policy_revision == casbin_decision.policy_revision asyncio.run(scenario()) -def test_casbin_composition_opens_a_writable_access_service() -> None: +def test_casbin_composition_has_writable_relationships_and_owner_enforcement() -> None: async def scenario() -> None: - async with open_casbin_access_control( - SQLiteConfig(), - bootstrap_administrators=(ADMIN,), - ) as service: - exact = ResourceRef.artifact("scope-a", family="experience", artifact_id="experience-a", revision=1) + async with open_casbin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: + experience = ResourceRef.artifact("scope-a", family="experience", artifact_id="experience-a") + await service.establish_artifact_owner(experience, ALICE, idempotency_key="owner-experience", context=AUDIT) await service.create_binding( ADMIN, CreateBinding( subject=BOB, - resource=exact, + resource=experience, role=AccessRole.ARTIFACT_VIEWER, - idempotency_key="casbin-composition-viewer", + idempotency_key="share-experience", ), context=AUDIT, ) - assert (await service.require(BOB, AccessAction.ARTIFACT_READ, exact, context=AUDIT)).allowed - - asyncio.run(scenario()) - - -def test_authzen_adapter_matches_the_exact_resource_conformance_vector() -> None: - exact = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=3) - sibling = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=4) - vectors = _handoff_conformance_vectors(exact, sibling) - expected = {(action.value, resource.key): allowed for action, resource, allowed in vectors} - - def handler(request: httpx.Request) -> httpx.Response: - payload = json.loads(request.content) - decisions = [ - { - "decision": expected[ - ( - evaluation["action"]["name"], - evaluation["resource"]["id"], - ) - ] - } - for evaluation in payload["evaluations"] - ] - return httpx.Response(200, json={"evaluations": decisions}) - - async def scenario() -> None: - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - provider = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) - requests = tuple( - AccessRequest(subject=BOB, action=action, resource=resource, context=AUDIT) - for action, resource, _expected in vectors - ) - decisions = await provider.check_batch(requests) - assert [decision.allowed for decision in decisions] == [value for _action, _resource, value in vectors] + assert (await service.require(BOB, AccessAction.ARTIFACT_READ, experience, context=AUDIT)).allowed + assert not (await service.check(BOB, AccessAction.ARTIFACT_WRITE, experience, context=AUDIT)).allowed asyncio.run(scenario()) -def test_authzen_adapter_uses_standard_point_and_boxcar_shapes_and_fails_closed() -> None: +def test_authzen_uses_logical_identity_and_description_without_issuer() -> None: seen: list[dict[str, object]] = [] def handler(request: httpx.Request) -> httpx.Response: @@ -225,19 +122,10 @@ def handler(request: httpx.Request) -> httpx.Response: seen.append(payload) if request.url.path.endswith("/evaluation"): return httpx.Response(200, json={"decision": True, "context": {"policy_revision": "pdp-42"}}) - evaluations = payload["evaluations"] - return httpx.Response( - 200, - json={ - "evaluations": [ - {"decision": evaluation["action"]["name"] == "artifact.read"} for evaluation in evaluations - ] - }, - ) + return httpx.Response(200, json={"evaluations": [{"decision": True}]}) async def scenario() -> None: - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient(transport=transport) as client: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: provider = AuthZenAuthorizationProvider( "http://127.0.0.1:9876", token=SecretStr("provider-token"), @@ -246,36 +134,24 @@ async def scenario() -> None: resource = ResourceRef.artifact( "scope-a", family="memory", - artifact_id="memory-a", - revision=4, - selector=MemoryEntrySelector(entry_id="entry-a", entry_version_id="entry-version-2"), + artifact_id="memory", + selector=MemoryEntrySelector(entry_id="entry-a"), ) - read = AccessRequest(subject=BOB, action=AccessAction.ARTIFACT_READ, resource=resource, context=AUDIT) - publish = AccessRequest(subject=BOB, action=AccessAction.SKILL_PUBLISH, resource=resource, context=AUDIT) - point = await provider.check(read) - batch = await provider.check_batch((read, publish)) - - assert point.allowed is True - assert point.policy_revision == "pdp-42" - assert [decision.allowed for decision in batch] == [True, False] + decision = await provider.check( + AccessRequest(subject=ALICE, action=AccessAction.ARTIFACT_READ, resource=resource, context=AUDIT) + ) + assert decision.allowed + assert decision.policy_revision == "pdp-42" assert seen[0] == { - "subject": { - "type": "user", - "id": "bob", - "properties": {"issuer": "https://identity.example"}, - }, + "subject": {"type": "user", "id": "alice", "properties": {"description": "Alice"}}, "action": {"name": "artifact.read"}, "resource": { "type": "artifact", "id": resource.key, "properties": { "scope_id": "scope-a", - "reference": {"family": "memory", "artifact_id": "memory-a", "revision": 4}, - "selector": { - "type": "memory_entry", - "entry_id": "entry-a", - "entry_version_id": "entry-version-2", - }, + "identity": {"family": "memory", "artifact_id": "memory"}, + "selector": {"type": "memory_entry", "entry_id": "entry-a"}, }, }, "context": { @@ -284,57 +160,47 @@ async def scenario() -> None: "operation": "adapter-conformance", }, } - assert seen[1]["options"] == {"evaluations_semantic": "execute_all"} with pytest.raises(AccessUnavailableError, match="filtering"): await provider.resolve_resource_filter( - _search_request(BOB, AccessAction.ARTIFACT_READ, family="memory") + ResourceSearchRequest( + subject=ALICE, + action=AccessAction.ARTIFACT_READ, + resource_type=resource.type, + family="memory", + context=AUDIT, + ) ) - malformed = httpx.MockTransport(lambda _request: httpx.Response(200, json={"decision": "allow"})) - async with httpx.AsyncClient(transport=malformed) as client: - provider = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) - with pytest.raises(AccessUnavailableError): - await provider.check(read) - asyncio.run(scenario()) -def test_authzen_adapter_enforces_the_policy_revision_contract_boundary() -> None: - request = AccessRequest( - subject=BOB, - action=AccessAction.SERVER_OBSERVE, - resource=ResourceRef.server(), - context=AUDIT, - ) - - async def evaluate(revision: str): - transport = httpx.MockTransport( - lambda _request: httpx.Response( - 200, - json={"decision": True, "context": {"policy_revision": revision}}, - ) - ) - async with httpx.AsyncClient(transport=transport) as client: +def test_authzen_fails_closed_on_malformed_decisions_and_cannot_manage_relationships() -> None: + async def scenario() -> None: + malformed = httpx.MockTransport(lambda _request: httpx.Response(200, json={"decision": "allow"})) + async with httpx.AsyncClient(transport=malformed) as client: provider = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) - return await provider.check(request) - - accepted = asyncio.run(evaluate("r" * 64)) - assert accepted.policy_revision == "r" * 64 - with pytest.raises(AccessUnavailableError): - asyncio.run(evaluate("r" * 65)) + request = AccessRequest( + subject=BOB, + action=AccessAction.SERVER_OBSERVE, + resource=ResourceRef.server(), + context=AUDIT, + ) + with pytest.raises(AccessUnavailableError): + await provider.check(request) + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) -def test_authzen_adapter_rejects_credential_urls_and_relationship_claims() -> None: - with pytest.raises(ValueError, match="credential-free"): - AuthZenAuthorizationProvider("https://user:secret@pdp.example") - with pytest.raises(ValueError, match="credential-free"): - AuthZenAuthorizationProvider("http://pdp.example") + def allow(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + if request.url.path.endswith("/evaluations"): + return httpx.Response( + 200, + json={"evaluations": [{"decision": True} for _ in payload["evaluations"]]}, + ) + return httpx.Response(200, json={"decision": True}) - async def scenario() -> None: - repository_profile = SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) - async with repository_profile as profile: - repository = RelationalAccessRepository(profile.database) - transport = httpx.MockTransport(lambda _: httpx.Response(200, json={"decision": True})) + transport = httpx.MockTransport(allow) async with httpx.AsyncClient(transport=transport) as client: provider = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) service = AccessControlService( @@ -362,25 +228,27 @@ async def scenario() -> None: asyncio.run(scenario()) -def _search_request(subject: PrincipalRef, action: AccessAction, *, family: str) -> ResourceSearchRequest: - return ResourceSearchRequest( - subject=subject, - action=action, - resource_type=AccessResourceType.ARTIFACT, - family=family, - context=AUDIT, - ) +def test_authzen_rejects_credential_urls_and_insecure_remote_http() -> None: + with pytest.raises(ValueError, match="credential-free"): + AuthZenAuthorizationProvider("https://user:secret@pdp.example") + with pytest.raises(ValueError, match="credential-free"): + AuthZenAuthorizationProvider("http://pdp.example") -def _handoff_conformance_vectors( - exact: ResourceRef, - sibling: ResourceRef, -) -> tuple[tuple[AccessAction, ResourceRef, bool], ...]: - return ( - (AccessAction.ARTIFACT_READ, exact, True), - (AccessAction.HANDOFF_EVIDENCE_READ, exact, True), - (AccessAction.HANDOFF_ACKNOWLEDGE, exact, True), - (AccessAction.ARTIFACT_READ, sibling, False), - (AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), False), - (AccessAction.SERVER_OBSERVE, ResourceRef.server(), False), +async def _seed_admin(repository: RelationalAccessRepository) -> None: + await repository.create_binding( + AccessBinding( + binding_id="seed-admin", + subject=ADMIN, + resource=ResourceRef.server(), + role=AccessRole.SERVER_ADMIN, + granted_by=ADMIN, + reason="test bootstrap", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key="seed-admin", + ) ) diff --git a/tests/test_access_control.py b/tests/test_access_control.py index 80e5409fe..f71702471 100644 --- a/tests/test_access_control.py +++ b/tests/test_access_control.py @@ -15,545 +15,403 @@ from __future__ import annotations import asyncio -from datetime import UTC, datetime, timedelta -from types import SimpleNamespace -from typing import cast -from unittest.mock import AsyncMock +from datetime import UTC, datetime import pytest -from sqlalchemy.dialects import mysql -from sqlalchemy.ext.asyncio import AsyncConnection -from sqlalchemy.schema import CreateTable from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.server.authz import ( AccessAction, AccessAuditContext, - AccessAuditStore, + AccessBinding, + AccessBindingState, AccessConflictError, AccessControlService, - AccessDecision, AccessDeniedError, AccessInvalidRequestError, AccessProviderCapabilities, - AccessRequest, AccessResourceType, AccessRole, AccessUnavailableError, - AuthorizationProvider, BuiltinAuthorizationProvider, CreateBinding, + GroupRef, MemoryEntrySelector, PrincipalRef, + ReassignHandoffReceiver, ResourceRef, ) -from powercontext.server.authz.repository import ( - ACCESS_AUDIT_EVENTS_TABLE, - ACCESS_BINDINGS_TABLE, - ACCESS_TABLES, - RelationalAccessRepository, - ensure_access_policy_revision_columns, -) +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository -NOW = datetime(2026, 8, 30, 10, tzinfo=UTC) -ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") -ALICE = PrincipalRef(type="user", issuer="https://identity.example", id="alice") -BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") +ADMIN = PrincipalRef(type="service", id="admin", description="deployment administrator") +ALICE = PrincipalRef(type="user", id="alice", description="artifact owner") +BOB = PrincipalRef(type="user", id="bob") +TEAM = GroupRef(type="group", id="team-platform", description="Platform team") AUDIT = AccessAuditContext(transport="http", operation="test", request_id="req-1") -def test_exact_handoff_receiver_cannot_discover_other_handoffs_or_scope_data() -> None: +def test_logical_artifact_share_is_read_only_across_all_versions() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, repository = _service(profile.database) - exact = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=3) - created = await service.create_binding( + async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: + skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a") + owner = await service.establish_artifact_owner(skill, ALICE, idempotency_key="owner-skill-a", context=AUDIT) + assert owner.owner == ALICE + + await service.create_binding( ADMIN, CreateBinding( subject=BOB, - resource=exact, - role=AccessRole.HANDOFF_RECEIVER, - idempotency_key="handoff-a-to-bob", + resource=skill, + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key="share-skill-a-with-bob", ), context=AUDIT, ) - allowed = await service.require( - BOB, - AccessAction.HANDOFF_ACKNOWLEDGE, - exact, - context=AUDIT, - ) - assert allowed.allowed is True - with pytest.raises(AccessDeniedError): - await service.require( - BOB, - AccessAction.ARTIFACT_READ, - ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-b", revision=1), - context=AUDIT, - ) + assert (await service.require(BOB, AccessAction.ARTIFACT_READ, skill, context=AUDIT)).allowed with pytest.raises(AccessDeniedError): - await service.require(BOB, AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), context=AUDIT) - - visible = await service.list_resources( - BOB, - action=AccessAction.ARTIFACT_READ, - resource_type=AccessResourceType.ARTIFACT, - family="handoff", - context=AUDIT, - ) - assert visible.items == (exact,) - assert created.policy_revision == "1" - assert len(await repository.list_audit()) == 5 + await service.require(BOB, AccessAction.ARTIFACT_WRITE, skill, context=AUDIT) + assert (await service.require(ALICE, AccessAction.ARTIFACT_WRITE, skill, context=AUDIT)).allowed + assert (await service.require(ALICE, AccessAction.ARTIFACT_SHARE, skill, context=AUDIT)).allowed + assert "revision" not in skill.key asyncio.run(scenario()) -def test_scope_role_covers_handoffs_but_expired_bindings_do_not() -> None: +def test_memory_share_targets_one_logical_entry_without_entry_version() -> None: + entry = ResourceRef.artifact( + "scope-a", + family="memory", + artifact_id="memory", + selector=MemoryEntrySelector(entry_id="entry-a"), + ) + assert entry.selector == MemoryEntrySelector(entry_id="entry-a") + assert "entry_version_id" not in entry.key + + +def test_administration_roles_do_not_become_content_writers() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, repository = _service(profile.database) + async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: + handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff") + await service.establish_artifact_owner(handoff, ALICE, idempotency_key="owner-handoff-a", context=AUDIT) await service.create_binding( ADMIN, CreateBinding( - subject=ALICE, + subject=BOB, resource=ResourceRef.scope("scope-a"), - role=AccessRole.SCOPE_VIEWER, - idempotency_key="scope-a-viewer", - expires_at=NOW + timedelta(hours=1), + role=AccessRole.SCOPE_ADMIN, + idempotency_key="scope-admin-bob", ), context=AUDIT, ) - handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=1) - assert (await service.require(ALICE, AccessAction.ARTIFACT_READ, handoff, context=AUDIT)).allowed - assert not (await service.check(ALICE, AccessAction.HANDOFF_ACKNOWLEDGE, handoff, context=AUDIT)).allowed - - expired_provider = BuiltinAuthorizationProvider( - repository, - bootstrap_administrators=(ADMIN,), - clock=lambda: NOW + timedelta(hours=2), - ) - expired = await expired_provider.check( - AccessRequest(subject=ALICE, action=AccessAction.ARTIFACT_READ, resource=handoff, context=AUDIT) - ) - assert expired.allowed is False - asyncio.run(scenario()) - - -def test_binding_creation_is_idempotent_and_revocation_uses_cas() -> None: - async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, repository = _service(profile.database) - request = CreateBinding( - subject=BOB, - resource=ResourceRef.scope("scope-a"), - role=AccessRole.SCOPE_VIEWER, - idempotency_key="stable-key", - reason="pairing session", - ) - first = await service.create_binding(ADMIN, request, context=AUDIT) - repeated = await service.create_binding(ADMIN, request, context=AUDIT) - assert repeated.binding_id == first.binding_id - assert await repository.policy_revision() == "1" - - with pytest.raises(AccessConflictError, match="idempotency"): - await service.create_binding( - ADMIN, - CreateBinding( - subject=ALICE, - resource=request.resource, - role=request.role, - idempotency_key=request.idempotency_key, - ), - context=AUDIT, - ) - - revoked = await service.revoke_binding( - ADMIN, - first.binding_id, - expected_version=1, - context=AUDIT, - ) - assert revoked.version == 2 - assert revoked.policy_revision == "2" - with pytest.raises(AccessConflictError, match="version"): - await service.revoke_binding( - ADMIN, - first.binding_id, - expected_version=1, - context=AUDIT, - ) + assert (await service.require(BOB, AccessAction.ARTIFACT_SHARE, handoff, context=AUDIT)).allowed + for action in (AccessAction.ARTIFACT_READ, AccessAction.ARTIFACT_WRITE): + with pytest.raises(AccessDeniedError): + await service.require(BOB, action, handoff, context=AUDIT) asyncio.run(scenario()) -def test_idempotency_key_is_scoped_to_grantor_and_resource() -> None: +def test_group_binding_is_inherited_from_trusted_authentication_context() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, repository = _service(profile.database) - first = await service.create_binding( - ADMIN, - CreateBinding( - subject=BOB, - resource=ResourceRef.scope("scope-a"), - role=AccessRole.SCOPE_VIEWER, - idempotency_key="share-viewer", + repository = RelationalAccessRepository(profile.database) + await _seed_server_admin(repository) + provider = BuiltinAuthorizationProvider(repository) + service = AccessControlService( + provider, + relationships=repository, + audit=repository, + provider_capabilities=AccessProviderCapabilities( + safe_resource_filtering=True, + multi_requirement_check=True, + relationship_management=True, + group_subjects=True, ), - context=AUDIT, ) - second = await service.create_binding( + handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff") + await service.establish_artifact_owner(handoff, ALICE, idempotency_key="owner-handoff-group", context=AUDIT) + binding = await service.create_binding( ADMIN, CreateBinding( - subject=BOB, - resource=ResourceRef.scope("scope-b"), - role=AccessRole.SCOPE_VIEWER, - idempotency_key="share-viewer", - ), - context=AUDIT, - ) - assert first.binding_id != second.binding_id - assert await repository.policy_revision() == "2" - - asyncio.run(scenario()) - - -def test_persisted_server_admin_covers_scope_administration() -> None: - async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, _ = _service(profile.database) - await service.create_binding( - ADMIN, - CreateBinding( - subject=ALICE, - resource=ResourceRef.server(), - role=AccessRole.SERVER_ADMIN, - idempotency_key="alice-server-admin", + subject=TEAM, + resource=handoff, + role=AccessRole.HANDOFF_VIEWER, + idempotency_key="share-handoff-with-team", ), context=AUDIT, ) - delegated = await service.create_binding( - ALICE, - CreateBinding( - subject=BOB, - resource=ResourceRef.scope("scope-a"), - role=AccessRole.SCOPE_VIEWER, - idempotency_key="bob-scope-viewer", - ), - context=AUDIT, + grouped = AccessAuditContext( + transport="http", + operation="test", + request_id="req-group", + subject_groups=(TEAM,), ) + decision = await service.require(BOB, AccessAction.ARTIFACT_READ, handoff, context=grouped) + assert decision.matched_subject == TEAM + assert decision.matched_binding_id == binding.binding_id - assert delegated.granted_by == ALICE - assert ( - await service.require(BOB, AccessAction.SCOPE_READ, ResourceRef.scope("scope-a"), context=AUDIT) - ).allowed + with pytest.raises(AccessInvalidRequestError, match="subject"): + await service.create_binding( + ADMIN, + CreateBinding( + subject=TEAM, + resource=handoff, + role=AccessRole.HANDOFF_RECEIVER, + idempotency_key="invalid-group-receiver", + ), + context=AUDIT, + ) asyncio.run(scenario()) -def test_artifact_family_profiles_enforce_selector_role_and_delegation_boundaries() -> None: +def test_only_one_handoff_receiver_and_reassignment_is_atomic() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, _ = _service(profile.database) - await service.create_binding( - ADMIN, - CreateBinding( - subject=ALICE, - resource=ResourceRef.scope("scope-a"), - role=AccessRole.SCOPE_DELEGATOR, - idempotency_key="alice-scope-delegator", - ), - context=AUDIT, + async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: + handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff") + await service.establish_artifact_owner( + handoff, ALICE, idempotency_key="owner-handoff-receiver", context=AUDIT ) - handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a", revision=1) - delegated = await service.create_binding( - ALICE, + first = await service.create_binding( + ADMIN, CreateBinding( subject=BOB, resource=handoff, - role=AccessRole.HANDOFF_VIEWER, - idempotency_key="bob-handoff-viewer", + role=AccessRole.HANDOFF_RECEIVER, + idempotency_key="receiver-bob", ), context=AUDIT, ) - assert delegated.granted_by == ALICE - - skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a", revision=1) - with pytest.raises(AccessDeniedError): - await service.create_binding( - ALICE, - CreateBinding( - subject=BOB, - resource=skill, - role=AccessRole.SKILL_PUBLISHER, - idempotency_key="bob-skill-publisher", - ), - context=AUDIT, - ) - with pytest.raises(AccessInvalidRequestError, match="role"): + with pytest.raises(AccessConflictError, match="receiver"): await service.create_binding( ADMIN, CreateBinding( - subject=BOB, + subject=ALICE, resource=handoff, - role=AccessRole.ARTIFACT_VIEWER, - idempotency_key="invalid-handoff-role", + role=AccessRole.HANDOFF_RECEIVER, + idempotency_key="receiver-alice-conflict", ), context=AUDIT, ) - memory_without_selector = ResourceRef.artifact( - "scope-a", family="memory", artifact_id="memory-a", revision=1 + changed = await service.reassign_handoff_receiver( + ADMIN, + ReassignHandoffReceiver( + binding_id=first.binding_id, + expected_version=1, + subject=ALICE, + idempotency_key="reassign-to-alice", + ), + context=AUDIT, ) - with pytest.raises(AccessInvalidRequestError, match="Memory Entry Version"): - await service.check(BOB, AccessAction.ARTIFACT_READ, memory_without_selector, context=AUDIT) - prompt = ResourceRef.artifact("scope-a", family="prompt", artifact_id="prompt-a", revision=1) - with pytest.raises(AccessInvalidRequestError, match="disabled"): - await service.create_binding( - ADMIN, - CreateBinding( - subject=BOB, - resource=prompt, - role=AccessRole.PROMPT_USER, - idempotency_key="disabled-prompt", - ), - context=AUDIT, - ) - - asyncio.run(scenario()) - + assert changed.revoked_binding.state is AccessBindingState.REVOKED + assert changed.created_binding.subject == ALICE + with pytest.raises(AccessDeniedError): + await service.require(BOB, AccessAction.HANDOFF_ACKNOWLEDGE, handoff, context=AUDIT) + assert (await service.require(ALICE, AccessAction.HANDOFF_ACKNOWLEDGE, handoff, context=AUDIT)).allowed -def test_exact_memory_and_skill_grants_do_not_follow_versions_or_collapse_actions() -> None: - async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, _ = _service(profile.database) - memory = ResourceRef.artifact( - "scope-a", - family="memory", - artifact_id="memory-a", - revision=4, - selector=MemoryEntrySelector(entry_id="entry-a", entry_version_id="entry-version-2"), - ) - skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a", revision=7) - await service.create_binding( + await service.revoke_binding( ADMIN, - CreateBinding( - subject=BOB, - resource=memory, - role=AccessRole.ARTIFACT_VIEWER, - idempotency_key="bob-memory-entry-version", - ), + changed.created_binding.binding_id, + expected_version=1, + idempotency_key="revoke-alice-receiver", context=AUDIT, ) - await service.create_binding( + replacement = await service.create_binding( ADMIN, CreateBinding( subject=BOB, - resource=skill, - role=AccessRole.SKILL_PUBLISHER, - idempotency_key="bob-skill-publisher", + resource=handoff, + role=AccessRole.HANDOFF_RECEIVER, + idempotency_key="receiver-bob-again", ), context=AUDIT, ) - - assert (await service.require(BOB, AccessAction.ARTIFACT_READ, memory, context=AUDIT)).allowed - future_memory = ResourceRef.artifact( - "scope-a", - family="memory", - artifact_id="memory-a", - revision=5, - selector=MemoryEntrySelector(entry_id="entry-a", entry_version_id="entry-version-3"), - ) - with pytest.raises(AccessDeniedError): - await service.require(BOB, AccessAction.ARTIFACT_READ, future_memory, context=AUDIT) - decisions = await service.require_all( - BOB, - ((AccessAction.ARTIFACT_READ, skill), (AccessAction.SKILL_PUBLISH, skill)), - context=AUDIT, - ) - assert all(decision.allowed for decision in decisions) - with pytest.raises(AccessInvalidRequestError, match="action"): - await service.check(BOB, AccessAction.SKILL_PUBLISH, memory, context=AUDIT) + assert replacement.subject == BOB asyncio.run(scenario()) -def test_safe_listing_is_exact_paginated_and_fails_closed_without_provider_support() -> None: +def test_resource_cursor_is_bound_to_policy_revision() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, repository = _service(profile.database) + async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: resources = tuple( - ResourceRef.artifact( - "scope-a", - family="handoff", - artifact_id=f"handoff-{index}", - revision=1, - ) - for index in range(3) + ResourceRef.artifact("scope-a", family="skill", artifact_id=f"skill-{index}") for index in range(3) ) for index, resource in enumerate(resources): + await service.establish_artifact_owner(resource, ALICE, idempotency_key=f"owner-{index}", context=AUDIT) await service.create_binding( ADMIN, CreateBinding( subject=BOB, resource=resource, - role=AccessRole.HANDOFF_VIEWER, - idempotency_key=f"handoff-{index}-viewer", + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key=f"share-{index}", ), context=AUDIT, ) + first = await service.list_resources( BOB, action=AccessAction.ARTIFACT_READ, resource_type=AccessResourceType.ARTIFACT, - family="handoff", + family="skill", limit=2, context=AUDIT, ) assert len(first.items) == 2 assert first.total == 3 assert first.next_cursor is not None - second = await service.list_resources( - BOB, - action=AccessAction.ARTIFACT_READ, - resource_type=AccessResourceType.ARTIFACT, - family="handoff", - cursor=first.next_cursor, - limit=2, + + await service.create_binding( + ADMIN, + CreateBinding( + subject=ALICE, + resource=ResourceRef.server(), + role=AccessRole.SERVER_OBSERVER, + idempotency_key="advance-policy-revision", + ), context=AUDIT, ) - assert len(second.items) == 1 - assert second.total == 3 - with pytest.raises(AccessInvalidRequestError, match="cursor"): + with pytest.raises(AccessConflictError, match="older policy revision"): await service.list_resources( BOB, action=AccessAction.ARTIFACT_READ, resource_type=AccessResourceType.ARTIFACT, - family="handoff", - cursor="not-base64!", + family="skill", + cursor=first.next_cursor, + limit=2, context=AUDIT, ) - unavailable = AccessControlService( - service.provider, + asyncio.run(scenario()) + + +def test_resource_cursor_is_bound_to_trusted_group_membership() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_server_admin(repository) + service = AccessControlService( + BuiltinAuthorizationProvider(repository), relationships=repository, audit=repository, provider_capabilities=AccessProviderCapabilities( - safe_resource_filtering=False, + safe_resource_filtering=True, multi_requirement_check=True, relationship_management=True, + group_subjects=True, ), ) - with pytest.raises(AccessUnavailableError, match="filtering"): - await unavailable.list_resources( - BOB, - action=AccessAction.ARTIFACT_READ, - resource_type=AccessResourceType.ARTIFACT, - family="handoff", + for index in range(2): + resource = ResourceRef.artifact("scope-a", family="skill", artifact_id=f"team-skill-{index}") + await service.establish_artifact_owner( + resource, + ALICE, + idempotency_key=f"team-owner-{index}", context=AUDIT, ) - no_multi_check = AccessControlService( - service.provider, - relationships=repository, - audit=repository, - provider_capabilities=AccessProviderCapabilities( - safe_resource_filtering=True, - multi_requirement_check=False, - relationship_management=True, - ), + await service.create_binding( + ADMIN, + CreateBinding( + subject=TEAM, + resource=resource, + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key=f"team-share-{index}", + ), + context=AUDIT, + ) + + grouped = AccessAuditContext(transport="http", operation="test", subject_groups=(TEAM,)) + first = await service.list_resources( + BOB, + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="skill", + limit=1, + context=grouped, ) - with pytest.raises(AccessUnavailableError, match="multi-requirement"): - await no_multi_check.require_all( + assert first.next_cursor is not None + + with pytest.raises(AccessConflictError, match="older policy revision"): + await service.list_resources( BOB, - ( - (AccessAction.ARTIFACT_READ, resources[0]), - (AccessAction.HANDOFF_EVIDENCE_READ, resources[0]), - ), + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family="skill", + cursor=first.next_cursor, + limit=1, context=AUDIT, ) asyncio.run(scenario()) -def test_access_self_is_not_exposed_as_a_public_audit_action() -> None: +def test_missing_owner_is_fail_closed_and_owner_is_immutable() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: - service, repository = _service(profile.database) - decision = await service.check(BOB, AccessAction.ACCESS_SELF, ResourceRef.server(), context=AUDIT) - assert decision.allowed is True - assert await repository.list_audit() == () + async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: + skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a") + with pytest.raises(AccessUnavailableError, match="owner"): + await service.require(ADMIN, AccessAction.ARTIFACT_READ, skill, context=AUDIT) + await service.establish_artifact_owner(skill, ALICE, idempotency_key="owner-a", context=AUDIT) + repeated = await service.establish_artifact_owner(skill, ALICE, idempotency_key="owner-a", context=AUDIT) + assert repeated.owner == ALICE + with pytest.raises(AccessConflictError, match="different owner"): + await service.establish_artifact_owner(skill, BOB, idempotency_key="owner-b", context=AUDIT) asyncio.run(scenario()) -def test_access_service_enforces_the_policy_revision_contract_boundary() -> None: - provider = SimpleNamespace( - check=AsyncMock(return_value=AccessDecision(True, "provider-allow", "r" * 64)), - ) - audit = SimpleNamespace(append_audit=AsyncMock()) - service = AccessControlService( - cast(AuthorizationProvider, provider), - relationships=None, - audit=cast(AccessAuditStore, audit), - ) - - accepted = asyncio.run( - service.check( - BOB, - AccessAction.SERVER_OBSERVE, - ResourceRef.server(), - context=AUDIT, - ) - ) - assert accepted.policy_revision == "r" * 64 - - provider.check.return_value = AccessDecision(True, "provider-allow", "r" * 65) - with pytest.raises(AccessUnavailableError): - asyncio.run( - service.check( - BOB, - AccessAction.SERVER_OBSERVE, - ResourceRef.server(), - context=AUDIT, +def test_candidate_owner_is_locked_to_proposer_and_target() -> None: + async def scenario() -> None: + async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: + first = await service.attest_candidate_owner( + scope_id="scope-a", + candidate_id="candidate-a", + family="experience", + proposed_owner=ALICE, + target=None, + idempotency_key="candidate-owner-a", ) - ) + repeated = await service.attest_candidate_owner( + scope_id="scope-a", + candidate_id="candidate-a", + family="experience", + proposed_owner=ALICE, + target=None, + idempotency_key="candidate-owner-a", + ) + assert repeated == first + with pytest.raises(AccessConflictError, match="different proposed owner"): + await service.attest_candidate_owner( + scope_id="scope-a", + candidate_id="candidate-a", + family="experience", + proposed_owner=BOB, + target=None, + idempotency_key="candidate-owner-b", + ) + asyncio.run(scenario()) -def test_access_schema_and_mysql_migration_use_the_policy_revision_contract_limit() -> None: - bindings = str(CreateTable(ACCESS_BINDINGS_TABLE).compile(dialect=mysql.dialect())) - audit = str(CreateTable(ACCESS_AUDIT_EVENTS_TABLE).compile(dialect=mysql.dialect())) - assert "policy_revision VARCHAR(64)" in bindings - assert "policy_revision VARCHAR(64)" in audit - connection = SimpleNamespace( - dialect=SimpleNamespace(name="mysql"), - scalar=AsyncMock(side_effect=(32, 32)), - exec_driver_sql=AsyncMock(), - ) - asyncio.run(ensure_access_policy_revision_columns(cast(AsyncConnection, connection))) - - migrations = [call.args[0] for call in connection.exec_driver_sql.await_args_list] - assert migrations == [ - "ALTER TABLE pc_access_bindings MODIFY COLUMN policy_revision " - "VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL", - "ALTER TABLE pc_access_audit_events MODIFY COLUMN policy_revision " - "VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL", - ] - - connection.scalar.side_effect = (64, 128) - connection.exec_driver_sql.reset_mock() - asyncio.run(ensure_access_policy_revision_columns(cast(AsyncConnection, connection))) - connection.exec_driver_sql.assert_not_awaited() - - -def _service(database) -> tuple[AccessControlService, RelationalAccessRepository]: - repository = RelationalAccessRepository(database) - provider = BuiltinAuthorizationProvider( - repository, - bootstrap_administrators=(ADMIN,), - clock=lambda: NOW, - ) - return ( - AccessControlService(provider, relationships=repository, audit=repository, clock=lambda: NOW), - repository, +async def _seed_server_admin(repository: RelationalAccessRepository) -> None: + await repository.create_binding( + AccessBinding( + binding_id="seed-admin", + subject=ADMIN, + resource=ResourceRef.server(), + role=AccessRole.SERVER_ADMIN, + granted_by=ADMIN, + reason="test bootstrap", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key="seed-admin", + ) ) diff --git a/tests/test_access_http.py b/tests/test_access_http.py index ae931d6d4..cf9321d1f 100644 --- a/tests/test_access_http.py +++ b/tests/test_access_http.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from typing import Self @@ -28,9 +29,18 @@ from powercontext.builtin.artifacts.skill import AgentSkillTarget, Skill, SkillContent from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.runtime import MemoryEntryRecord +from powercontext.builtin.runtime.config import RuntimeConfig from powercontext.server.app import create_app +from powercontext.server.authentication import ( + AuthenticationResult, + ProviderReadiness, + StaticBearerAuthenticationProvider, +) from powercontext.server.authz import ( + AccessAction, AccessAuditContext, + AccessBinding, + AccessBindingState, AccessControlService, AccessRole, BuiltinAuthorizationProvider, @@ -41,19 +51,45 @@ ) from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository from powercontext.server.factory import create_server_app -from powercontext.server.middleware import StaticBearerMiddleware -from powercontext.server.settings import AccessControlConfig, ServerSettings +from powercontext.server.middleware import AuthenticationMiddleware +from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, ServerSettings from powercontext.server.web import mount_web_ui -ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") -BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") -ALICE = PrincipalRef(type="user", issuer="https://identity.example", id="alice") +ADMIN = PrincipalRef(type="service", id="admin", description="deployment administrator") +BOB = PrincipalRef(type="user", id="bob") +ALICE = PrincipalRef(type="user", id="alice") AUDIT = AccessAuditContext(transport="test", operation="seed") +class _FailingAuthenticationProvider: + async def authenticate(self, request) -> AuthenticationResult: + del request + raise RuntimeError("private-provider-detail") + + async def readiness(self) -> ProviderReadiness: + return ProviderReadiness(ready=False) + + def test_enforced_mode_cannot_silently_start_without_authentication_or_provider() -> None: - with pytest.raises(ValueError, match="enforced Access Control"): - create_server_app(settings=ServerSettings(access=AccessControlConfig(mode="enforced"))) + with pytest.raises(ValueError, match="requires authentication and authorization Providers"): + ServerSettings(access=AccessControlConfig(mode="enforced")) + + with pytest.raises(ValueError, match="selected Authentication Provider"): + create_server_app( + settings=ServerSettings( + access=AccessControlConfig(mode="enforced"), + auth=AuthenticationConfig(provider="oidc"), + authorization_provider="builtin", + ) + ) + + with pytest.raises(ValueError, match="BACKGROUND_PRINCIPAL_ID"): + ServerSettings( + access=AccessControlConfig(mode="enforced"), + auth=AuthenticationConfig(provider="oidc"), + authorization_provider="builtin", + runtime=RuntimeConfig(schedule_seconds=60), + ) def test_low_level_enforced_app_fails_closed_without_an_authorization_provider() -> None: @@ -70,6 +106,96 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_authentication_provider_failures_use_a_stable_secret_safe_response() -> None: + async def scenario() -> None: + provider = _FailingAuthenticationProvider() + app = create_app( + authentication_provider=provider, + middleware=(Middleware(AuthenticationMiddleware, provider=provider),), + ) + async with _client(app) as client: + response = await client.get("/v1/access/me", headers=_auth("never-echo-this")) + + assert response.status_code == 503 + assert response.json()["error"]["code"] == "authentication_unavailable" + assert "private-provider-detail" not in response.text + assert "never-echo-this" not in response.text + + asyncio.run(scenario()) + + +def test_access_audit_time_range_filters_results_and_binds_the_cursor() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) + service = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + ) + await service.check( + ADMIN, + AccessAction.SCOPE_READ, + ResourceRef.scope("scope-a"), + context=AUDIT, + ) + app = _app(service, principal=ADMIN, token="admin-token") # noqa: S106 - test credential. + now = datetime.now(UTC) + current_range = { + "start": (now - timedelta(days=1)).isoformat(), + "end": (now + timedelta(days=1)).isoformat(), + } + payload = { + "resource": {"type": "scope", "scope_id": "scope-a"}, + "time_range": current_range, + "limit": 1, + } + async with _client(app) as client: + first = await client.post( + "/v1/access/audit/list", + headers=_auth("admin-token"), + json=payload, + ) + assert first.status_code == 200, first.json() + assert len(first.json()["items"]) == 1 + assert first.json()["next_cursor"] is not None + + outside = await client.post( + "/v1/access/audit/list", + headers=_auth("admin-token"), + json=payload + | { + "time_range": { + "start": (now - timedelta(days=3)).isoformat(), + "end": (now - timedelta(days=2)).isoformat(), + } + }, + ) + assert outside.status_code == 200 + assert outside.json()["items"] == [] + + changed_filter = await client.post( + "/v1/access/audit/list", + headers=_auth("admin-token"), + json=payload + | { + "time_range": current_range | {"start": (now - timedelta(hours=12)).isoformat()}, + "cursor": first.json()["next_cursor"], + }, + ) + assert changed_filter.status_code == 422 + + invalid = await client.post( + "/v1/access/audit/list", + headers=_auth("admin-token"), + json=payload | {"time_range": {"start": now.isoformat(), "end": now.isoformat()}}, + ) + assert invalid.status_code == 422 + + asyncio.run(scenario()) + + class _HandoffShareability: def for_scope(self, scope_id: str) -> Self: del scope_id @@ -121,11 +247,31 @@ def test_access_api_and_handoff_pep_enforce_exact_receiver_visibility() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) service = AccessControlService( - BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + BuiltinAuthorizationProvider(repository), relationships=repository, audit=repository, ) + handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-a") + await service.establish_artifact_owner( + handoff, + ADMIN, + idempotency_key="owner-handoff-a", + context=AUDIT, + ) + await service.establish_artifact_owner( + ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff-b"), + ADMIN, + idempotency_key="owner-handoff-b", + context=AUDIT, + ) + await service.establish_artifact_owner( + ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff"), + ADMIN, + idempotency_key="owner-default-handoff", + context=AUDIT, + ) admin_app = _app( service, principal=ADMIN, @@ -142,9 +288,9 @@ async def scenario() -> None: principal = await admin.get("/v1/access/me", headers=_auth("admin-token")) assert principal.status_code == 200 assert principal.json()["principal"] == { - "type": "user", - "issuer": "https://identity.example", + "type": "service", "id": "admin", + "description": "deployment administrator", } assert principal.json()["mode"] == "enforced" assert principal.json()["resource_kinds"] == ["server", "scope", "artifact"] @@ -156,15 +302,26 @@ async def scenario() -> None: "experience", "skill", } + roles = await admin.post( + "/v1/access/roles/list", + headers=_auth("admin-token"), + json={"resource_type": "artifact", "family": "skill"}, + ) + assert roles.status_code == 200 + assert {item["role"] for item in roles.json()["items"]} == { + "artifact.owner", + "artifact.viewer", + } + assert all(item["artifact_families"] == ["skill"] for item in roles.json()["items"]) created = await admin.post( "/v1/access/bindings/create", headers=_auth("admin-token"), json={ - "subject": {"type": "user", "issuer": "https://identity.example", "id": "bob"}, + "subject": {"type": "user", "id": "bob", "description": "forged directory name"}, "resource": { "type": "artifact", "scope_id": "scope-a", - "reference": {"family": "handoff", "artifact_id": "handoff-a", "revision": 3}, + "identity": {"family": "handoff", "artifact_id": "handoff-a"}, "selector": None, }, "role": "handoff.receiver", @@ -172,14 +329,15 @@ async def scenario() -> None: }, ) assert created.status_code == 201 - assert created.json()["policy_revision"] == "1" + assert created.json()["policy_revision"] + assert created.json()["subject"] == {"type": "user", "id": "bob", "description": None} bob_app = _app(service, principal=BOB, token="bob-token") # noqa: S106 - test credential. async with _client(bob_app) as bob: exact = { "type": "artifact", "scope_id": "scope-a", - "reference": {"family": "handoff", "artifact_id": "handoff-a", "revision": 3}, + "identity": {"family": "handoff", "artifact_id": "handoff-a"}, "selector": None, } decision = await bob.post( @@ -234,7 +392,7 @@ async def scenario() -> None: "/v1/access/bindings/create", headers=_auth("bob-token"), json={ - "subject": {"type": "user", "issuer": "https://identity.example", "id": "alice"}, + "subject": {"type": "user", "id": "alice"}, "resource": exact, "role": "handoff.viewer", "idempotency_key": "bob-cannot-delegate", @@ -248,12 +406,13 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_exact_memory_entry_version_grant_allows_get_but_not_scope_listing() -> None: +def test_logical_memory_entry_grant_allows_every_entry_version_but_not_scope_listing() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) service = AccessControlService( - BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + BuiltinAuthorizationProvider(repository), relationships=repository, audit=repository, ) @@ -261,8 +420,13 @@ async def scenario() -> None: "scope-a", family="memory", artifact_id="memory-a", - revision=4, - selector=MemoryEntrySelector(entry_id="entry-a", entry_version_id="entry-version-2"), + selector=MemoryEntrySelector(entry_id="entry-a"), + ) + await service.establish_artifact_owner( + exact, + ADMIN, + idempotency_key="owner-memory-entry-a", + context=AUDIT, ) await service.create_binding( ADMIN, @@ -309,9 +473,11 @@ async def scenario() -> None: assert allowed.status_code == 200, allowed.json() assert allowed.json()["text"] == "Only this exact Memory Entry Version is shared." - sibling = request | {"citation": request["citation"] | {"entry_version_id": "entry-version-3"}} - denied = await client.post("/v1/memory/entries/get", headers=_auth("bob-token"), json=sibling) - assert denied.status_code == 403 + future_version = request | {"citation": request["citation"] | {"entry_version_id": "entry-version-3"}} + allowed_future = await client.post( + "/v1/memory/entries/get", headers=_auth("bob-token"), json=future_version + ) + assert allowed_future.status_code == 200 aggregate = await client.post( "/v1/memory/entries/list", @@ -323,23 +489,30 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_skill_publication_requires_read_and_publish_before_target_lookup(tmp_path: Path) -> None: +def test_skill_publication_uses_the_generic_read_grant_before_target_lookup(tmp_path: Path) -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) service = AccessControlService( - BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + BuiltinAuthorizationProvider(repository), relationships=repository, audit=repository, ) - skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a", revision=7) + skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a") + await service.establish_artifact_owner( + skill, + ADMIN, + idempotency_key="owner-skill-a", + context=AUDIT, + ) await service.create_binding( ADMIN, CreateBinding( subject=BOB, resource=skill, - role=AccessRole.SKILL_PUBLISHER, - idempotency_key="bob-skill-publisher", + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key="bob-skill-viewer", ), context=AUDIT, ) @@ -373,10 +546,12 @@ async def scenario() -> None: allow_managed_publish=True, ) application = SimpleNamespace(skill=runtime_skill, external_skills=_ExternalSkillsApplication()) + bob_provider = StaticBearerAuthenticationProvider("bob-token", BOB) bob_app = create_app( application=application, access_control=service, - middleware=(Middleware(StaticBearerMiddleware, token="bob-token", principal=BOB),), # noqa: S106 + authentication_provider=bob_provider, + middleware=(Middleware(AuthenticationMiddleware, provider=bob_provider),), agent_skill_targets=(target,), ) payload = { @@ -426,21 +601,23 @@ async def scenario() -> None: assert str(target_path) not in published.text assert target_path.joinpath("safe-publication", "SKILL.md").is_file() + alice_provider = StaticBearerAuthenticationProvider("alice-token", ALICE) alice_app = create_app( application=application, access_control=service, - middleware=(Middleware(StaticBearerMiddleware, token="alice-token", principal=ALICE),), # noqa: S106 + authentication_provider=alice_provider, + middleware=(Middleware(AuthenticationMiddleware, provider=alice_provider),), agent_skill_targets=(target,), ) calls_before = runtime_skill.get_calls async with _client(alice_app) as alice: - denied = await alice.post( + visible = await alice.post( "/v1/skills/publication-targets/list", headers=_auth("alice-token"), json=payload, ) - assert denied.status_code == 403 - assert runtime_skill.get_calls == calls_before + assert visible.status_code == 200 + assert runtime_skill.get_calls == calls_before + 1 asyncio.run(scenario()) @@ -449,8 +626,9 @@ def test_dashboard_scope_discovery_uses_the_same_principal_and_filters_before_re async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) service = AccessControlService( - BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + BuiltinAuthorizationProvider(repository), relationships=repository, audit=repository, ) @@ -481,10 +659,12 @@ async def scenario() -> None: def _app(service: AccessControlService, *, principal: PrincipalRef, token: str, application=None): + provider = StaticBearerAuthenticationProvider(token, principal) return create_app( application=application, access_control=service, - middleware=(Middleware(StaticBearerMiddleware, token=token, principal=principal),), + authentication_provider=provider, + middleware=(Middleware(AuthenticationMiddleware, provider=provider),), ) @@ -494,3 +674,22 @@ def _client(app) -> httpx.AsyncClient: def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} + + +async def _seed_admin(repository: RelationalAccessRepository) -> None: + await repository.create_binding( + AccessBinding( + binding_id="seed-admin", + subject=ADMIN, + resource=ResourceRef.server(), + role=AccessRole.SERVER_ADMIN, + granted_by=ADMIN, + reason="test bootstrap", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key="seed-admin", + ) + ) diff --git a/tests/test_access_mcp.py b/tests/test_access_mcp.py index 89eec6342..eea988486 100644 --- a/tests/test_access_mcp.py +++ b/tests/test_access_mcp.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +from datetime import UTC, datetime from types import SimpleNamespace from typing import Self @@ -26,8 +27,11 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.runtime import MemoryEntriesPage from powercontext.server.app import create_app +from powercontext.server.authentication import StaticBearerAuthenticationProvider from powercontext.server.authz import ( AccessAuditContext, + AccessBinding, + AccessBindingState, AccessControlService, AccessRole, BuiltinAuthorizationProvider, @@ -37,10 +41,10 @@ ) from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository from powercontext.server.mcp import mount_mcp -from powercontext.server.middleware import StaticBearerMiddleware +from powercontext.server.middleware import AuthenticationMiddleware -ADMIN = PrincipalRef(type="user", issuer="https://identity.example", id="admin") -BOB = PrincipalRef(type="user", issuer="https://identity.example", id="bob") +ADMIN = PrincipalRef(type="service", id="admin") +BOB = PrincipalRef(type="user", id="bob") class _MemoryApplication: @@ -57,8 +61,24 @@ def test_mcp_internal_bridge_preserves_principal_and_audits_mcp_transport() -> N async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: repository = RelationalAccessRepository(profile.database) + await repository.create_binding( + AccessBinding( + binding_id="seed-admin", + subject=ADMIN, + resource=ResourceRef.server(), + role=AccessRole.SERVER_ADMIN, + granted_by=ADMIN, + reason="test bootstrap", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key="seed-admin", + ) + ) service = AccessControlService( - BuiltinAuthorizationProvider(repository, bootstrap_administrators=(ADMIN,)), + BuiltinAuthorizationProvider(repository), relationships=repository, audit=repository, ) @@ -72,14 +92,15 @@ async def scenario() -> None: ), context=AccessAuditContext(transport="test", operation="seed"), ) + authentication = StaticBearerAuthenticationProvider("bob-token", BOB) app = create_app( application=SimpleNamespace(memory=_MemoryApplication()), access_control=service, + authentication_provider=authentication, middleware=( Middleware( - StaticBearerMiddleware, - token="bob-token", # noqa: S106 - test credential. - principal=BOB, + AuthenticationMiddleware, + provider=authentication, ), ), ) @@ -109,7 +130,7 @@ def create_http_client( result = await client.call_tool("list_memory_entries", {"scope_id": "scope-a"}) assert result.is_error is False - audit = await repository.list_audit() + audit = await repository.list_audit(resource=ResourceRef.server()) decision = next(event for event in audit if event.operation == "list_memory_entries") assert decision.transport == "mcp" assert decision.principal == BOB diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index e6652f749..b257da7f1 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -234,18 +234,18 @@ def test_handoff_access_metadata_preserves_exact_revision_authorization() -> Non assert ACKNOWLEDGE_HANDOFF.access.resolver == "acknowledge_handoff_access" -def test_access_contract_uses_stable_resource_kinds_family_profiles_and_skill_publication() -> None: +def test_access_contract_uses_logical_resources_and_generic_skill_read_access() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) schemas = contract["components"]["schemas"] assert schemas["AccessResourceType"]["enum"] == ["server", "scope", "artifact"] assert "access.self" not in schemas["AccessAction"]["enum"] artifact = schemas["ArtifactAccessResource"] - assert artifact["required"] == ["type", "scope_id", "reference", "selector"] - assert set(artifact["properties"]) == {"type", "scope_id", "reference", "selector"} + assert artifact["required"] == ["type", "scope_id", "identity"] + assert set(artifact["properties"]) == {"type", "scope_id", "identity", "selector"} selector = schemas["MemoryEntryAccessSelector"] - assert selector["required"] == ["type", "entry_id", "entry_version_id"] - assert schemas["AccessDecision"]["properties"]["policy_revision"]["maxLength"] == 64 + assert selector["required"] == ["type", "entry_id"] + assert set(schemas["AccessDecision"]["properties"]) == {"allowed", "reason_code"} assert schemas["AccessBinding"]["properties"]["policy_revision"]["maxLength"] == 64 assert schemas["AccessAuditEvent"]["properties"]["policy_revision"]["maxLength"] == 64 @@ -258,9 +258,9 @@ def test_access_contract_uses_stable_resource_kinds_family_profiles_and_skill_pu assert LIST_SKILL_PUBLICATION_TARGETS.path == "/v1/skills/publication-targets/list" assert PUBLISH_MANAGED_SKILL.path == "/v1/skills/publish" assert LIST_SKILL_PUBLICATION_TARGETS.access is not None - assert LIST_SKILL_PUBLICATION_TARGETS.access.resolver == "publish_managed_skill_access" + assert LIST_SKILL_PUBLICATION_TARGETS.access.resolver == "exact_skill_access" assert PUBLISH_MANAGED_SKILL.access is not None - assert PUBLISH_MANAGED_SKILL.access.resolver == "publish_managed_skill_access" + assert PUBLISH_MANAGED_SKILL.access.resolver == "exact_skill_access" def test_prepared_context_is_a_generic_typed_operation_outside_the_mcp_memory_tools() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index aaa65eabb..90ae19486 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -272,7 +272,7 @@ def test_server_command_clears_stale_server_values_missing_from_env_file( ) -> None: environment = tmp_path / ".env" environment.write_text("POWERCONTEXT_SERVER_HTTP_HOST=127.0.0.1\n", encoding="utf-8") - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_ENABLED", "true") + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_PROVIDER", "static-bearer") monkeypatch.delenv("POWERCONTEXT_SERVER_AUTH_TOKEN", raising=False) run_server = Mock() tracing = Mock() @@ -287,7 +287,7 @@ def test_server_command_clears_stale_server_values_missing_from_env_file( assert result.exit_code == 0 run_server.assert_called_once() - assert os.environ["POWERCONTEXT_SERVER_AUTH_ENABLED"] == "true" + assert os.environ["POWERCONTEXT_SERVER_AUTH_PROVIDER"] == "static-bearer" def test_server_command_reports_a_missing_env_file_without_starting( @@ -354,7 +354,9 @@ def test_server_command_reports_a_friendly_error_when_auth_lacks_a_token( monkeypatch.setattr("powercontext.server.cli._run_server", run_server) monkeypatch.setattr("powercontext.server.cli.configure_server_logging", lambda _config: None) monkeypatch.setattr("powercontext.server.cli.configure_server_tracing", lambda _config: tracing) - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_ENABLED", "true") + monkeypatch.setenv("POWERCONTEXT_SERVER_ACCESS_MODE", "enforced") + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_PROVIDER", "static-bearer") + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER", "builtin") monkeypatch.delenv("POWERCONTEXT_SERVER_AUTH_TOKEN", raising=False) result = CliRunner().invoke(create_cli([server_app]), ["server", "run"]) @@ -363,7 +365,7 @@ def test_server_command_reports_a_friendly_error_when_auth_lacks_a_token( run_server.assert_not_called() # The operator gets the concrete token / disable levers, not pydantic's internal dump. assert "POWERCONTEXT_SERVER_AUTH_TOKEN" in result.output - assert "POWERCONTEXT_SERVER_AUTH_ENABLED=false" in result.output + assert "POWERCONTEXT_SERVER_ACCESS_MODE=disabled" in result.output assert "pydantic" not in result.output @@ -393,7 +395,7 @@ def test_server_command_does_not_load_client_settings(monkeypatch: pytest.Monkey run_server = Mock() tracing = Mock() monkeypatch.setenv("POWERCONTEXT_CLIENT_SERVER_URL", "not-a-url") - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_ENABLED", "false") + monkeypatch.setenv("POWERCONTEXT_SERVER_ACCESS_MODE", "disabled") monkeypatch.setenv("POWERCONTEXT_SERVER_DASHBOARD_ENABLED", "true") monkeypatch.setattr("powercontext.server.cli._run_server", run_server) monkeypatch.setattr("powercontext.server.cli.configure_server_logging", lambda _config: None) diff --git a/tests/test_client.py b/tests/test_client.py index 7bbbdd48e..eb92af891 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -31,10 +31,10 @@ from powercontext.client.settings import ClientSettings from powercontext.http import ( AccessAction, + AccessArtifactIdentity, AccessCheckRequest, AccessResource, ArtifactAccessResource, - ArtifactReference, CaptureContentSourceRequest, GetHandoffReportRequest, ) @@ -48,7 +48,7 @@ def respond(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response( 200, - json={"allowed": True, "reason_code": "role-binding", "policy_revision": "7"}, + json={"allowed": True, "reason_code": "role-binding"}, ) async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: @@ -60,7 +60,7 @@ def respond(request: httpx.Request) -> httpx.Response: root=ArtifactAccessResource( type="artifact", scope_id="scope-a", - reference=ArtifactReference(family="handoff", artifact_id="handoff-a", revision=3), + identity=AccessArtifactIdentity(family="handoff", artifact_id="handoff-a"), selector=None, ) ), @@ -69,7 +69,7 @@ def respond(request: httpx.Request) -> httpx.Response: assert decision.allowed is True assert requests[0].url.path == "/v1/access/check" - assert json.loads(requests[0].content)["resource"]["reference"]["artifact_id"] == "handoff-a" + assert json.loads(requests[0].content)["resource"]["identity"]["artifact_id"] == "handoff-a" asyncio.run(scenario()) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index e3b07cd8c..e5031daed 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -25,7 +25,8 @@ from powercontext.builtin.runtime.config import ExternalSkillsConfig, HandoffReportConfig from powercontext.server.factory import create_server_app from powercontext.server.settings import ( - BearerAuthConfig, + AccessControlConfig, + AuthenticationConfig, DashboardConfig, DashboardScopeConfig, McpConfig, @@ -37,7 +38,9 @@ def test_dashboard_is_enabled_by_default_without_authentication_or_scopes(tmp_path, monkeypatch) -> None: for name in ( - "POWERCONTEXT_SERVER_AUTH_ENABLED", + "POWERCONTEXT_SERVER_ACCESS_MODE", + "POWERCONTEXT_SERVER_AUTH_PROVIDER", + "POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER", "POWERCONTEXT_SERVER_AUTH_TOKEN", "POWERCONTEXT_SERVER_DASHBOARD_ENABLED", "POWERCONTEXT_SERVER_DASHBOARD_SCOPES", @@ -110,10 +113,9 @@ def fail_to_mount(*_args, **_kwargs) -> None: def test_dashboard_is_the_authenticated_server_ui_entry(tmp_path) -> None: app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig( - enabled=True, - token=SecretStr("dashboard-secret"), - ), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[ @@ -150,7 +152,9 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target codex_skill_root = tmp_path / "repository" / ".agents" / "skills" claude_skill_root = tmp_path / "repository" / ".claude" / "skills" settings = ServerSettings( - auth=BearerAuthConfig(enabled=True, token=SecretStr("dashboard-secret")), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], @@ -367,7 +371,9 @@ def for_scope(self, scope_id: str): def test_publish_reports_success_when_post_publish_scan_fails(tmp_path, caplog) -> None: codex_skill_root = tmp_path / "repository" / ".agents" / "skills" settings = ServerSettings( - auth=BearerAuthConfig(enabled=True, token=SecretStr("dashboard-secret")), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], @@ -472,7 +478,9 @@ def for_scope(self, scope_id: str): def test_publish_reports_stale_discovery_when_registry_database_is_unavailable(tmp_path, caplog) -> None: codex_skill_root = tmp_path / "repository" / ".agents" / "skills" settings = ServerSettings( - auth=BearerAuthConfig(enabled=True, token=SecretStr("dashboard-secret")), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], @@ -579,7 +587,9 @@ def test_handoff_report_page_is_available_without_the_statistics_dashboard(tmp_p def _handoff_report_settings(database_path: Path, *, enabled: bool) -> ServerSettings: return ServerSettings( - auth=BearerAuthConfig(enabled=True, token=SecretStr("dashboard-secret")), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", dashboard=DashboardConfig(enabled=False), database=SQLiteConfig(url=f"sqlite+aiosqlite:///{database_path}"), mcp=McpConfig(enabled=False), diff --git a/tests/test_env_file.py b/tests/test_env_file.py index 7554a9678..5aab2ec2e 100644 --- a/tests/test_env_file.py +++ b/tests/test_env_file.py @@ -108,18 +108,18 @@ def test_invalid_utf8_is_reported_as_an_environment_file_error(tmp_path) -> None def test_environment_context_can_clear_stale_values(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_ENABLED", "true") + monkeypatch.setenv("POWERCONTEXT_SERVER_ACCESS_MODE", "enforced") monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_HOST", "192.0.2.10") with environment_context( {"POWERCONTEXT_SERVER_HTTP_HOST": "127.0.0.1"}, override=True, - clear={"POWERCONTEXT_SERVER_AUTH_ENABLED", "POWERCONTEXT_SERVER_HTTP_HOST"}, + clear={"POWERCONTEXT_SERVER_ACCESS_MODE", "POWERCONTEXT_SERVER_HTTP_HOST"}, ): - assert "POWERCONTEXT_SERVER_AUTH_ENABLED" not in os.environ + assert "POWERCONTEXT_SERVER_ACCESS_MODE" not in os.environ assert os.environ["POWERCONTEXT_SERVER_HTTP_HOST"] == "127.0.0.1" - assert os.environ["POWERCONTEXT_SERVER_AUTH_ENABLED"] == "true" + assert os.environ["POWERCONTEXT_SERVER_ACCESS_MODE"] == "enforced" assert os.environ["POWERCONTEXT_SERVER_HTTP_HOST"] == "192.0.2.10" diff --git a/tests/test_server.py b/tests/test_server.py index 0869ab6d2..28b38f2eb 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -49,7 +49,7 @@ from powercontext.server.factory import create_server_app from powercontext.server.settings import ( AccessControlConfig, - BearerAuthConfig, + AuthenticationConfig, DashboardConfig, DashboardScopeConfig, McpConfig, @@ -62,15 +62,16 @@ def _access_readiness_checks( *, - mode: str = "legacy-static-admin", + mode: str = "disabled", provider: str = "disabled", + authentication: str = "disabled", ) -> dict[str, str]: return { "access_mode": mode, + "authentication_provider": authentication, "access_provider": provider, "access_resource_kinds": "server,scope,artifact", "access_artifact_families": _ACCESS_FAMILIES, - "access_skill_publication": "disabled", } @@ -274,12 +275,16 @@ def test_server_scheduler_uses_the_powercontext_data_directory(tmp_path, monkeyp def test_settings_load_bearer_authentication_without_exposing_token(monkeypatch) -> None: - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_ENABLED", "true") + monkeypatch.setenv("POWERCONTEXT_SERVER_ACCESS_MODE", "enforced") + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_PROVIDER", "static-bearer") + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER", "builtin") monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_TOKEN", "server-secret") settings = ServerSettings() - assert settings.auth.enabled is True + assert settings.access.mode == "enforced" + assert settings.auth.provider == "static-bearer" + assert settings.authorization_provider == "builtin" assert settings.auth.token is not None assert settings.auth.token.get_secret_value() == "server-secret" assert "server-secret" not in repr(settings) @@ -287,7 +292,7 @@ def test_settings_load_bearer_authentication_without_exposing_token(monkeypatch) def test_enabled_bearer_authentication_requires_a_token() -> None: with pytest.raises(ValueError, match="Bearer token is required"): - BearerAuthConfig(enabled=True) + AuthenticationConfig(provider="static-bearer") def test_liveness_adds_a_server_owned_request_id() -> None: @@ -325,19 +330,20 @@ def test_scalar_reference_embeds_the_canonical_openapi_contract() -> None: def test_server_factory_optionally_requires_bearer_authentication() -> None: app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig(enabled=True, token=SecretStr("server-secret")), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", mcp=McpConfig(enabled=False), ) ) - client = TestClient(app) - - missing = client.get("/v1/capabilities") - invalid = client.get("/v1/capabilities", headers={"Authorization": "Bearer wrong"}) - accepted = client.get("/v1/capabilities", headers={"Authorization": "Bearer server-secret"}) - protected_metrics = client.get("/metrics") - accepted_metrics = client.get("/metrics", headers={"Authorization": "Bearer server-secret"}) - liveness = client.get("/health/live") - scalar_reference = client.get("/docs") + with TestClient(app) as client: + missing = client.get("/v1/capabilities") + invalid = client.get("/v1/capabilities", headers={"Authorization": "Bearer wrong"}) + accepted = client.get("/v1/capabilities", headers={"Authorization": "Bearer server-secret"}) + protected_metrics = client.get("/metrics") + accepted_metrics = client.get("/metrics", headers={"Authorization": "Bearer server-secret"}) + liveness = client.get("/health/live") + scalar_reference = client.get("/docs") assert missing.status_code == 401 assert missing.headers["WWW-Authenticate"] == "Bearer" @@ -345,7 +351,7 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: assert missing.json() == { "error": { "code": "unauthorized", - "message": "A valid bearer token is required.", + "message": "A valid credential is required.", "details": None, } } @@ -360,8 +366,9 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: def test_enforced_mode_fails_closed_if_the_authorization_provider_disappears(tmp_path) -> None: app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig(enabled=True, token=SecretStr("server-secret")), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), access=AccessControlConfig(mode="enforced"), + authorization_provider="external", dashboard=DashboardConfig(scopes=[DashboardScopeConfig(scope_id="scope-a", display_name="Scope A")]), database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), mcp=McpConfig(enabled=False), @@ -397,7 +404,9 @@ def test_enforced_mode_fails_closed_if_the_authorization_provider_disappears(tmp def test_server_factory_maps_static_token_to_bootstrap_principal() -> None: app = create_server_app( settings=ServerSettings( - auth=BearerAuthConfig(enabled=True, token=SecretStr("server-secret")), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", database=SQLiteConfig(), mcp=McpConfig(enabled=False), ) @@ -410,15 +419,18 @@ def test_server_factory_maps_static_token_to_bootstrap_principal() -> None: payload = response.json() assert payload["principal"] == { "type": "service", - "issuer": "powercontext:powercontext:static", "id": "server-token", + "description": "PowerContext static bearer", } - assert payload["mode"] == "legacy-static-admin" + assert payload["mode"] == "enforced" assert payload["resource_kinds"] == ["server", "scope", "artifact"] assert payload["provider_capabilities"] == { "safe_resource_filtering": True, "multi_requirement_check": True, "relationship_management": True, + "group_subjects": False, + "multi_principal": False, + "max_direct_resource_keys": 10000, } diff --git a/tests/test_transport.py b/tests/test_transport.py index c2b8647f7..f64477e91 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -28,7 +28,7 @@ from powercontext.client import PowerContextClient from powercontext.client.settings import ClientSettings -from powercontext.server.settings import BearerAuthConfig, HttpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, HttpConfig, ServerSettings from powercontext.transport import is_loopback_host, is_plaintext_non_loopback _ALL_INTERFACES = "0.0.0.0" # noqa: S104 - a non-loopback bind used to exercise the policy. @@ -184,14 +184,16 @@ def test_server_rejects_an_unauthenticated_non_loopback_bind() -> None: with pytest.raises(ValidationError): ServerSettings( http=HttpConfig(host=_ALL_INTERFACES), - auth=BearerAuthConfig(enabled=False), + auth=AuthenticationConfig(), ) def test_server_allows_a_non_loopback_bind_with_authentication() -> None: settings = ServerSettings( http=HttpConfig(host=_ALL_INTERFACES), - auth=BearerAuthConfig(enabled=True, token=SecretStr("server-secret")), + auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), + access=AccessControlConfig(mode="enforced"), + authorization_provider="builtin", ) assert settings.http.host == _ALL_INTERFACES @@ -199,7 +201,7 @@ def test_server_allows_a_non_loopback_bind_with_authentication() -> None: def test_server_allows_a_non_loopback_bind_with_an_explicit_opt_in() -> None: settings = ServerSettings( http=HttpConfig(host=_ALL_INTERFACES), - auth=BearerAuthConfig(enabled=False), + auth=AuthenticationConfig(), allow_unauthenticated_non_loopback=True, ) assert settings.allow_unauthenticated_non_loopback is True From fc567246d9155790cf794bfce9344261f6acf402 Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 2 Sep 2026 22:11:34 +0800 Subject: [PATCH 08/22] test: authenticate configured real-service journey --- tests/e2e/real_experience_skill/harness.py | 23 ++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/e2e/real_experience_skill/harness.py b/tests/e2e/real_experience_skill/harness.py index 4a061152e..bc1590556 100644 --- a/tests/e2e/real_experience_skill/harness.py +++ b/tests/e2e/real_experience_skill/harness.py @@ -283,12 +283,14 @@ def stop(self) -> None: def main(argv: Sequence[str] | None = None) -> int: # noqa: C901 - one exception-safe harness lifecycle arguments = _arguments(argv) configured_settings: ServerSettings | None = None + configured_access_token: str | None = None configured_scopes: ConfiguredScopes | None = None external_skill: Path | None = None if arguments.configured: load_dotenv(arguments.env_file, override=False) configured_settings = ServerSettings() _validate_configured_settings(configured_settings) + configured_access_token = _configured_access_token(configured_settings) configured_scopes = _new_configured_scopes() run_id = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") mode = "configured-experience-skill" if arguments.configured else "experience-skill" @@ -308,6 +310,8 @@ def main(argv: Sequence[str] | None = None) -> int: # noqa: C901 - one exceptio shutil.copyfile(auth_file, isolated_auth) isolated_auth.chmod(0o600) codex_environment = {**os.environ, "CODEX_HOME": str(isolated_home), "NO_COLOR": "1"} + if configured_access_token is not None: + codex_environment["POWERCONTEXT_CLIENT_API_TOKEN"] = configured_access_token server: RunningServer | None = None configured_server_settings: ServerSettings | None = None @@ -394,6 +398,7 @@ def main(argv: Sequence[str] | None = None) -> int: # noqa: C901 - one exceptio server_url=server.base_url, timeout=arguments.codex_timeout, generation_timeout=configured_settings.inference.generation_timeout_seconds, + access_token=configured_access_token, ) ) server.stop() @@ -409,6 +414,7 @@ def main(argv: Sequence[str] | None = None) -> int: # noqa: C901 - one exceptio state=journey, server_url=server.base_url, generation_timeout=configured_settings.inference.generation_timeout_seconds, + access_token=configured_access_token, ) ) finally: @@ -755,6 +761,7 @@ async def _run_configured_journey( server_url: str, timeout: int, # noqa: ASYNC109 - external Codex process budget, not an asyncio timeout scope generation_timeout: float, + access_token: str | None, ) -> ConfiguredJourneyState: memory_scope = scopes.memory artifact_scope = scopes.artifacts @@ -767,7 +774,7 @@ async def _run_configured_journey( generation_timeout + CONFIGURED_EXPERIENCE_SCHEDULE_SECONDS + 30.0, ) - async with PowerContextClient(server_url, timeout=max(30.0, generation_wait)) as client: + async with PowerContextClient(server_url, token=access_token, timeout=max(30.0, generation_wait)) as client: with recorder.scenario( "configured embedding model and database support vector and hybrid Memory retrieval", "api/capabilities.json", @@ -1092,6 +1099,7 @@ async def _run_configured_journey( skill=skill_v1, destination=projection, recorder=recorder, + access_token=access_token, ) recorder.write_text("projection/SKILL.md", (projection / "SKILL.md").read_text(encoding="utf-8")) recorder.write_text( @@ -1551,13 +1559,14 @@ async def _verify_configured_restart( state: ConfiguredJourneyState, server_url: str, generation_timeout: float, + access_token: str | None, ) -> None: with recorder.scenario( "configured state remains exact and searchable after a clean Server restart", "api/restart-persistence.json", ): timeout = max(30.0, generation_timeout + 30.0) - async with PowerContextClient(server_url, timeout=timeout) as client: + async with PowerContextClient(server_url, token=access_token, timeout=timeout) as client: persisted_experience_values: list[ExperienceArtifact] = [] for expected in state.experience_revisions: persisted_experience_values.append( @@ -1787,6 +1796,7 @@ def _project_via_cli( skill: SkillArtifact, destination: Path, recorder: Recorder, + access_token: str | None = None, ) -> None: uv = _required_executable("uv") completed = _run( @@ -1809,6 +1819,7 @@ def _project_via_cli( skill.artifact.artifact_id, ], cwd=PROJECT_ROOT, + env=(None if access_token is None else {**os.environ, "POWERCONTEXT_CLIENT_API_TOKEN": access_token}), ) recorder.write_text("projection/cli.stdout", completed.stdout) @@ -2016,6 +2027,14 @@ def _validate_configured_settings(settings: ServerSettings) -> None: _fail("configured E2E requires POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL") +def _configured_access_token(settings: ServerSettings) -> str | None: + if settings.access.mode == "disabled": + return None + if settings.auth.provider != "static-bearer" or settings.auth.token is None: + _fail("configured E2E supports enforced Access Control only with static-bearer authentication") + return settings.auth.token.get_secret_value() + + def _new_configured_scopes() -> ConfiguredScopes: suffix = f"{time.time_ns()}-{os.getpid()}" return ConfiguredScopes( From 40fa905cf18d9381b2316d9dde50d66f665ddd64 Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 2 Sep 2026 22:42:05 +0800 Subject: [PATCH 09/22] fix(access): retain trusted actor in audit --- docs/en/docs/reference/http-api.md | 3 +- docs/zh/docs/reference/http-api.md | 3 +- .../powercontext/openapi/powercontext.yaml | 5 ++ openapi/powercontext.yaml | 5 ++ src/powercontext/http/_generated/models.py | 1 + src/powercontext/http/_generated/schema.py | 2 + src/powercontext/server/app.py | 1 + src/powercontext/server/authz/composition.py | 8 +-- src/powercontext/server/authz/models.py | 1 + src/powercontext/server/authz/repository.py | 18 +++--- src/powercontext/server/authz/service.py | 3 + tests/test_access_http.py | 59 +++++++++++++++++++ tests/test_server.py | 1 + 13 files changed, 90 insertions(+), 20 deletions(-) diff --git a/docs/en/docs/reference/http-api.md b/docs/en/docs/reference/http-api.md index 9d7a31892..f01206fa8 100644 --- a/docs/en/docs/reference/http-api.md +++ b/docs/en/docs/reference/http-api.md @@ -124,7 +124,8 @@ scope role allows it. Use `/v1/access/me` to verify which Principal the deployment established, `/v1/access/check` for one decision, and `/v1/access/resources/list` for a non-discovering list of already visible resources. Creation is idempotent per grantor and key; revocation uses `binding_id` plus `expected_version`. Relationship and decision events are available -to Server administrators through `/v1/access/audit/list`. +to Server administrators through `/v1/access/audit/list`. When authentication establishes delegated execution, each +audit event keeps the effective `principal` and the trusted `actor` as separate opaque identities. The Access wire contract has only three Resource Kinds: `server`, `scope`, and `artifact`. An Artifact Resource uses the logical identity `{family, artifact_id}` and deliberately contains no Revision. Memory can narrow a grant with a diff --git a/docs/zh/docs/reference/http-api.md b/docs/zh/docs/reference/http-api.md index 9ef701e0e..99a92114c 100644 --- a/docs/zh/docs/reference/http-api.md +++ b/docs/zh/docs/reference/http-api.md @@ -114,7 +114,8 @@ curl --fail \ 接收者可以读取和确认这个 Handoff 的历史、当前及未来 Revision;除非另有 scope role,否则不能在 scope 范围发现 latest Handoff、读取其他 Handoff,也不能访问父 scope 的 Memory。用 `/v1/access/me` 确认部署建立的 Principal,用 `/v1/access/check` 检查一个决策, 用 `/v1/access/resources/list` 非发现式地列出已经可见的资源。创建操作按授权者与幂等键保证幂等;撤销时必须提交 -`binding_id` 和 `expected_version`。Server 管理员可通过 `/v1/access/audit/list` 查看关系变更与决策事件。 +`binding_id` 和 `expected_version`。Server 管理员可通过 `/v1/access/audit/list` 查看关系变更与决策事件。认证层确认代办执行时, +每条审计事件会把 effective `principal` 与可信 `actor` 记录为两个独立的 opaque identity。 Access wire contract 只使用 `server`、`scope` 和 `artifact` 三种 Resource Kind。Artifact Resource 使用逻辑 identity `{family, artifact_id}`,刻意不包含 Revision;Memory 可使用仅含 `entry_id` 的 `memory_entry` selector 缩小授权单位。 diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index 8dfaac031..5fb8b0e75 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -2742,6 +2742,7 @@ components: - transport - operation - principal + - actor - action - resource - allowed @@ -2762,6 +2763,10 @@ components: operation: {type: string, minLength: 1, maxLength: 128} principal: $ref: "#/components/schemas/AccessPrincipal" + actor: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true action: $ref: "#/components/schemas/AccessAction" resource: diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 8dfaac031..5fb8b0e75 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -2742,6 +2742,7 @@ components: - transport - operation - principal + - actor - action - resource - allowed @@ -2762,6 +2763,10 @@ components: operation: {type: string, minLength: 1, maxLength: 128} principal: $ref: "#/components/schemas/AccessPrincipal" + actor: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true action: $ref: "#/components/schemas/AccessAction" resource: diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index f8150070a..400321599 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -370,6 +370,7 @@ class AccessAuditEvent(BaseModel): transport: Annotated[StrictStr, Field(max_length=16, min_length=1)] operation: Annotated[StrictStr, Field(max_length=128, min_length=1)] principal: AccessPrincipal + actor: Annotated[AccessPrincipal | None, Field(...)] action: AccessAction resource: AccessResource allowed: StrictBool diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 04be84210..d99b155d0 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -2456,6 +2456,7 @@ "transport": {"type": "string", "maxLength": 16, "minLength": 1}, "operation": {"type": "string", "maxLength": 128, "minLength": 1}, "principal": {"$ref": "#/components/schemas/AccessPrincipal"}, + "actor": {"allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], "nullable": True}, "action": {"$ref": "#/components/schemas/AccessAction"}, "resource": {"$ref": "#/components/schemas/AccessResource"}, "allowed": {"type": "boolean"}, @@ -2478,6 +2479,7 @@ "transport", "operation", "principal", + "actor", "action", "resource", "allowed", diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 6d7d5eb43..3d6b4d8ed 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -2438,6 +2438,7 @@ def _access_audit_response(value: AccessAuditEvent) -> TransportAccessAuditEvent transport=value.transport, operation=value.operation, principal=_access_principal_response(value.principal), + actor=None if value.actor is None else _access_principal_response(value.actor), action=TransportAccessAction(value.action.value), resource=_access_resource_response(value.resource), allowed=value.allowed, diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py index 1c0623322..570c47fef 100644 --- a/src/powercontext/server/authz/composition.py +++ b/src/powercontext/server/authz/composition.py @@ -35,11 +35,7 @@ PrincipalRef, ResourceRef, ) -from powercontext.server.authz.repository import ( - ACCESS_TABLES, - RelationalAccessRepository, - ensure_access_policy_revision_columns, -) +from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository from powercontext.server.authz.service import ( AccessControlService, AccessProviderCapabilities, @@ -120,8 +116,6 @@ async def _open_access_repository( else: raise BuiltinConfigurationError("database") async with profile_context as profile: - async with profile.database.transaction() as connection: - await ensure_access_policy_revision_columns(connection) yield RelationalAccessRepository(profile.database) diff --git a/src/powercontext/server/authz/models.py b/src/powercontext/server/authz/models.py index c58b3004a..b6b8b6256 100644 --- a/src/powercontext/server/authz/models.py +++ b/src/powercontext/server/authz/models.py @@ -325,6 +325,7 @@ class AccessAuditEvent: transport: str operation: str principal: PrincipalRef + actor: PrincipalRef | None action: AccessAction resource: ResourceRef allowed: bool diff --git a/src/powercontext/server/authz/repository.py b/src/powercontext/server/authz/repository.py index 2f77bec02..0484b07ee 100644 --- a/src/powercontext/server/authz/repository.py +++ b/src/powercontext/server/authz/repository.py @@ -12,12 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Dialect-neutral persistence for terminal Access relationships. - -The table names intentionally describe the final relationship model instead of -reusing the earlier experimental, revision-bound schema. This lets an operator -evaluate the unmerged implementation without altering obsolete Access tables. -""" +"""Dialect-neutral persistence for terminal Access relationships.""" from __future__ import annotations @@ -173,6 +168,9 @@ Column("principal_type", identity_string(16), nullable=False), Column("principal_id", identity_string(255), nullable=False), Column("principal_description", Text), + Column("actor_type", identity_string(16)), + Column("actor_id", identity_string(255)), + Column("actor_description", Text), Column("action", identity_string(64), nullable=False), Column("resource_type", identity_string(16), nullable=False), Column("deployment_id", identity_string(128)), @@ -208,10 +206,6 @@ _POLICY_HEAD = "authorization" -async def ensure_access_policy_revision_columns(_connection: object, /) -> None: - """Keep the composition hook; the terminal schema needs no in-place migration.""" - - class RelationalAccessRepository: """Persist logical bindings, direct ownership and minimized audit events.""" @@ -921,6 +915,7 @@ def _audit_row(event: AccessAuditEvent) -> dict[str, object | None]: "transport": event.transport, "operation": event.operation, **_subject_row("principal", event.principal), + **_optional_subject_row("actor", event.actor), "action": event.action.value, **_resource_row(event.resource), "allowed": event.allowed, @@ -944,6 +939,7 @@ def _decode_audit(row: Mapping[Any, Any]) -> AccessAuditEvent: transport=str(row["transport"]), operation=str(row["operation"]), principal=_principal(row, "principal"), + actor=_optional_principal(row, "actor"), action=AccessAction(str(row["action"])), resource=_decode_resource(row), allowed=bool(row["allowed"]), @@ -1049,4 +1045,4 @@ def _digest(value: str) -> str: return sha256(value.encode("utf-8")).hexdigest() -__all__ = ("ACCESS_TABLES", "RelationalAccessRepository", "ensure_access_policy_revision_columns") +__all__ = ("ACCESS_TABLES", "RelationalAccessRepository") diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index 54ddd2778..cd76d4459 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -980,6 +980,7 @@ async def _record_decision( transport=context.transport, operation=context.operation, principal=principal, + actor=context.actor, action=action, resource=resource, allowed=decision.allowed, @@ -1007,6 +1008,7 @@ async def _record_relationship( transport=context.transport, operation=context.operation, principal=principal, + actor=context.actor, action=_administrative_checks(binding.resource, deployment_id=self.deployment_id)[0][0], resource=binding.resource, allowed=True, @@ -1036,6 +1038,7 @@ async def _record_owner( transport=context.transport, operation=context.operation, principal=principal, + actor=context.actor, action=AccessAction.ARTIFACT_WRITE, resource=relation.resource, allowed=True, diff --git a/tests/test_access_http.py b/tests/test_access_http.py index cf9321d1f..fc6ec7141 100644 --- a/tests/test_access_http.py +++ b/tests/test_access_http.py @@ -70,6 +70,15 @@ async def readiness(self) -> ProviderReadiness: return ProviderReadiness(ready=False) +class _ActingAuthenticationProvider: + async def authenticate(self, request) -> AuthenticationResult: + del request + return AuthenticationResult(subject=BOB, actor=ADMIN, credential_id="delegated-test") + + async def readiness(self) -> ProviderReadiness: + return ProviderReadiness(ready=True) + + def test_enforced_mode_cannot_silently_start_without_authentication_or_provider() -> None: with pytest.raises(ValueError, match="requires authentication and authorization Providers"): ServerSettings(access=AccessControlConfig(mode="enforced")) @@ -196,6 +205,56 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_access_audit_persists_the_trusted_actor_separately_from_the_subject() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) + service = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + ) + provider = _ActingAuthenticationProvider() + app = create_app( + access_control=service, + authentication_provider=provider, + middleware=(Middleware(AuthenticationMiddleware, provider=provider),), + ) + async with _client(app) as client: + checked = await client.post( + "/v1/access/check", + headers=_auth("delegated-token"), + json={ + "action": "scope.read", + "resource": {"type": "scope", "scope_id": "scope-a"}, + }, + ) + assert checked.status_code == 200 + + admin_app = _app(service, principal=ADMIN, token="admin-token") # noqa: S106 - test credential. + async with _client(admin_app) as admin: + audit = await admin.post( + "/v1/access/audit/list", + headers=_auth("admin-token"), + json={ + "resource": {"type": "scope", "scope_id": "scope-a"}, + "subject": {"type": "user", "id": "bob"}, + }, + ) + assert audit.status_code == 200, audit.json() + assert len(audit.json()["items"]) == 1 + event = audit.json()["items"][0] + assert event["principal"] == {"type": "user", "id": "bob", "description": None} + assert event["actor"] == { + "type": "service", + "id": "admin", + "description": "deployment administrator", + } + + asyncio.run(scenario()) + + class _HandoffShareability: def for_scope(self, scope_id: str) -> Self: del scope_id diff --git a/tests/test_server.py b/tests/test_server.py index 28b38f2eb..4676457f2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -333,6 +333,7 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), access=AccessControlConfig(mode="enforced"), authorization_provider="builtin", + database=SQLiteConfig(), mcp=McpConfig(enabled=False), ) ) From ef541793bae6ed821611018dd5c5c51136c4c3d6 Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 2 Sep 2026 23:06:35 +0800 Subject: [PATCH 10/22] test: isolate OpenCode host validation --- tests/e2e/test_opencode_plugin_host.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test_opencode_plugin_host.py b/tests/e2e/test_opencode_plugin_host.py index a2bd0384d..66ac32bf0 100644 --- a/tests/e2e/test_opencode_plugin_host.py +++ b/tests/e2e/test_opencode_plugin_host.py @@ -90,6 +90,8 @@ def test_opencode_run_normalizes_prompt_before_recall_and_capture(tmp_path: Path copyfile(plugin, installed_plugin) env.update({ "OPENCODE_DISABLE_AUTOUPDATE": "true", + "OPENCODE_DISABLE_MODELS_FETCH": "true", + "OPENCODE_TEST_HOME": str(tmp_path), "POWERCONTEXT_OPENCODE_BASE_URL": f"http://127.0.0.1:{server.server_port}", "POWERCONTEXT_OPENCODE_SCOPE_ID": "project:test", }) @@ -112,7 +114,7 @@ def test_opencode_run_normalizes_prompt_before_recall_and_capture(tmp_path: Path ) captured = False try: - deadline = monotonic() + 30 + deadline = monotonic() + 60 while monotonic() < deadline and process.poll() is None: if server.captured.wait(timeout=0.25): captured = True From 22ad4718191d149f74366efcb7e5474dd9b3655a Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 2 Sep 2026 23:45:36 +0800 Subject: [PATCH 11/22] test: handle fresh real-service databases --- tests/e2e/real_experience_skill/harness.py | 24 +++++-- .../e2e/test_real_experience_skill_harness.py | 65 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/test_real_experience_skill_harness.py diff --git a/tests/e2e/real_experience_skill/harness.py b/tests/e2e/real_experience_skill/harness.py index bc1590556..1b24f1652 100644 --- a/tests/e2e/real_experience_skill/harness.py +++ b/tests/e2e/real_experience_skill/harness.py @@ -39,7 +39,7 @@ import uvicorn from dotenv import load_dotenv -from sqlalchemy import bindparam, text +from sqlalchemy import bindparam, inspect, text from powercontext.builtin.artifacts.experience import ExperienceCandidateInput, ExperienceContent from powercontext.builtin.artifacts.skill import CodexSkillRoot @@ -2083,7 +2083,7 @@ async def _discover_harness_scopes(database: DatabaseConfig) -> tuple[str, ...]: async def discover(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> tuple[str, ...]: scopes: set[str] = set() async with profile.database.transaction() as connection: - for table_name in _SCOPE_TABLES: + for table_name in await _existing_scope_tables(connection): statement = text( f"SELECT DISTINCT scope_id FROM {table_name} WHERE scope_id LIKE :prefix" # noqa: S608 ) @@ -2128,9 +2128,10 @@ async def _purge_database_scopes( ) -> dict[str, object]: async def purge(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> dict[str, object]: async with profile.database.transaction() as connection: - before = await _scope_counts(connection, scopes) + tables = await _existing_scope_tables(connection) + before = await _scope_counts(connection, scopes, tables=tables) if scopes: - for table_name in _SCOPE_TABLES: + for table_name in tables: statement = text( f"DELETE FROM {table_name} WHERE scope_id IN :scope_ids" # noqa: S608 ).bindparams(bindparam("scope_ids", expanding=True)) @@ -2164,11 +2165,22 @@ async def purge(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> di return await purge(profile) -async def _scope_counts(connection: Any, scopes: tuple[str, ...]) -> dict[str, dict[str, int]]: +async def _existing_scope_tables(connection: Any) -> tuple[str, ...]: + table_names = set(await connection.run_sync(lambda sync_connection: inspect(sync_connection).get_table_names())) + return tuple(table_name for table_name in _SCOPE_TABLES if table_name in table_names) + + +async def _scope_counts( + connection: Any, + scopes: tuple[str, ...], + *, + tables: tuple[str, ...] | None = None, +) -> dict[str, dict[str, int]]: counts = {scope: dict.fromkeys(_SCOPE_TABLES, 0) for scope in scopes} if not scopes: return counts - for table_name in _SCOPE_TABLES: + existing_tables = await _existing_scope_tables(connection) if tables is None else tables + for table_name in existing_tables: statement = text( f"SELECT scope_id, COUNT(*) FROM {table_name} " # noqa: S608 "WHERE scope_id IN :scope_ids GROUP BY scope_id" diff --git a/tests/e2e/test_real_experience_skill_harness.py b/tests/e2e/test_real_experience_skill_harness.py new file mode 100644 index 000000000..5de8264f2 --- /dev/null +++ b/tests/e2e/test_real_experience_skill_harness.py @@ -0,0 +1,65 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from sqlalchemy import text + +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from tests.e2e.real_experience_skill.harness import _purge_existing_harness_scopes + + +def test_preflight_cleanup_accepts_a_fresh_database(tmp_path: Path) -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'fresh.db'}") + + cleanup = asyncio.run(_purge_existing_harness_scopes(database)) + + assert cleanup == { + "scope_count": 0, + "rows_before": {}, + "rows_after": {}, + "remaining_row_count": 0, + "remaining_harness_scope_count": 0, + } + + +def test_preflight_cleanup_uses_only_existing_scope_tables(tmp_path: Path) -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'partial.db'}") + + async def scenario() -> tuple[dict[str, object], int]: + async with SQLiteProfile.open(database, tables=()) as profile, profile.database.transaction() as connection: + await connection.exec_driver_sql("CREATE TABLE pc_sources (scope_id TEXT NOT NULL)") + await connection.execute( + text("INSERT INTO pc_sources (scope_id) VALUES (:scope_id)"), + [ + {"scope_id": "configured-real-memory:stale"}, + {"scope_id": "project:keep"}, + ], + ) + cleanup = await _purge_existing_harness_scopes(database) + async with SQLiteProfile.open(database, tables=()) as profile, profile.database.transaction() as connection: + remaining = int(await connection.scalar(text("SELECT COUNT(*) FROM pc_sources")) or 0) + return cleanup, remaining + + cleanup, remaining = asyncio.run(scenario()) + + assert cleanup["scope_count"] == 1 + assert cleanup["rows_before"] == {"pc_sources": 1} + assert cleanup["rows_after"] == {} + assert cleanup["remaining_row_count"] == 0 + assert cleanup["remaining_harness_scope_count"] == 0 + assert remaining == 1 From d6e8c84b6b61fe623bc9224240167b7e8275b4e8 Mon Sep 17 00:00:00 2001 From: Teingi Date: Thu, 3 Sep 2026 00:18:25 +0800 Subject: [PATCH 12/22] test: isolate OpenCode temporary state --- tests/e2e/test_opencode_plugin_host.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/test_opencode_plugin_host.py b/tests/e2e/test_opencode_plugin_host.py index 66ac32bf0..77b8d2128 100644 --- a/tests/e2e/test_opencode_plugin_host.py +++ b/tests/e2e/test_opencode_plugin_host.py @@ -85,6 +85,8 @@ def test_opencode_run_normalizes_prompt_before_recall_and_capture(tmp_path: Path path = tmp_path / name path.mkdir() env[f"XDG_{name.upper()}_HOME"] = str(path) + temp_directory = tmp_path / "tmp" + temp_directory.mkdir() installed_plugin = tmp_path / "config" / "opencode" / "plugins" / "powercontext-opencode.js" installed_plugin.parent.mkdir(parents=True) copyfile(plugin, installed_plugin) @@ -94,6 +96,7 @@ def test_opencode_run_normalizes_prompt_before_recall_and_capture(tmp_path: Path "OPENCODE_TEST_HOME": str(tmp_path), "POWERCONTEXT_OPENCODE_BASE_URL": f"http://127.0.0.1:{server.server_port}", "POWERCONTEXT_OPENCODE_SCOPE_ID": "project:test", + "TMPDIR": str(temp_directory), }) process = subprocess.Popen( [ From 9718fe1558fab3d96f67f732e85403fae0ca437c Mon Sep 17 00:00:00 2001 From: Teingi Date: Thu, 3 Sep 2026 17:50:07 +0800 Subject: [PATCH 13/22] feat(access): consolidate access control APIs --- docs/en/docs/reference/http-api.md | 21 +- docs/en/rfcs/1396_handoff_access_control.md | 6 +- docs/zh/docs/reference/http-api.md | 16 +- docs/zh/rfcs/1396_handoff_access_control.md | 6 +- .../dsh/plugins/powercontext/lib/index.js | 10 +- .../powercontext/openapi/powercontext.yaml | 107 ++++---- .../powercontext/src/operations.generated.ts | 3 +- .../plugins/powercontext/lib/index.js | 10 +- .../powercontext/src/operations.generated.ts | 3 +- .../powercontext/src/operations.generated.ts | 3 +- openapi/powercontext.yaml | 107 ++++---- .../builtin/persistence/connectors.py | 29 +- src/powercontext/client/client.py | 23 +- src/powercontext/http/__init__.py | 22 +- src/powercontext/http/_generated/models.py | 44 ++- .../http/_generated/operations.py | 50 +--- src/powercontext/http/_generated/schema.py | 104 +++---- src/powercontext/server/app.py | 103 +++---- src/powercontext/server/authz/__init__.py | 12 +- src/powercontext/server/authz/errors.py | 2 +- src/powercontext/server/authz/models.py | 16 ++ src/powercontext/server/authz/repository.py | 148 ++++++---- src/powercontext/server/authz/service.py | 41 +-- src/powercontext/server/web.py | 117 +++++++- tests/builtin/persistence/test_connectors.py | 71 +++++ tests/builtin/persistence/test_cursors.py | 3 +- .../test_access_control.py | 108 +++++++- tests/test_access_control.py | 123 +++++++-- tests/test_access_http.py | 258 +++++++++++++++++- tests/test_api_contract.py | 42 +++ tests/test_client.py | 92 ++++++- 31 files changed, 1254 insertions(+), 446 deletions(-) create mode 100644 tests/builtin/persistence/test_connectors.py diff --git a/docs/en/docs/reference/http-api.md b/docs/en/docs/reference/http-api.md index 8b43b0203..f60755498 100644 --- a/docs/en/docs/reference/http-api.md +++ b/docs/en/docs/reference/http-api.md @@ -119,11 +119,13 @@ curl --fail \ The receiver can read and acknowledge the Handoff's history, current Revision, and future Revisions. It cannot use scope-wide latest-Handoff discovery, read another Handoff, or access Memory in the parent scope unless a separate scope role allows it. Use `/v1/access/me` to -verify which Principal the deployment established, `/v1/access/check` for one decision, and +verify which Principal the deployment established, `/v1/access/check` for one compound `all` or `any` requirement, and `/v1/access/resources/list` for a non-discovering list of already visible resources. Creation is idempotent per -grantor and key; revocation uses `binding_id` plus `expected_version`. Relationship and decision events are available -to Server administrators through `/v1/access/audit/list`. When authentication establishes delegated execution, each -audit event keeps the effective `principal` and the trusted `actor` as separate opaque identities. +grantor and key; revocation uses `binding_id` plus `expected_version`. An atomic `/v1/access/bindings/replace` +revokes one immutable Binding and creates its successor with the same Resource and role. Role descriptors expose +whether they allow `many_per_resource` or `one_per_resource` active Bindings. Relationship and decision events are +available to Server administrators through `/v1/access/audit/list`. When authentication establishes delegated execution, +each audit event keeps the effective `principal` and the trusted `actor` as separate opaque identities. The Access wire contract has only three Resource Kinds: `server`, `scope`, and `artifact`. An Artifact Resource uses the logical identity `{family, artifact_id}` and deliberately contains no Revision. Memory can narrow a grant with a @@ -137,6 +139,15 @@ the receiving Principal decides whether and how to install an exact Revision. Re `target_id`; public responses and errors omit host paths, Agent homes, credentials, and locators. Detailed Dashboard publication status is separately protected by `server.observe`. +The standard Skill lifecycle uses the same Access boundary. Library listing requires `scope.read`; lifecycle changes +require `artifact.write`; package manifest/download requires `artifact.read`; package proposals require +`scope.contribute` and, when replacing an existing Skill, `artifact.write`; usage capture requires both +`scope.contribute` and `artifact.read`. Remote target administration requires `scope.admin`, while publishing an exact +Revision also requires `artifact.read` for that Skill. The enrollment endpoint is protected by its one-time code, and +Receiver reconcile/download/receipt endpoints use the separately issued `TargetBearerAuth` credential instead of a +user Principal. Dashboard data routes apply the corresponding Access checks before scope lookup, package inspection, +target lookup, or filesystem work. + The built-in static token represents one local administrator and cannot model different A/B users. A real multi-user deployment must authenticate each caller to a different Principal and inject an Authorization Provider. HTTP and MCP use the same policy enforcement point; MCP tool visibility is not permission. @@ -151,7 +162,7 @@ use the same policy enforcement point; MCP tool visibility is not permission. | Work continuity | `/v1/work/*` | Create work contracts, prepare or acknowledge Handoffs, and record outcomes | | Low-level Handoff | `/v1/handoff/*` | Activate, prepare, finalize, commit, or continue a Handoff | | Memory | `/v1/memory/*` | Flush, remember, search, list, get, revise, retire, and inspect changes | -| Experience and Skill | `/v1/experience/*`, `/v1/skill/*`, `/v1/skills/*` | Propose, generate, read Artifact revisions, and export readable managed Skills | +| Experience and Skill | `/v1/experience/*`, `/v1/skill/*`, `/v1/skills/*` | Propose, review, package, govern, distribute, and read managed Skill revisions | | Review | `/v1/artifact-candidates/*` | List, inspect, revise, approve, or reject pending Candidates | | External Skills | `/v1/external-skills/*` | Scan configured targets and resolve or import packages | | Handoff Reports | `/v1/handoff-reports/*` | Manage Projects, Workstreams, activities, reports, and workspace bindings | diff --git a/docs/en/rfcs/1396_handoff_access_control.md b/docs/en/rfcs/1396_handoff_access_control.md index 2fd28de93..8b91cb162 100644 --- a/docs/en/rfcs/1396_handoff_access_control.md +++ b/docs/en/rfcs/1396_handoff_access_control.md @@ -878,16 +878,16 @@ The OpenAPI source of truth adds these operations: | Operation | Purpose | Authorization | | --- | --- | --- | | `GET /v1/access/me` | Return the current Principal and access-control capabilities | Authenticated Principal | -| `POST /v1/access/check` | Check one action/resource for the current Principal | Current Principal only | -| `POST /v1/access/check-batch` | Batch checks for the current Principal | Current Principal only | +| `POST /v1/access/check` | Check one compound `all` or `any` requirement for the current Principal | Current Principal only | | `POST /v1/access/resources/list` | List resource identities available to the current Principal | Current Principal only | | `POST /v1/access/roles/list` | Return fixed roles and action vocabulary | Authenticated Principal | | `POST /v1/access/bindings/list` | List Bindings the caller may administer | `scope.delegate`, `scope.admin`, or `server.admin` | | `POST /v1/access/bindings/create` | Create a Family-compatible exact-resource or administrative Binding | Resource-specific administration action | | `POST /v1/access/bindings/revoke` | Revoke a Binding using CAS | Same administration boundary | +| `POST /v1/access/bindings/replace` | Atomically revoke an immutable Binding and create its successor | Same administration boundary | | `POST /v1/access/audit/list` | Query security audit events | `scope.admin` or `server.admin` | -`check`, `check-batch`, and `resources/list` do not accept a client-selected subject. They evaluate only the current +`check` and `resources/list` do not accept a client-selected subject. They evaluate only the current authenticated Principal, preventing ordinary users from using the API as a personnel permission oracle. Administrator checks for another Principal, subject search, and directory integration are deferred. diff --git a/docs/zh/docs/reference/http-api.md b/docs/zh/docs/reference/http-api.md index 4e07ea290..9676b3264 100644 --- a/docs/zh/docs/reference/http-api.md +++ b/docs/zh/docs/reference/http-api.md @@ -109,9 +109,12 @@ curl --fail \ ``` 接收者可以读取和确认这个 Handoff 的历史、当前及未来 Revision;除非另有 scope role,否则不能在 scope 范围发现 -latest Handoff、读取其他 Handoff,也不能访问父 scope 的 Memory。用 `/v1/access/me` 确认部署建立的 Principal,用 `/v1/access/check` 检查一个决策, +latest Handoff、读取其他 Handoff,也不能访问父 scope 的 Memory。用 `/v1/access/me` 确认部署建立的 Principal,用 `/v1/access/check` +检查一个由 `all` 或 `any` 组合的权限要求, 用 `/v1/access/resources/list` 非发现式地列出已经可见的资源。创建操作按授权者与幂等键保证幂等;撤销时必须提交 -`binding_id` 和 `expected_version`。Server 管理员可通过 `/v1/access/audit/list` 查看关系变更与决策事件。认证层确认代办执行时, +`binding_id` 和 `expected_version`。`/v1/access/bindings/replace` 会原子撤销一个不可变 Binding,并用相同 Resource 和 role +创建后继 Binding;角色描述通过 `many_per_resource` 或 `one_per_resource` 声明活动 Binding 数量约束。Server 管理员可通过 +`/v1/access/audit/list` 查看关系变更与决策事件。认证层确认代办执行时, 每条审计事件会把 effective `principal` 与可信 `actor` 记录为两个独立的 opaque identity。 Access wire contract 只使用 `server`、`scope` 和 `artifact` 三种 Resource Kind。Artifact Resource 使用逻辑 identity @@ -124,6 +127,13 @@ Managed Skill 的导出和安装不使用单独的分享权限。`/v1/skills/pub `target_id`;公共响应和错误不返回 host path、Agent home、credential 或 locator。详细 Dashboard publication status 另由 `server.observe` 保护。 +标准 Skill 生命周期复用同一 Access 边界:Library 列表要求 `scope.read`,生命周期变更要求 `artifact.write`, +package manifest/download 要求 `artifact.read`,package proposal 要求 `scope.contribute`,替换已有 Skill 时还要求 +`artifact.write`;usage capture 同时要求 `scope.contribute` 与 `artifact.read`。远端 target 管理要求 +`scope.admin`,发布精确 Revision 还要求该 Skill 的 `artifact.read`。注册接口由一次性 enrollment code 保护, +Receiver 的 reconcile/download/receipt 使用单独签发的 `TargetBearerAuth` 凭据,而不是用户 Principal。 +Dashboard 数据接口会在 scope 查询、package 检查、target 查询或文件系统操作之前执行对应 Access 检查。 + 内置静态 token 只代表一个本地管理员,无法表达不同的 A/B 用户。真正的多用户部署必须把每个调用者认证为不同的 Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策略执行点;MCP tool 可见不等于有权限。 @@ -137,7 +147,7 @@ Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策 | 工作连续性 | `/v1/work/*` | 创建 Work Contract、准备或确认 Handoff、记录 Outcome | | 底层 Handoff | `/v1/handoff/*` | activate、prepare、finalize、commit 或 continue Handoff | | Memory | `/v1/memory/*` | flush、remember、search、list、get、revise、retire 和查看变更 | -| Experience 与 Skill | `/v1/experience/*`、`/v1/skill/*`、`/v1/skills/*` | propose、generate、读取 Artifact Revision 和导出可读的 managed Skill | +| Experience 与 Skill | `/v1/experience/*`、`/v1/skill/*`、`/v1/skills/*` | propose、review、打包、治理、分发并读取 managed Skill Revision | | 审核 | `/v1/artifact-candidates/*` | 列出、检查、修订、批准或拒绝 pending Candidate | | 外部 Skill | `/v1/external-skills/*` | 扫描已配置 target,解析或导入 package | | Handoff Report | `/v1/handoff-reports/*` | 管理 Project、Workstream、activity、report 和 workspace binding | diff --git a/docs/zh/rfcs/1396_handoff_access_control.md b/docs/zh/rfcs/1396_handoff_access_control.md index 7406ee213..c3569c379 100644 --- a/docs/zh/rfcs/1396_handoff_access_control.md +++ b/docs/zh/rfcs/1396_handoff_access_control.md @@ -822,16 +822,16 @@ OpenAPI source of truth 增加以下 operation: | Operation | Purpose | Authorization | | --- | --- | --- | | `GET /v1/access/me` | 返回当前 Principal 和 access-control capability | authenticated Principal | -| `POST /v1/access/check` | 检查当前 Principal 的一个 action/resource | current Principal only | -| `POST /v1/access/check-batch` | 批量检查当前 Principal | current Principal only | +| `POST /v1/access/check` | 检查当前 Principal 的一个 `all` 或 `any` 复合权限要求 | current Principal only | | `POST /v1/access/resources/list` | 列出当前 Principal 可访问的资源 identity | current Principal only | | `POST /v1/access/roles/list` | 返回固定角色及 action vocabulary | authenticated Principal | | `POST /v1/access/bindings/list` | 列出调用方可管理的 Binding | `scope.delegate`, `scope.admin`, or `server.admin` | | `POST /v1/access/bindings/create` | 创建 Family-compatible exact-resource 或管理级 Binding | resource-specific administration action | | `POST /v1/access/bindings/revoke` | CAS revoke 一个 Binding | same administration boundary | +| `POST /v1/access/bindings/replace` | 原子撤销不可变 Binding 并创建其后继 Binding | same administration boundary | | `POST /v1/access/audit/list` | 查询安全审计事件 | `scope.admin` or `server.admin` | -`check`、`check-batch` 和 `resources/list` 不接受 client-specified subject,只检查当前 authenticated Principal,防止普通 +`check` 和 `resources/list` 不接受 client-specified subject,只检查当前 authenticated Principal,防止普通 用户把 API 当作人员权限枚举器。管理员代查其他 Principal、subject search 和 directory integration 留给后续 RFC。 `bindings/create` 必须接收 recipient subject,因为分享需要指定 B;调用方仍然只能在自己拥有管理权限的 resource 上创建 diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 7df82da06..2fd2288cd 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -545,12 +545,6 @@ const OPERATIONS = { location: "body", scope: false }, - check_access_batch: { - method: "POST", - path: "/v1/access/check-batch", - location: "body", - scope: false - }, list_access_resources: { method: "POST", path: "/v1/access/resources/list", @@ -581,9 +575,9 @@ const OPERATIONS = { location: "body", scope: false }, - reassign_handoff_receiver_binding: { + replace_access_binding: { method: "POST", - path: "/v1/access/bindings/reassign-handoff-receiver", + path: "/v1/access/bindings/replace", location: "body", scope: false }, diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index a96b7f5fc..9d343f66c 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -2603,7 +2603,7 @@ paths: /v1/access/check: post: tags: [access] - summary: Check one authorization decision + summary: Check one compound authorization requirement operationId: check_access x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: @@ -2614,38 +2614,11 @@ paths: $ref: "#/components/schemas/AccessCheckRequest" responses: "200": - description: A low-sensitivity allow or deny decision. + description: The aggregate decision and ordered low-sensitivity requirement decisions. content: application/json: schema: - $ref: "#/components/schemas/AccessDecision" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "422": - $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" - /v1/access/check-batch: - post: - tags: [access] - summary: Check a bounded batch of authorization decisions - operationId: check_access_batch - x-powercontext-access: {action: access.self, resource: {type: server}} - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AccessCheckBatchRequest" - responses: - "200": - description: Ordered low-sensitivity decisions matching the submitted checks. - content: - application/json: - schema: - $ref: "#/components/schemas/AccessCheckBatchResponse" + $ref: "#/components/schemas/AccessCheckResponse" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -2793,25 +2766,25 @@ paths: $ref: "#/components/responses/InvalidRequest" "503": $ref: "#/components/responses/Unavailable" - /v1/access/bindings/reassign-handoff-receiver: + /v1/access/bindings/replace: post: tags: [access] - summary: Atomically reassign the single Handoff receiver - operationId: reassign_handoff_receiver_binding + summary: Atomically replace an immutable Access Binding + operationId: replace_access_binding x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ReassignHandoffReceiverRequest" + $ref: "#/components/schemas/ReplaceAccessBindingRequest" responses: "200": - description: The revoked previous receiver and active replacement Binding. + description: The revoked previous Binding and active replacement with the same resource and role. content: application/json: schema: - $ref: "#/components/schemas/HandoffReceiverReassignment" + $ref: "#/components/schemas/AccessBindingReplacement" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -3111,7 +3084,10 @@ components: properties: allowed: {type: boolean} reason_code: {type: string, minLength: 1, maxLength: 64} - AccessCheckRequest: + AccessRequirementMatch: + type: string + enum: [all, any] + AccessCheckRequirement: type: object additionalProperties: false required: [action, resource] @@ -3120,24 +3096,28 @@ components: $ref: "#/components/schemas/AccessAction" resource: $ref: "#/components/schemas/AccessResource" - AccessCheckBatchRequest: + AccessCheckRequest: type: object additionalProperties: false - required: [checks] + required: [match, requirements] properties: - checks: + match: + $ref: "#/components/schemas/AccessRequirementMatch" + requirements: type: array minItems: 1 maxItems: 100 items: - $ref: "#/components/schemas/AccessCheckRequest" - AccessCheckBatchResponse: + $ref: "#/components/schemas/AccessCheckRequirement" + AccessCheckResponse: type: object additionalProperties: false - required: [decisions] + required: [allowed, decisions] properties: + allowed: {type: boolean} decisions: type: array + minItems: 1 maxItems: 100 items: $ref: "#/components/schemas/AccessDecision" @@ -3180,6 +3160,9 @@ components: - scope.admin - server.observer - server.admin + AccessRoleCardinality: + type: string + enum: [many_per_resource, one_per_resource] ListAccessRolesRequest: type: object additionalProperties: false @@ -3192,12 +3175,21 @@ components: AccessRoleDescriptor: type: object additionalProperties: false - required: [role, resource_type, actions, artifact_families, assignable_subject_types, system_managed] + required: + - role + - resource_type + - cardinality + - actions + - artifact_families + - assignable_subject_types + - system_managed properties: role: $ref: "#/components/schemas/AccessRole" resource_type: $ref: "#/components/schemas/AccessResourceType" + cardinality: + $ref: "#/components/schemas/AccessRoleCardinality" actions: type: array items: @@ -3317,26 +3309,33 @@ components: binding_id: {type: string, minLength: 1, maxLength: 64} expected_version: {type: integer, minimum: 1} idempotency_key: {type: string, minLength: 1, maxLength: 255} - ReassignHandoffReceiverRequest: + AccessBindingReplacementInput: type: object additionalProperties: false - required: [binding_id, expected_version, subject, idempotency_key] + required: [subject] properties: - binding_id: {type: string, minLength: 1, maxLength: 64} - expected_version: {type: integer, minimum: 1} subject: - $ref: "#/components/schemas/AccessPrincipal" - expires_at: {type: string, format: date-time, nullable: true} + $ref: "#/components/schemas/AccessSubject" reason: {type: string, maxLength: 1024, nullable: true} + expires_at: {type: string, format: date-time, nullable: true} + ReplaceAccessBindingRequest: + type: object + additionalProperties: false + required: [binding_id, expected_version, replacement, idempotency_key] + properties: + binding_id: {type: string, minLength: 1, maxLength: 64} + expected_version: {type: integer, minimum: 1} + replacement: + $ref: "#/components/schemas/AccessBindingReplacementInput" idempotency_key: {type: string, minLength: 1, maxLength: 255} - HandoffReceiverReassignment: + AccessBindingReplacement: type: object additionalProperties: false - required: [revoked_binding, created_binding] + required: [previous, current] properties: - revoked_binding: + previous: $ref: "#/components/schemas/AccessBinding" - created_binding: + current: $ref: "#/components/schemas/AccessBinding" ListAccessAuditRequest: type: object diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 5112f74f4..a720fee6a 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -94,13 +94,12 @@ export const OPERATIONS = { detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scope: false }, check_access: { method: 'POST', path: '/v1/access/check', location: "body", scope: false }, - check_access_batch: { method: 'POST', path: '/v1/access/check-batch', location: "body", scope: false }, list_access_resources: { method: 'POST', path: '/v1/access/resources/list', location: "body", scope: false }, list_access_roles: { method: 'POST', path: '/v1/access/roles/list', location: "body", scope: false }, list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - reassign_handoff_receiver_binding: { method: 'POST', path: '/v1/access/bindings/reassign-handoff-receiver', location: "body", scope: false }, + replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scope: false }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index f7376f7d1..283313140 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -530,12 +530,6 @@ const OPERATIONS = { location: "body", scope: false }, - check_access_batch: { - method: "POST", - path: "/v1/access/check-batch", - location: "body", - scope: false - }, list_access_resources: { method: "POST", path: "/v1/access/resources/list", @@ -566,9 +560,9 @@ const OPERATIONS = { location: "body", scope: false }, - reassign_handoff_receiver_binding: { + replace_access_binding: { method: "POST", - path: "/v1/access/bindings/reassign-handoff-receiver", + path: "/v1/access/bindings/replace", location: "body", scope: false }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 5112f74f4..a720fee6a 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -94,13 +94,12 @@ export const OPERATIONS = { detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scope: false }, check_access: { method: 'POST', path: '/v1/access/check', location: "body", scope: false }, - check_access_batch: { method: 'POST', path: '/v1/access/check-batch', location: "body", scope: false }, list_access_resources: { method: 'POST', path: '/v1/access/resources/list', location: "body", scope: false }, list_access_roles: { method: 'POST', path: '/v1/access/roles/list', location: "body", scope: false }, list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - reassign_handoff_receiver_binding: { method: 'POST', path: '/v1/access/bindings/reassign-handoff-receiver', location: "body", scope: false }, + replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scope: false }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 5112f74f4..a720fee6a 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -94,13 +94,12 @@ export const OPERATIONS = { detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scope: false }, check_access: { method: 'POST', path: '/v1/access/check', location: "body", scope: false }, - check_access_batch: { method: 'POST', path: '/v1/access/check-batch', location: "body", scope: false }, list_access_resources: { method: 'POST', path: '/v1/access/resources/list', location: "body", scope: false }, list_access_roles: { method: 'POST', path: '/v1/access/roles/list', location: "body", scope: false }, list_access_bindings: { method: 'POST', path: '/v1/access/bindings/list', location: "body", scope: false }, create_access_binding: { method: 'POST', path: '/v1/access/bindings/create', location: "body", scope: false }, revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scope: false }, - reassign_handoff_receiver_binding: { method: 'POST', path: '/v1/access/bindings/reassign-handoff-receiver', location: "body", scope: false }, + replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scope: false }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scope: false }, } as const diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index a96b7f5fc..9d343f66c 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -2603,7 +2603,7 @@ paths: /v1/access/check: post: tags: [access] - summary: Check one authorization decision + summary: Check one compound authorization requirement operationId: check_access x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: @@ -2614,38 +2614,11 @@ paths: $ref: "#/components/schemas/AccessCheckRequest" responses: "200": - description: A low-sensitivity allow or deny decision. + description: The aggregate decision and ordered low-sensitivity requirement decisions. content: application/json: schema: - $ref: "#/components/schemas/AccessDecision" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "422": - $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" - /v1/access/check-batch: - post: - tags: [access] - summary: Check a bounded batch of authorization decisions - operationId: check_access_batch - x-powercontext-access: {action: access.self, resource: {type: server}} - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AccessCheckBatchRequest" - responses: - "200": - description: Ordered low-sensitivity decisions matching the submitted checks. - content: - application/json: - schema: - $ref: "#/components/schemas/AccessCheckBatchResponse" + $ref: "#/components/schemas/AccessCheckResponse" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -2793,25 +2766,25 @@ paths: $ref: "#/components/responses/InvalidRequest" "503": $ref: "#/components/responses/Unavailable" - /v1/access/bindings/reassign-handoff-receiver: + /v1/access/bindings/replace: post: tags: [access] - summary: Atomically reassign the single Handoff receiver - operationId: reassign_handoff_receiver_binding + summary: Atomically replace an immutable Access Binding + operationId: replace_access_binding x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ReassignHandoffReceiverRequest" + $ref: "#/components/schemas/ReplaceAccessBindingRequest" responses: "200": - description: The revoked previous receiver and active replacement Binding. + description: The revoked previous Binding and active replacement with the same resource and role. content: application/json: schema: - $ref: "#/components/schemas/HandoffReceiverReassignment" + $ref: "#/components/schemas/AccessBindingReplacement" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -3111,7 +3084,10 @@ components: properties: allowed: {type: boolean} reason_code: {type: string, minLength: 1, maxLength: 64} - AccessCheckRequest: + AccessRequirementMatch: + type: string + enum: [all, any] + AccessCheckRequirement: type: object additionalProperties: false required: [action, resource] @@ -3120,24 +3096,28 @@ components: $ref: "#/components/schemas/AccessAction" resource: $ref: "#/components/schemas/AccessResource" - AccessCheckBatchRequest: + AccessCheckRequest: type: object additionalProperties: false - required: [checks] + required: [match, requirements] properties: - checks: + match: + $ref: "#/components/schemas/AccessRequirementMatch" + requirements: type: array minItems: 1 maxItems: 100 items: - $ref: "#/components/schemas/AccessCheckRequest" - AccessCheckBatchResponse: + $ref: "#/components/schemas/AccessCheckRequirement" + AccessCheckResponse: type: object additionalProperties: false - required: [decisions] + required: [allowed, decisions] properties: + allowed: {type: boolean} decisions: type: array + minItems: 1 maxItems: 100 items: $ref: "#/components/schemas/AccessDecision" @@ -3180,6 +3160,9 @@ components: - scope.admin - server.observer - server.admin + AccessRoleCardinality: + type: string + enum: [many_per_resource, one_per_resource] ListAccessRolesRequest: type: object additionalProperties: false @@ -3192,12 +3175,21 @@ components: AccessRoleDescriptor: type: object additionalProperties: false - required: [role, resource_type, actions, artifact_families, assignable_subject_types, system_managed] + required: + - role + - resource_type + - cardinality + - actions + - artifact_families + - assignable_subject_types + - system_managed properties: role: $ref: "#/components/schemas/AccessRole" resource_type: $ref: "#/components/schemas/AccessResourceType" + cardinality: + $ref: "#/components/schemas/AccessRoleCardinality" actions: type: array items: @@ -3317,26 +3309,33 @@ components: binding_id: {type: string, minLength: 1, maxLength: 64} expected_version: {type: integer, minimum: 1} idempotency_key: {type: string, minLength: 1, maxLength: 255} - ReassignHandoffReceiverRequest: + AccessBindingReplacementInput: type: object additionalProperties: false - required: [binding_id, expected_version, subject, idempotency_key] + required: [subject] properties: - binding_id: {type: string, minLength: 1, maxLength: 64} - expected_version: {type: integer, minimum: 1} subject: - $ref: "#/components/schemas/AccessPrincipal" - expires_at: {type: string, format: date-time, nullable: true} + $ref: "#/components/schemas/AccessSubject" reason: {type: string, maxLength: 1024, nullable: true} + expires_at: {type: string, format: date-time, nullable: true} + ReplaceAccessBindingRequest: + type: object + additionalProperties: false + required: [binding_id, expected_version, replacement, idempotency_key] + properties: + binding_id: {type: string, minLength: 1, maxLength: 64} + expected_version: {type: integer, minimum: 1} + replacement: + $ref: "#/components/schemas/AccessBindingReplacementInput" idempotency_key: {type: string, minLength: 1, maxLength: 255} - HandoffReceiverReassignment: + AccessBindingReplacement: type: object additionalProperties: false - required: [revoked_binding, created_binding] + required: [previous, current] properties: - revoked_binding: + previous: $ref: "#/components/schemas/AccessBinding" - created_binding: + current: $ref: "#/components/schemas/AccessBinding" ListAccessAuditRequest: type: object diff --git a/src/powercontext/builtin/persistence/connectors.py b/src/powercontext/builtin/persistence/connectors.py index 4c65e83b9..34a4abfdc 100644 --- a/src/powercontext/builtin/persistence/connectors.py +++ b/src/powercontext/builtin/persistence/connectors.py @@ -97,18 +97,29 @@ async def save( payload = _dump_checkpoint(binding, checkpoint) if existing_row is None: + statement = insert(CONNECTOR_CHECKPOINTS_TABLE).values( + scope_id=binding.scope_id, + binding_id=binding.binding_id, + connector_name=binding.connector_name, + connector_version=binding.connector_version, + checkpoint=payload, + ) try: - async with connection.begin_nested(): - await connection.execute( - insert(CONNECTOR_CHECKPOINTS_TABLE).values( - scope_id=binding.scope_id, - binding_id=binding.binding_id, - connector_name=binding.connector_name, - connector_version=binding.connector_version, - checkpoint=payload, - ) + if connection.dialect.name == "sqlite": + async with connection.begin_nested(): + await connection.execute(statement) + elif connection.dialect.name == "mysql": + await connection.execute(statement) + else: + raise InvalidConnectorRunError( + "unsupported-database", + f"unsupported database dialect: {connection.dialect.name}", ) except IntegrityError: + # SQLite needs the nested transaction to keep the outer CAS + # transaction usable after an insert race. OceanBase is + # MySQL-compatible but may discard a write-path SAVEPOINT + # before SQLAlchemy releases it, so execute directly there. raise _checkpoint_conflict(binding) from None else: result = await connection.execute( diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 7211df45d..23dac3b91 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -29,10 +29,9 @@ AccessAuditPage, AccessBinding, AccessBindingPage, - AccessCheckBatchRequest, - AccessCheckBatchResponse, + AccessBindingReplacement, AccessCheckRequest, - AccessDecision, + AccessCheckResponse, AccessMeResponse, AccessResourcePage, AccessRolePage, @@ -142,6 +141,7 @@ RemoteSkillTargetCredential, RemoteSkillTargetEnrollment, RenameRemoteSkillTargetRequest, + ReplaceAccessBindingRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, @@ -176,7 +176,6 @@ ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, CHECK_ACCESS, - CHECK_ACCESS_BATCH, COMMIT_CONNECTOR_CHECKPOINT, COMMIT_HANDOFF, CONTINUE_HANDOFF, @@ -241,6 +240,7 @@ REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RENAME_REMOTE_SKILL_TARGET, + REPLACE_ACCESS_BINDING, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, @@ -515,16 +515,11 @@ async def get_access_principal(self) -> AccessMeResponse: return await self._request(GET_ACCESS_PRINCIPAL) - async def check_access(self, request: AccessCheckRequest) -> AccessDecision: - """Evaluate one action and resource for the current Principal.""" + async def check_access(self, request: AccessCheckRequest) -> AccessCheckResponse: + """Evaluate one compound requirement for the current Principal.""" return await self._request(CHECK_ACCESS, request) - async def check_access_batch(self, request: AccessCheckBatchRequest) -> AccessCheckBatchResponse: - """Evaluate a bounded ordered batch for the current Principal.""" - - return await self._request(CHECK_ACCESS_BATCH, request) - async def list_access_resources(self, request: ListAccessResourcesRequest) -> AccessResourcePage: """List only relationships already visible to the current Principal.""" @@ -550,6 +545,11 @@ async def revoke_access_binding(self, request: RevokeAccessBindingRequest) -> Ac return await self._request(REVOKE_ACCESS_BINDING, request) + async def replace_access_binding(self, request: ReplaceAccessBindingRequest) -> AccessBindingReplacement: + """Atomically replace an immutable Access Binding.""" + + return await self._request(REPLACE_ACCESS_BINDING, request) + async def list_access_audit(self, request: ListAccessAuditRequest) -> AccessAuditPage: """List data-minimized authorization and relationship audit events.""" @@ -710,6 +710,7 @@ async def publish_managed_skill(self, request: PublishManagedSkillRequest) -> Ma """Publish one exact managed Skill to an opaque configured target.""" return await self._request(PUBLISH_MANAGED_SKILL, request) + async def list_managed_skills(self, request: ListManagedSkillsRequest) -> ListManagedSkillsResponse: """List or search current governed managed Skill heads.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 482518ac9..38cf955a4 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -21,20 +21,24 @@ AccessAuditPage, AccessBinding, AccessBindingPage, + AccessBindingReplacement, + AccessBindingReplacementInput, AccessBindingState, - AccessCheckBatchRequest, - AccessCheckBatchResponse, AccessCheckRequest, + AccessCheckRequirement, + AccessCheckResponse, AccessControlMode, AccessDecision, AccessGroup, AccessMeResponse, AccessPrincipal, AccessProviderCapabilities, + AccessRequirementMatch, AccessResource, AccessResourcePage, AccessResourceType, AccessRole, + AccessRoleCardinality, AccessRoleDescriptor, AccessRolePage, AccessSubject, @@ -117,7 +121,6 @@ HandoffMemoryCitation, HandoffOmission, HandoffReceiptStatus, - HandoffReceiverReassignment, HandoffReportActivity, HandoffReportActivityAgent, HandoffReportActivityPage, @@ -196,7 +199,6 @@ PurgeHandoffReportActivitiesResponse, ReadinessResponse, ReadinessStatus, - ReassignHandoffReceiverRequest, RecallTokenDay, RecallTokenStatistics, RecallTokenValue, @@ -226,6 +228,7 @@ RemoteSkillTargetState, RemoteSkillTargetStatus, RenameRemoteSkillTargetRequest, + ReplaceAccessBindingRequest, ReportActivitySource, ReportCatalogState, ReportFormat, @@ -295,20 +298,24 @@ "AccessAuditPage", "AccessBinding", "AccessBindingPage", + "AccessBindingReplacement", + "AccessBindingReplacementInput", "AccessBindingState", - "AccessCheckBatchRequest", - "AccessCheckBatchResponse", "AccessCheckRequest", + "AccessCheckRequirement", + "AccessCheckResponse", "AccessControlMode", "AccessDecision", "AccessGroup", "AccessMeResponse", "AccessPrincipal", "AccessProviderCapabilities", + "AccessRequirementMatch", "AccessResource", "AccessResourcePage", "AccessResourceType", "AccessRole", + "AccessRoleCardinality", "AccessRoleDescriptor", "AccessRolePage", "AccessSubject", @@ -391,7 +398,6 @@ "HandoffMemoryCitation", "HandoffOmission", "HandoffReceiptStatus", - "HandoffReceiverReassignment", "HandoffReportActivity", "HandoffReportActivityAgent", "HandoffReportActivityPage", @@ -470,7 +476,6 @@ "PurgeHandoffReportActivitiesResponse", "ReadinessResponse", "ReadinessStatus", - "ReassignHandoffReceiverRequest", "RecallTokenDay", "RecallTokenStatistics", "RecallTokenValue", @@ -500,6 +505,7 @@ "RemoteSkillTargetState", "RemoteSkillTargetStatus", "RenameRemoteSkillTargetRequest", + "ReplaceAccessBindingRequest", "ReportActivitySource", "ReportCatalogState", "ReportFormat", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index ca0376d71..aedd5088a 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -166,7 +166,12 @@ class AccessDecision(BaseModel): reason_code: Annotated[StrictStr, Field(max_length=64, min_length=1)] -class AccessCheckRequest(BaseModel): +class AccessRequirementMatch(StrEnum): + ALL = "all" + ANY = "any" + + +class AccessCheckRequirement(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -174,18 +179,20 @@ class AccessCheckRequest(BaseModel): resource: AccessResource -class AccessCheckBatchRequest(BaseModel): +class AccessCheckRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - checks: Annotated[list[AccessCheckRequest], Field(max_length=100, min_length=1)] + match: AccessRequirementMatch + requirements: Annotated[list[AccessCheckRequirement], Field(max_length=100, min_length=1)] -class AccessCheckBatchResponse(BaseModel): +class AccessCheckResponse(BaseModel): model_config = ConfigDict( extra="forbid", ) - decisions: Annotated[list[AccessDecision], Field(max_length=100)] + allowed: StrictBool + decisions: Annotated[list[AccessDecision], Field(max_length=100, min_length=1)] class ListAccessResourcesRequest(BaseModel): @@ -223,6 +230,11 @@ class AccessRole(StrEnum): SERVER_ADMIN = "server.admin" +class AccessRoleCardinality(StrEnum): + MANY_PER_RESOURCE = "many_per_resource" + ONE_PER_RESOURCE = "one_per_resource" + + class ListAccessRolesRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -247,6 +259,7 @@ class AccessRoleDescriptor(BaseModel): ) role: AccessRole resource_type: AccessResourceType + cardinality: AccessRoleCardinality actions: list[AccessAction] artifact_families: list[ArtifactFamily] assignable_subject_types: list[AssignableSubjectType] @@ -326,24 +339,31 @@ class RevokeAccessBindingRequest(BaseModel): idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] -class ReassignHandoffReceiverRequest(BaseModel): +class AccessBindingReplacementInput(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + subject: AccessSubject + reason: Annotated[StrictStr | None, Field(max_length=1024)] = None + expires_at: AwareDatetime | None = None + + +class ReplaceAccessBindingRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) binding_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] expected_version: Annotated[StrictInt, Field(ge=1)] - subject: AccessPrincipal - expires_at: AwareDatetime | None = None - reason: Annotated[StrictStr | None, Field(max_length=1024)] = None + replacement: AccessBindingReplacementInput idempotency_key: Annotated[StrictStr, Field(max_length=255, min_length=1)] -class HandoffReceiverReassignment(BaseModel): +class AccessBindingReplacement(BaseModel): model_config = ConfigDict( extra="forbid", ) - revoked_binding: AccessBinding - created_binding: AccessBinding + previous: AccessBinding + current: AccessBinding class Result(StrEnum): diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index dd2d9c83b..9f2c7b81e 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -10,10 +10,9 @@ AccessAuditPage, AccessBinding, AccessBindingPage, - AccessCheckBatchRequest, - AccessCheckBatchResponse, + AccessBindingReplacement, AccessCheckRequest, - AccessDecision, + AccessCheckResponse, AccessMeResponse, AccessResourcePage, AccessRolePage, @@ -60,7 +59,6 @@ HandoffActivation, HandoffCurrentWorkRequest, HandoffDraft, - HandoffReceiverReassignment, HandoffReportActivityPage, HandoffReportResponse, HandoffReportWorkspaceBinding, @@ -107,7 +105,6 @@ PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, - ReassignHandoffReceiverRequest, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, RecordHandoffReportActivityRequest, @@ -124,6 +121,7 @@ RemoteSkillTargetCredential, RemoteSkillTargetEnrollment, RenameRemoteSkillTargetRequest, + ReplaceAccessBindingRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, @@ -2073,38 +2071,18 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), ) -CHECK_ACCESS = Operation[AccessCheckRequest, AccessDecision]( +CHECK_ACCESS = Operation[AccessCheckRequest, AccessCheckResponse]( method="POST", path="/v1/access/check", operation_id="check_access", request_type=AccessCheckRequest, request_location="body", - response_type=AccessDecision, + response_type=AccessCheckResponse, success_status=200, - summary="Check one authorization decision", + summary="Check one compound authorization requirement", tags=("access",), responses={ - 200: {"description": "A low-sensitivity allow or deny decision."}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 403: {"$ref": "#/components/responses/Forbidden"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 503: {"$ref": "#/components/responses/Unavailable"}, - }, - access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), -) - -CHECK_ACCESS_BATCH = Operation[AccessCheckBatchRequest, AccessCheckBatchResponse]( - method="POST", - path="/v1/access/check-batch", - operation_id="check_access_batch", - request_type=AccessCheckBatchRequest, - request_location="body", - response_type=AccessCheckBatchResponse, - success_status=200, - summary="Check a bounded batch of authorization decisions", - tags=("access",), - responses={ - 200: {"description": "Ordered low-sensitivity decisions matching the submitted checks."}, + 200: {"description": "The aggregate decision and ordered low-sensitivity requirement decisions."}, 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, @@ -2215,18 +2193,18 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), ) -REASSIGN_HANDOFF_RECEIVER_BINDING = Operation[ReassignHandoffReceiverRequest, HandoffReceiverReassignment]( +REPLACE_ACCESS_BINDING = Operation[ReplaceAccessBindingRequest, AccessBindingReplacement]( method="POST", - path="/v1/access/bindings/reassign-handoff-receiver", - operation_id="reassign_handoff_receiver_binding", - request_type=ReassignHandoffReceiverRequest, + path="/v1/access/bindings/replace", + operation_id="replace_access_binding", + request_type=ReplaceAccessBindingRequest, request_location="body", - response_type=HandoffReceiverReassignment, + response_type=AccessBindingReplacement, success_status=200, - summary="Atomically reassign the single Handoff receiver", + summary="Atomically replace an immutable Access Binding", tags=("access",), responses={ - 200: {"description": "The revoked previous receiver and active replacement Binding."}, + 200: {"description": "The revoked previous Binding and active replacement with the same resource and role."}, 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 409: {"$ref": "#/components/responses/Conflict"}, diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index d1a776f21..01173b263 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -2436,7 +2436,7 @@ "/v1/access/check": { "post": { "tags": ["access"], - "summary": "Check one authorization decision", + "summary": "Check one compound authorization requirement", "operationId": "check_access", "requestBody": { "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessCheckRequest"}}}, @@ -2444,33 +2444,9 @@ }, "responses": { "200": { - "description": "A low-sensitivity allow or deny decision.", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AccessDecision"}}}, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "403": {"$ref": "#/components/responses/Forbidden"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "503": {"$ref": "#/components/responses/Unavailable"}, - }, - "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, - } - }, - "/v1/access/check-batch": { - "post": { - "tags": ["access"], - "summary": "Check a bounded batch of authorization decisions", - "operationId": "check_access_batch", - "requestBody": { - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/AccessCheckBatchRequest"}} - }, - "required": True, - }, - "responses": { - "200": { - "description": "Ordered low-sensitivity decisions matching the submitted checks.", + "description": "The aggregate decision and ordered low-sensitivity requirement decisions.", "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/AccessCheckBatchResponse"}} + "application/json": {"schema": {"$ref": "#/components/schemas/AccessCheckResponse"}} }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, @@ -2605,22 +2581,33 @@ "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, - "/v1/access/bindings/reassign-handoff-receiver": { + "/v1/access/bindings/replace": { "post": { "tags": ["access"], - "summary": "Atomically reassign the single Handoff receiver", - "operationId": "reassign_handoff_receiver_binding", + "summary": "Atomically replace an immutable Access Binding", + "operationId": "replace_access_binding", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ReassignHandoffReceiverRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ReplaceAccessBindingRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The revoked previous receiver and active replacement Binding.", + "description": "The " + "revoked " + "previous " + "Binding " + "and " + "active " + "replacement " + "with the " + "same " + "resource " + "and " + "role.", "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReceiverReassignment"}} + "application/json": {"schema": {"$ref": "#/components/schemas/AccessBindingReplacement"}} }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, @@ -2839,7 +2826,8 @@ "type": "object", "required": ["allowed", "reason_code"], }, - "AccessCheckRequest": { + "AccessRequirementMatch": {"type": "string", "enum": ["all", "any"]}, + "AccessCheckRequirement": { "properties": { "action": {"$ref": "#/components/schemas/AccessAction"}, "resource": {"$ref": "#/components/schemas/AccessResource"}, @@ -2848,30 +2836,33 @@ "type": "object", "required": ["action", "resource"], }, - "AccessCheckBatchRequest": { + "AccessCheckRequest": { "properties": { - "checks": { - "items": {"$ref": "#/components/schemas/AccessCheckRequest"}, + "match": {"$ref": "#/components/schemas/AccessRequirementMatch"}, + "requirements": { + "items": {"$ref": "#/components/schemas/AccessCheckRequirement"}, "type": "array", "maxItems": 100, "minItems": 1, - } + }, }, "additionalProperties": False, "type": "object", - "required": ["checks"], + "required": ["match", "requirements"], }, - "AccessCheckBatchResponse": { + "AccessCheckResponse": { "properties": { + "allowed": {"type": "boolean"}, "decisions": { "items": {"$ref": "#/components/schemas/AccessDecision"}, "type": "array", "maxItems": 100, - } + "minItems": 1, + }, }, "additionalProperties": False, "type": "object", - "required": ["decisions"], + "required": ["allowed", "decisions"], }, "ListAccessResourcesRequest": { "properties": { @@ -2916,6 +2907,7 @@ "server.admin", ], }, + "AccessRoleCardinality": {"type": "string", "enum": ["many_per_resource", "one_per_resource"]}, "ListAccessRolesRequest": { "properties": { "resource_type": {"allOf": [{"$ref": "#/components/schemas/AccessResourceType"}], "nullable": True}, @@ -2928,6 +2920,7 @@ "properties": { "role": {"$ref": "#/components/schemas/AccessRole"}, "resource_type": {"$ref": "#/components/schemas/AccessResourceType"}, + "cardinality": {"$ref": "#/components/schemas/AccessRoleCardinality"}, "actions": {"items": {"$ref": "#/components/schemas/AccessAction"}, "type": "array"}, "artifact_families": { "items": {"type": "string", "maxLength": 128, "minLength": 1}, @@ -2944,6 +2937,7 @@ "required": [ "role", "resource_type", + "cardinality", "actions", "artifact_families", "assignable_subject_types", @@ -3048,27 +3042,35 @@ "type": "object", "required": ["binding_id", "expected_version", "idempotency_key"], }, - "ReassignHandoffReceiverRequest": { + "AccessBindingReplacementInput": { + "properties": { + "subject": {"$ref": "#/components/schemas/AccessSubject"}, + "reason": {"type": "string", "maxLength": 1024, "nullable": True}, + "expires_at": {"type": "string", "format": "date-time", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["subject"], + }, + "ReplaceAccessBindingRequest": { "properties": { "binding_id": {"type": "string", "maxLength": 64, "minLength": 1}, "expected_version": {"type": "integer", "minimum": 1.0}, - "subject": {"$ref": "#/components/schemas/AccessPrincipal"}, - "expires_at": {"type": "string", "format": "date-time", "nullable": True}, - "reason": {"type": "string", "maxLength": 1024, "nullable": True}, + "replacement": {"$ref": "#/components/schemas/AccessBindingReplacementInput"}, "idempotency_key": {"type": "string", "maxLength": 255, "minLength": 1}, }, "additionalProperties": False, "type": "object", - "required": ["binding_id", "expected_version", "subject", "idempotency_key"], + "required": ["binding_id", "expected_version", "replacement", "idempotency_key"], }, - "HandoffReceiverReassignment": { + "AccessBindingReplacement": { "properties": { - "revoked_binding": {"$ref": "#/components/schemas/AccessBinding"}, - "created_binding": {"$ref": "#/components/schemas/AccessBinding"}, + "previous": {"$ref": "#/components/schemas/AccessBinding"}, + "current": {"$ref": "#/components/schemas/AccessBinding"}, }, "additionalProperties": False, "type": "object", - "required": ["revoked_binding", "created_binding"], + "required": ["previous", "current"], }, "ListAccessAuditRequest": { "properties": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index c9325c9cc..ffb876238 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -305,9 +305,8 @@ from powercontext.http import ( AccessAuditPage, AccessBindingPage, - AccessCheckBatchRequest, - AccessCheckBatchResponse, AccessCheckRequest, + AccessCheckResponse, AccessMeResponse, AccessProviderCapabilities, AccessResourcePage, @@ -406,7 +405,6 @@ PurgeHandoffReportActivitiesResponse, ReadinessResponse, ReadinessStatus, - ReassignHandoffReceiverRequest, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, RecordHandoffReportActivityRequest, @@ -425,6 +423,7 @@ RemoteSkillTargetEnrollment, RemoteSkillTargetStatus, RenameRemoteSkillTargetRequest, + ReplaceAccessBindingRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, @@ -459,6 +458,9 @@ from powercontext.http import ( AccessBinding as TransportAccessBinding, ) +from powercontext.http import ( + AccessBindingReplacement as TransportAccessBindingReplacement, +) from powercontext.http import ( AccessBindingState as TransportAccessBindingState, ) @@ -480,6 +482,9 @@ from powercontext.http import ( AccessRole as TransportAccessRole, ) +from powercontext.http import ( + AccessRoleCardinality as TransportAccessRoleCardinality, +) from powercontext.http import ( AccessRoleDescriptor as TransportAccessRoleDescriptor, ) @@ -501,9 +506,6 @@ from powercontext.http import ( HandoffDraft as TransportHandoffDraft, ) -from powercontext.http import ( - HandoffReceiverReassignment as TransportHandoffReceiverReassignment, -) from powercontext.http import ( HandoffResolution as TransportHandoffResolution, ) @@ -536,7 +538,6 @@ ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, CHECK_ACCESS, - CHECK_ACCESS_BATCH, COMMIT_CONNECTOR_CHECKPOINT, COMMIT_HANDOFF, CONTINUE_HANDOFF, @@ -592,7 +593,6 @@ PUBLISH_MANAGED_SKILL, PUBLISH_REMOTE_SKILL, PURGE_HANDOFF_REPORT_ACTIVITIES, - REASSIGN_HANDOFF_RECEIVER_BINDING, RECONCILE_REMOTE_SKILLS, RECORD_HANDOFF_REPORT_ACTIVITY, RECORD_REMOTE_SKILL_RECEIPT, @@ -603,6 +603,7 @@ REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RENAME_REMOTE_SKILL_TARGET, + REPLACE_ACCESS_BINDING, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, @@ -647,11 +648,11 @@ GroupRef, MemoryEntrySelector, PrincipalRef, - ReassignHandoffReceiver, + ReplaceBinding, ResourceRef, access_control_for_mode, ) -from powercontext.server.authz.models import ROLE_ACTIONS, ROLE_RESOURCE_TYPES, ROLE_SUBJECT_TYPES +from powercontext.server.authz.models import ROLE_ACTIONS, ROLE_CARDINALITIES, ROLE_RESOURCE_TYPES, ROLE_SUBJECT_TYPES from powercontext.server.authz.profiles import ARTIFACT_FAMILY_PROFILES, artifact_family_profile from powercontext.server.context import ( bind_request_id, @@ -1100,13 +1101,12 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_STATS, get_stats) _add_route(app, GET_ACCESS_PRINCIPAL, get_access_principal) _add_route(app, CHECK_ACCESS, check_access) - _add_route(app, CHECK_ACCESS_BATCH, check_access_batch) _add_route(app, LIST_ACCESS_RESOURCES, list_access_resources) _add_route(app, LIST_ACCESS_ROLES, list_access_roles) _add_route(app, LIST_ACCESS_BINDINGS, list_access_bindings) _add_route(app, CREATE_ACCESS_BINDING, create_access_binding) _add_route(app, REVOKE_ACCESS_BINDING, revoke_access_binding) - _add_route(app, REASSIGN_HANDOFF_RECEIVER_BINDING, reassign_handoff_receiver_binding) + _add_route(app, REPLACE_ACCESS_BINDING, replace_access_binding) _add_route(app, LIST_ACCESS_AUDIT, list_access_audit) if handoff_report_enabled: _add_route(app, CREATE_HANDOFF_REPORT_PROJECT, create_handoff_report_project) @@ -1299,26 +1299,26 @@ async def get_access_principal(request: Request) -> AccessMeResponse: ) -async def check_access(payload: AccessCheckRequest, request: Request) -> TransportAccessDecision: +async def check_access(payload: AccessCheckRequest, request: Request) -> AccessCheckResponse: access = _require_access_control(request) - decision = await access.check( - _require_principal(), - AccessAction(payload.action.value), - _access_resource(payload.resource), - context=_access_audit_context(CHECK_ACCESS.operation_id), + requirements = tuple( + (AccessAction(requirement.action.value), _access_resource(requirement.resource)) + for requirement in payload.requirements ) - return _access_decision_response(decision) - - -async def check_access_batch(payload: AccessCheckBatchRequest, request: Request) -> AccessCheckBatchResponse: - access = _require_access_control(request) - checks = tuple((AccessAction(check.action.value), _access_resource(check.resource)) for check in payload.checks) decisions = await access.check_batch( _require_principal(), - checks, - context=_access_audit_context(CHECK_ACCESS_BATCH.operation_id), + requirements, + context=_access_audit_context(CHECK_ACCESS.operation_id), + ) + allowed = ( + all(decision.allowed for decision in decisions) + if payload.match.value == "all" + else any(decision.allowed for decision in decisions) + ) + return AccessCheckResponse( + allowed=allowed, + decisions=[_access_decision_response(decision) for decision in decisions], ) - return AccessCheckBatchResponse(decisions=[_access_decision_response(decision) for decision in decisions]) async def list_access_resources(payload: ListAccessResourcesRequest, request: Request) -> AccessResourcePage: @@ -1467,6 +1467,7 @@ async def list_access_roles(payload: ListAccessRolesRequest, request: Request) - TransportAccessRoleDescriptor( role=TransportAccessRole(role.value), resource_type=TransportAccessResourceType(ROLE_RESOURCE_TYPES[role].value), + cardinality=TransportAccessRoleCardinality(ROLE_CARDINALITIES[role].value), actions=[ TransportAccessAction(action.value) for action in sorted(ROLE_ACTIONS[role], key=str) @@ -1541,26 +1542,26 @@ async def revoke_access_binding(payload: RevokeAccessBindingRequest, request: Re return _access_binding_response(binding) -async def reassign_handoff_receiver_binding( - payload: ReassignHandoffReceiverRequest, +async def replace_access_binding( + payload: ReplaceAccessBindingRequest, request: Request, -) -> TransportHandoffReceiverReassignment: +) -> TransportAccessBindingReplacement: access = _require_access_control(request) - result = await access.reassign_handoff_receiver( + result = await access.replace_binding( _require_principal(), - ReassignHandoffReceiver( + ReplaceBinding( binding_id=payload.binding_id, expected_version=payload.expected_version, - subject=_access_principal(payload.subject), + subject=_access_subject(payload.replacement.subject), idempotency_key=payload.idempotency_key, - reason=payload.reason, - expires_at=payload.expires_at, + reason=payload.replacement.reason, + expires_at=payload.replacement.expires_at, ), - context=_access_audit_context(REASSIGN_HANDOFF_RECEIVER_BINDING.operation_id), + context=_access_audit_context(REPLACE_ACCESS_BINDING.operation_id), ) - return TransportHandoffReceiverReassignment( - revoked_binding=_access_binding_response(result.revoked_binding), - created_binding=_access_binding_response(result.created_binding), + return TransportAccessBindingReplacement( + previous=_access_binding_response(result.previous), + current=_access_binding_response(result.current), ) @@ -2366,6 +2367,7 @@ async def download_skill_package( async def propose_skill_package( request: ProposeSkillPackageRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> ArtifactCandidate: try: archive_bytes = base64.b64decode(request.archive_base64, validate=True) @@ -2379,6 +2381,13 @@ async def propose_skill_package( ) except ValueError as error: raise InvalidRuntimeRequestError("skill-package") from error + await _attest_candidate_owner( + http_request, + scope_id=request.scope_id, + candidate_id=candidate.candidate_id, + family=candidate.family, + target=candidate.target, + ) return mapping.candidate_response(candidate) @@ -3411,14 +3420,16 @@ def _skill_identity_write_access( payload: Mapping[str, Any], _deployment_id: str, ) -> tuple[tuple[AccessAction, ResourceRef], ...]: - return (( - AccessAction.ARTIFACT_WRITE, - ResourceRef.artifact( - _nested_request_value(payload, "scope_id"), - family="skill", - artifact_id=_nested_request_value(payload, "artifact_id"), + return ( + ( + AccessAction.ARTIFACT_WRITE, + ResourceRef.artifact( + _nested_request_value(payload, "scope_id"), + family="skill", + artifact_id=_nested_request_value(payload, "artifact_id"), + ), ), - ),) + ) def _skill_usage_access( diff --git a/src/powercontext/server/authz/__init__.py b/src/powercontext/server/authz/__init__.py index 76a91ba4e..37d15a371 100644 --- a/src/powercontext/server/authz/__init__.py +++ b/src/powercontext/server/authz/__init__.py @@ -28,6 +28,7 @@ from powercontext.server.authz.models import ( DEFAULT_DEPLOYMENT_ID, PUBLIC_ACCESS_ACTIONS, + ROLE_CARDINALITIES, AccessAction, AccessAuditEvent, AccessBinding, @@ -35,6 +36,7 @@ AccessDecision, AccessResourceType, AccessRole, + AccessRoleCardinality, AccessSubjectRef, ArtifactIdentity, ArtifactOwnerRelation, @@ -56,14 +58,14 @@ AuthorizationProvider, AuthorizedResourceFilter, AuthorizedResourcePage, + BindingReplacement, BindingSearchRequest, BuiltinAuthorizationProvider, CreateBinding, - HandoffReceiverReassignment, - ReassignHandoffReceiver, RelationshipReader, RelationshipStore, RelationshipWriter, + ReplaceBinding, ResourceSearchRequest, access_control_for_mode, ) @@ -71,6 +73,7 @@ __all__ = ( "DEFAULT_DEPLOYMENT_ID", "PUBLIC_ACCESS_ACTIONS", + "ROLE_CARDINALITIES", "AccessAction", "AccessAuditContext", "AccessAuditEvent", @@ -91,6 +94,7 @@ "AccessRequest", "AccessResourceType", "AccessRole", + "AccessRoleCardinality", "AccessSubjectRef", "AccessUnavailableError", "ArtifactIdentity", @@ -100,19 +104,19 @@ "AuthorizationProvider", "AuthorizedResourceFilter", "AuthorizedResourcePage", + "BindingReplacement", "BindingSearchRequest", "BuiltinAuthorizationProvider", "CandidateOwnerAttestation", "CasbinAuthorizationProvider", "CreateBinding", "GroupRef", - "HandoffReceiverReassignment", "MemoryEntrySelector", "PrincipalRef", - "ReassignHandoffReceiver", "RelationshipReader", "RelationshipStore", "RelationshipWriter", + "ReplaceBinding", "ResourceRef", "ResourceSearchRequest", "access_control_for_mode", diff --git a/src/powercontext/server/authz/errors.py b/src/powercontext/server/authz/errors.py index 477fc2157..9f209a810 100644 --- a/src/powercontext/server/authz/errors.py +++ b/src/powercontext/server/authz/errors.py @@ -65,9 +65,9 @@ def __init__(self, code: str) -> None: self.code = code messages = { "artifact-owner": "the logical Artifact already has a different owner", + "binding_cardinality_conflict": "the Access role already has the maximum active Bindings for this resource", "binding-version": "the Access Binding version is stale", "candidate-owner": "the Candidate is already locked to a different proposed owner", - "handoff_receiver_conflict": "the logical Handoff already has an active receiver", "idempotency-key": "the Access Binding idempotency key was reused with different input", "access_cursor_stale": "the Access cursor belongs to an older policy revision", } diff --git a/src/powercontext/server/authz/models.py b/src/powercontext/server/authz/models.py index b6b8b6256..72fd136bd 100644 --- a/src/powercontext/server/authz/models.py +++ b/src/powercontext/server/authz/models.py @@ -75,6 +75,13 @@ class AccessRole(StrEnum): SERVER_ADMIN = "server.admin" +class AccessRoleCardinality(StrEnum): + """Number of active assignments allowed for one role and resource.""" + + MANY_PER_RESOURCE = "many_per_resource" + ONE_PER_RESOURCE = "one_per_resource" + + class AccessBindingState(StrEnum): """Lifecycle state of an immutable role assignment.""" @@ -409,6 +416,13 @@ class AccessAuditEvent: } +ROLE_CARDINALITIES: dict[AccessRole, AccessRoleCardinality] = dict.fromkeys( + AccessRole, AccessRoleCardinality.MANY_PER_RESOURCE +) +ROLE_CARDINALITIES[AccessRole.HANDOFF_RECEIVER] = AccessRoleCardinality.ONE_PER_RESOURCE +ROLE_CARDINALITIES[AccessRole.ARTIFACT_OWNER] = AccessRoleCardinality.ONE_PER_RESOURCE + + ROLE_SUBJECT_TYPES: dict[AccessRole, frozenset[str]] = { role: frozenset({"user", "service", "group"}) for role in AccessRole } @@ -428,6 +442,7 @@ def _canonical_json(value: object) -> str: "DEFAULT_DEPLOYMENT_ID", "PUBLIC_ACCESS_ACTIONS", "ROLE_ACTIONS", + "ROLE_CARDINALITIES", "ROLE_CHILD_ACTIONS", "ROLE_RESOURCE_TYPES", "ROLE_SUBJECT_TYPES", @@ -438,6 +453,7 @@ def _canonical_json(value: object) -> str: "AccessDecision", "AccessResourceType", "AccessRole", + "AccessRoleCardinality", "AccessSubjectRef", "ArtifactIdentity", "ArtifactOwnerRelation", diff --git a/src/powercontext/server/authz/repository.py b/src/powercontext/server/authz/repository.py index 0484b07ee..d2a3c19b7 100644 --- a/src/powercontext/server/authz/repository.py +++ b/src/powercontext/server/authz/repository.py @@ -48,12 +48,14 @@ ) from powercontext.server.authz.errors import AccessConflictError, AccessInvalidRequestError from powercontext.server.authz.models import ( + ROLE_CARDINALITIES, AccessAction, AccessAuditEvent, AccessBinding, AccessBindingState, AccessResourceType, AccessRole, + AccessRoleCardinality, AccessSubjectRef, ArtifactOwnerRelation, CandidateOwnerAttestation, @@ -62,7 +64,7 @@ PrincipalRef, ResourceRef, ) -from powercontext.server.authz.service import BindingSearchRequest, HandoffReceiverReassignment, ReassignHandoffReceiver +from powercontext.server.authz.service import BindingReplacement, BindingSearchRequest, ReplaceBinding ACCESS_METADATA = MetaData() @@ -124,10 +126,11 @@ Column("idempotency_key", identity_string(255), nullable=False), ) -ACCESS_RECEIVER_LEASES_TABLE = Table( - "pc_access_handoff_receiver_leases", +ACCESS_BINDING_LEASES_TABLE = Table( + "pc_access_binding_leases", ACCESS_METADATA, Column("resource_key_hash", identity_string(64), primary_key=True), + Column("role", identity_string(32), primary_key=True), Column("binding_id", identity_string(64), unique=True), ) @@ -199,7 +202,7 @@ ACCESS_BINDINGS_TABLE, ACCESS_OWNERS_TABLE, ACCESS_CANDIDATE_OWNERS_TABLE, - ACCESS_RECEIVER_LEASES_TABLE, + ACCESS_BINDING_LEASES_TABLE, ACCESS_IDEMPOTENCY_TABLE, ACCESS_AUDIT_EVENTS_TABLE, ) @@ -399,8 +402,8 @@ async def create_binding(self, binding: AccessBinding, /) -> AccessBinding: if row is None: raise AccessConflictError("idempotency-key") return _decode_binding(row) - if binding.role is AccessRole.HANDOFF_RECEIVER: - await _claim_receiver_lease(connection, binding, now=binding.created_at) + if ROLE_CARDINALITIES[binding.role] is AccessRoleCardinality.ONE_PER_RESOURCE: + await _claim_singleton_binding_lease(connection, binding, now=binding.created_at) revision = await self._increment_policy_revision(connection) created = replace(binding, policy_revision=str(revision)) try: @@ -467,10 +470,10 @@ async def revoke_binding( ) if result.rowcount != 1: raise AccessConflictError("binding-version") - if current.role is AccessRole.HANDOFF_RECEIVER: + if ROLE_CARDINALITIES[current.role] is AccessRoleCardinality.ONE_PER_RESOURCE: await connection.execute( - update(ACCESS_RECEIVER_LEASES_TABLE) - .where(ACCESS_RECEIVER_LEASES_TABLE.c.binding_id == binding_id) + update(ACCESS_BINDING_LEASES_TABLE) + .where(ACCESS_BINDING_LEASES_TABLE.c.binding_id == binding_id) .values(binding_id=None) ) await _record_idempotency( @@ -483,14 +486,14 @@ async def revoke_binding( ) return revoked - async def reassign_handoff_receiver( + async def replace_binding( self, - request: ReassignHandoffReceiver, + request: ReplaceBinding, /, *, actor: PrincipalRef, changed_at: datetime, - ) -> HandoffReceiverReassignment: + ) -> BindingReplacement: payload_hash = _digest( "\0".join(( request.binding_id, @@ -506,7 +509,7 @@ async def reassign_handoff_receiver( connection, actor=actor, key=request.idempotency_key, - operation="handoff.receiver.reassign", + operation="binding.replace", payload_hash=payload_hash, ) if replay is not None: @@ -514,30 +517,14 @@ async def reassign_handoff_receiver( new_row = None if replay[1] is None else await _binding_by_id(connection, replay[1]) if old_row is None or new_row is None: raise AccessConflictError("idempotency-key") - return HandoffReceiverReassignment(_decode_binding(old_row), _decode_binding(new_row)) + return BindingReplacement(_decode_binding(old_row), _decode_binding(new_row)) old_row = await _binding_by_id(connection, request.binding_id, for_update=True) if old_row is None: raise AccessConflictError("binding-version") old = _decode_binding(old_row) - if ( - old.role is not AccessRole.HANDOFF_RECEIVER - or old.version != request.expected_version - or old.state is not AccessBindingState.ACTIVE - ): + if old.version != request.expected_version or old.state is not AccessBindingState.ACTIVE: raise AccessConflictError("binding-version") - lease = ( - ( - await connection.execute( - select(ACCESS_RECEIVER_LEASES_TABLE) - .where(ACCESS_RECEIVER_LEASES_TABLE.c.resource_key_hash == _digest(old.resource.key)) - .with_for_update() - ) - ) - .mappings() - .one_or_none() - ) - if lease is None or lease["binding_id"] != old.binding_id: - raise AccessConflictError("handoff_receiver_conflict") + has_singleton_lease = await _lock_singleton_binding_lease(connection, old) revision = await self._increment_policy_revision(connection) revoked = replace( old, @@ -551,7 +538,7 @@ async def reassign_handoff_receiver( binding_id=f"bind_{sha256(f'{request.idempotency_key}:{old.binding_id}'.encode()).hexdigest()[:32]}", subject=request.subject, resource=old.resource, - role=AccessRole.HANDOFF_RECEIVER, + role=old.role, granted_by=actor, reason=request.reason, created_at=changed_at, @@ -574,28 +561,24 @@ async def reassign_handoff_receiver( raise AccessConflictError("binding-version") try: await connection.execute(insert(ACCESS_BINDINGS_TABLE).values(_binding_row(created))) - lease_result = await connection.execute( - update(ACCESS_RECEIVER_LEASES_TABLE) - .where( - ACCESS_RECEIVER_LEASES_TABLE.c.resource_key_hash == _digest(old.resource.key), - ACCESS_RECEIVER_LEASES_TABLE.c.binding_id == old.binding_id, - ) - .values(binding_id=created.binding_id) + await _transfer_singleton_binding_lease( + connection, + previous=old, + current=created, + required=has_singleton_lease, ) - if lease_result.rowcount != 1: - raise AccessConflictError("handoff_receiver_conflict") await _record_idempotency( connection, actor=actor, key=request.idempotency_key, - operation="handoff.receiver.reassign", + operation="binding.replace", payload_hash=payload_hash, result_binding_id=old.binding_id, secondary_binding_id=created.binding_id, ) except IntegrityError as error: raise AccessConflictError("idempotency-key") from error - return HandoffReceiverReassignment(revoked, created) + return BindingReplacement(revoked, created) async def append_audit(self, event: AccessAuditEvent, /) -> AccessAuditEvent: async with self._database.transaction() as connection: @@ -726,13 +709,16 @@ async def _record_idempotency( ) -async def _claim_receiver_lease(connection: Any, binding: AccessBinding, *, now: datetime) -> None: +async def _claim_singleton_binding_lease(connection: Any, binding: AccessBinding, *, now: datetime) -> None: resource_hash = _digest(binding.resource.key) lease = ( ( await connection.execute( - select(ACCESS_RECEIVER_LEASES_TABLE) - .where(ACCESS_RECEIVER_LEASES_TABLE.c.resource_key_hash == resource_hash) + select(ACCESS_BINDING_LEASES_TABLE) + .where( + ACCESS_BINDING_LEASES_TABLE.c.resource_key_hash == resource_hash, + ACCESS_BINDING_LEASES_TABLE.c.role == binding.role.value, + ) .with_for_update() ) ) @@ -742,31 +728,83 @@ async def _claim_receiver_lease(connection: Any, binding: AccessBinding, *, now: if lease is None: try: await connection.execute( - insert(ACCESS_RECEIVER_LEASES_TABLE).values( + insert(ACCESS_BINDING_LEASES_TABLE).values( resource_key_hash=resource_hash, + role=binding.role.value, binding_id=binding.binding_id, ) ) except IntegrityError as error: - raise AccessConflictError("handoff_receiver_conflict") from error + raise AccessConflictError("binding_cardinality_conflict") from error else: return prior_id = None if lease["binding_id"] is None else str(lease["binding_id"]) prior_row = None if prior_id is None else await _binding_by_id(connection, prior_id, for_update=True) + if prior_id is not None and prior_row is None: + # A concurrent transaction may have claimed the lease while its new + # Binding is not yet visible in this transaction's snapshot. Treat a + # non-null lease without a visible Binding as occupied; reclaiming it + # here can admit two active singleton Bindings on OceanBase. + raise AccessConflictError("binding_cardinality_conflict") if prior_row is not None and _decode_binding(prior_row).active_at(now): - raise AccessConflictError("handoff_receiver_conflict") + raise AccessConflictError("binding_cardinality_conflict") result = await connection.execute( - update(ACCESS_RECEIVER_LEASES_TABLE) + update(ACCESS_BINDING_LEASES_TABLE) .where( - ACCESS_RECEIVER_LEASES_TABLE.c.resource_key_hash == resource_hash, - ACCESS_RECEIVER_LEASES_TABLE.c.binding_id.is_(None) + ACCESS_BINDING_LEASES_TABLE.c.resource_key_hash == resource_hash, + ACCESS_BINDING_LEASES_TABLE.c.role == binding.role.value, + ACCESS_BINDING_LEASES_TABLE.c.binding_id.is_(None) if prior_id is None - else ACCESS_RECEIVER_LEASES_TABLE.c.binding_id == prior_id, + else ACCESS_BINDING_LEASES_TABLE.c.binding_id == prior_id, ) .values(binding_id=binding.binding_id) ) if result.rowcount != 1: - raise AccessConflictError("handoff_receiver_conflict") + raise AccessConflictError("binding_cardinality_conflict") + + +async def _lock_singleton_binding_lease(connection: Any, binding: AccessBinding) -> bool: + if ROLE_CARDINALITIES[binding.role] is not AccessRoleCardinality.ONE_PER_RESOURCE: + return False + lease = ( + ( + await connection.execute( + select(ACCESS_BINDING_LEASES_TABLE) + .where( + ACCESS_BINDING_LEASES_TABLE.c.resource_key_hash == _digest(binding.resource.key), + ACCESS_BINDING_LEASES_TABLE.c.role == binding.role.value, + ) + .with_for_update() + ) + ) + .mappings() + .one_or_none() + ) + if lease is None or lease["binding_id"] != binding.binding_id: + raise AccessConflictError("binding_cardinality_conflict") + return True + + +async def _transfer_singleton_binding_lease( + connection: Any, + *, + previous: AccessBinding, + current: AccessBinding, + required: bool, +) -> None: + if not required: + return + result = await connection.execute( + update(ACCESS_BINDING_LEASES_TABLE) + .where( + ACCESS_BINDING_LEASES_TABLE.c.resource_key_hash == _digest(previous.resource.key), + ACCESS_BINDING_LEASES_TABLE.c.role == previous.role.value, + ACCESS_BINDING_LEASES_TABLE.c.binding_id == previous.binding_id, + ) + .values(binding_id=current.binding_id) + ) + if result.rowcount != 1: + raise AccessConflictError("binding_cardinality_conflict") def _boundary_predicates(resource: ResourceRef, *, audit: bool = False) -> tuple[Any, ...]: diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index cd76d4459..2b1bf4471 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -192,12 +192,12 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) -class ReassignHandoffReceiver: - """Atomic compare-and-swap receiver reassignment.""" +class ReplaceBinding: + """Atomic compare-and-swap replacement of one immutable Binding.""" binding_id: str expected_version: int - subject: PrincipalRef + subject: AccessSubjectRef idempotency_key: str reason: str | None = None expires_at: datetime | None = None @@ -211,11 +211,11 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) -class HandoffReceiverReassignment: - """Both sides of one atomic receiver reassignment.""" +class BindingReplacement: + """The revoked Binding and its active replacement.""" - revoked_binding: AccessBinding - created_binding: AccessBinding + previous: AccessBinding + current: AccessBinding class AuthorizationProvider(Protocol): @@ -272,14 +272,14 @@ async def revoke_binding( revoked_by: PrincipalRef, ) -> AccessBinding: ... - async def reassign_handoff_receiver( + async def replace_binding( self, - request: ReassignHandoffReceiver, + request: ReplaceBinding, /, *, actor: PrincipalRef, changed_at: datetime, - ) -> HandoffReceiverReassignment: ... + ) -> BindingReplacement: ... class RelationshipStore(RelationshipReader, RelationshipWriter, Protocol): @@ -906,13 +906,13 @@ async def revoke_binding( ) return revoked - async def reassign_handoff_receiver( + async def replace_binding( self, principal: PrincipalRef | None, - request: ReassignHandoffReceiver, + request: ReplaceBinding, *, context: AccessAuditContext, - ) -> HandoffReceiverReassignment: + ) -> BindingReplacement: actor = _required_principal(principal) current = await _access_call(self._relationship_reader().get_binding(request.binding_id)) if current is None: @@ -925,8 +925,9 @@ async def reassign_handoff_receiver( if decision.allowed: raise AccessBindingNotFoundError raise AccessDeniedError - if current.role is not AccessRole.HANDOFF_RECEIVER or current.resource.family != "handoff": - raise AccessInvalidRequestError("binding-role") + validate_binding_subject(current.resource, current.role, request.subject.type) + if isinstance(request.subject, GroupRef) and not self.provider_capabilities.group_subjects: + raise AccessInvalidRequestError("group-subjects-unavailable") if request.expires_at is not None and request.expires_at <= self._clock(): raise AccessInvalidRequestError("binding-expired") await self.require_any( @@ -935,7 +936,7 @@ async def reassign_handoff_receiver( context=context, ) changed = await _access_call( - self._relationship_writer().reassign_handoff_receiver( + self._relationship_writer().replace_binding( request, actor=actor, changed_at=self._clock(), @@ -943,13 +944,13 @@ async def reassign_handoff_receiver( ) await _access_call( self._record_relationship( - changed.revoked_binding, + changed.previous, principal=actor, context=context, expected_version=request.expected_version, ) ) - await _access_call(self._record_relationship(changed.created_binding, principal=actor, context=context)) + await _access_call(self._record_relationship(changed.current, principal=actor, context=context)) return changed def _relationship_reader(self) -> RelationshipReader: @@ -1433,14 +1434,14 @@ async def _access_call(awaitable: Awaitable[_T]) -> _T: "AuthorizationProvider", "AuthorizedResourceFilter", "AuthorizedResourcePage", + "BindingReplacement", "BindingSearchRequest", "BuiltinAuthorizationProvider", "CreateBinding", - "HandoffReceiverReassignment", - "ReassignHandoffReceiver", "RelationshipReader", "RelationshipStore", "RelationshipWriter", + "ReplaceBinding", "ResourceSearchRequest", "access_control_for_mode", ) diff --git a/src/powercontext/server/web.py b/src/powercontext/server/web.py index e7a3825bf..54b186bb5 100644 --- a/src/powercontext/server/web.py +++ b/src/powercontext/server/web.py @@ -287,6 +287,11 @@ async def unpublish( request: DashboardSkillUnpublishRequest, http_request: Request, ) -> DashboardSkillProjection | JSONResponse: + await _authorize_dashboard_skill( + http_request, + request, + operation="dashboard_skill_projection_unpublish", + ) resolved = await _dashboard_managed_skill(http_request, request, self._scope_ids) if isinstance(resolved, JSONResponse): return resolved @@ -303,22 +308,28 @@ async def unpublish( 409, "skill_projection_conflict", "The Agent Skill publication changed or cannot be removed safely.", - details={"state": error.status.state.value, "reason": error.status.reason}, + details={ + "state": error.status.state.value, + "reason_code": _projection_reason_code(error.status.state), + }, ) - except (OSError, UnicodeError, ValueError) as error: + except (OSError, UnicodeError, ValueError): return _web_error( 422, "skill_projection_failed", "The approved managed Skill could not be unpublished from the configured Agent target.", - details={"reason": str(error)}, + details={"reason_code": "projection_failed"}, ) # The publication removal already succeeded; keep registry bookkeeping best-effort for # the same reason as the publish path above. try: await application.external_skills.for_scope(request.scope_id).scan() - except Exception as error: + except Exception: log_safely( - logger, logging.WARNING, "PowerContext external Skill scan failed after unpublication", exc_info=error + logger, + logging.WARNING, + "PowerContext external Skill scan failed after unpublication", + extra={"error_code": "external_skill_scan_failed"}, ) return await _skill_projection_response(application, request.scope_id, skill, self._targets) @@ -425,6 +436,12 @@ async def list_managed_skills( request: DashboardSkillLibraryRequest, http_request: Request, ) -> list[DashboardManagedSkill] | JSONResponse: + await _authorize_dashboard_scope( + http_request, + request.scope_id, + AccessAction.SCOPE_READ, + operation="dashboard_skills_library", + ) if request.scope_id not in dashboard_scope_ids: return _web_error(404, "dashboard_scope_not_found", "The Dashboard scope was not found.") application = http_request.app.state.application @@ -466,6 +483,13 @@ async def update_skill_lifecycle( request: DashboardSkillLifecycleRequest, http_request: Request, ) -> ArtifactGovernance | JSONResponse: + await _authorize_dashboard_skill_identity( + http_request, + scope_id=request.scope_id, + artifact_id=request.artifact_id, + action=AccessAction.ARTIFACT_WRITE, + operation="dashboard_skill_lifecycle", + ) if request.scope_id not in dashboard_scope_ids: return _web_error(404, "dashboard_scope_not_found", "The Dashboard scope was not found.") application = http_request.app.state.application @@ -478,18 +502,24 @@ async def update_skill_lifecycle( request.lifecycle_state, request.replacement_artifact_id, ) - except ValueError as error: + except ValueError: return _web_error( 422, "skill_lifecycle_invalid", "The requested Skill lifecycle transition is not allowed.", - details={"reason": str(error)}, + details={"reason_code": "invalid_transition"}, ) async def get_package_manifest( request: DashboardSkillPackageRequest, http_request: Request, ) -> SkillPackageManifest | JSONResponse: + await _authorize_dashboard_scope( + http_request, + request.scope_id, + AccessAction.SCOPE_REVIEW, + operation="dashboard_skill_package_manifest", + ) resolved = await _dashboard_package(http_request, request, dashboard_scope_ids) if isinstance(resolved, JSONResponse): return resolved @@ -499,6 +529,12 @@ async def preview_package_file( request: DashboardSkillPackageFileRequest, http_request: Request, ) -> DashboardSkillPackageFilePreview | JSONResponse: + await _authorize_dashboard_scope( + http_request, + request.scope_id, + AccessAction.SCOPE_REVIEW, + operation="dashboard_skill_package_preview", + ) resolved = await _dashboard_package(http_request, request, dashboard_scope_ids) if isinstance(resolved, JSONResponse): return resolved @@ -672,6 +708,23 @@ async def _authorize_dashboard_skill( selection: DashboardSkillProjectionRequest, *, operation: str, +) -> None: + await _authorize_dashboard_skill_identity( + request, + scope_id=selection.scope_id, + artifact_id=selection.artifact.artifact_id, + action=AccessAction.ARTIFACT_READ, + operation=operation, + ) + + +async def _authorize_dashboard_skill_identity( + request: Request, + *, + scope_id: str, + artifact_id: str, + action: AccessAction, + operation: str, ) -> None: access = access_control_for_mode( request.app.state.access_control, @@ -679,19 +732,48 @@ async def _authorize_dashboard_skill( ) if access is None: return + principal = current_principal() + context = _dashboard_access_context(operation) + await access.bootstrap_static_scope(principal, scope_id, context=context) resource = ResourceRef.artifact( - selection.scope_id, - family=selection.artifact.family, - artifact_id=selection.artifact.artifact_id, + scope_id, + family="skill", + artifact_id=artifact_id, ) checks = [ (AccessAction.SERVER_OBSERVE, ResourceRef.server(access.deployment_id)), - (AccessAction.ARTIFACT_READ, resource), + (action, resource), ] await access.require_all( - current_principal(), + principal, checks, - context=_dashboard_access_context(operation), + context=context, + ) + + +async def _authorize_dashboard_scope( + request: Request, + scope_id: str, + action: AccessAction, + *, + operation: str, +) -> None: + access = access_control_for_mode( + request.app.state.access_control, + mode=request.app.state.access_mode, + ) + if access is None: + return + principal = current_principal() + context = _dashboard_access_context(operation) + await access.bootstrap_static_scope(principal, scope_id, context=context) + await access.require_all( + principal, + ( + (AccessAction.SERVER_OBSERVE, ResourceRef.server(access.deployment_id)), + (action, ResourceRef.scope(scope_id)), + ), + context=context, ) @@ -721,8 +803,13 @@ async def _skill_projection_response( registrations = await application.external_skills.for_scope(scope_id).list( ListExternalSkillsRequest(include_unavailable=True) ) - except Exception as error: - log_safely(logger, logging.WARNING, "PowerContext external Skill registry discovery failed", exc_info=error) + except Exception: + log_safely( + logger, + logging.WARNING, + "PowerContext external Skill registry discovery failed", + extra={"error_code": "external_skill_registry_discovery_failed"}, + ) registrations = () targets = [] package = await application.skill.for_scope(scope_id).package(skill.as_ref()) diff --git a/tests/builtin/persistence/test_connectors.py b/tests/builtin/persistence/test_connectors.py new file mode 100644 index 000000000..1553bc2c4 --- /dev/null +++ b/tests/builtin/persistence/test_connectors.py @@ -0,0 +1,71 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import cast + +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.connectors import ConnectorCheckpointRepository +from powercontext.sources import ConnectorBinding + + +def test_connector_checkpoint_initial_creation_avoids_savepoints_on_mysql_compatible_connections() -> None: + """OceanBase can discard this write-path SAVEPOINT before SQLAlchemy releases it.""" + + async def scenario() -> None: + class EmptyConnectorCheckpointRepository(ConnectorCheckpointRepository): + async def _find_row( + self, + connection: AsyncConnection, + binding: ConnectorBinding, + *, + for_update: bool, + ) -> None: + del connection, binding, for_update + + class MySQLCompatibleConnection: + dialect = SimpleNamespace(name="mysql") + + def __init__(self) -> None: + self.executions = 0 + + async def execute(self, _statement: object) -> None: + self.executions += 1 + + def begin_nested(self) -> None: + raise AssertionError + + binding = ConnectorBinding( + scope_id="scope-a", + binding_id="connector-a", + connector_name="test-connector", + connector_version="1", + ) + connection = MySQLCompatibleConnection() + repository = EmptyConnectorCheckpointRepository() + + await repository.save( + cast(AsyncConnection, connection), + binding, + {"cursor": 1}, + expected=None, + ) + + assert connection.executions == 1 + + asyncio.run(scenario()) diff --git a/tests/builtin/persistence/test_cursors.py b/tests/builtin/persistence/test_cursors.py index ce3c248ce..bca25df0b 100644 --- a/tests/builtin/persistence/test_cursors.py +++ b/tests/builtin/persistence/test_cursors.py @@ -141,8 +141,9 @@ class MySQLCompatibleConnection: def __init__(self) -> None: self.executions = 0 - async def execute(self, _statement: object) -> None: + async def execute(self, _statement: object) -> SimpleNamespace: self.executions += 1 + return SimpleNamespace(rowcount=1) def begin_nested(self) -> None: raise AssertionError diff --git a/tests/e2e/real_experience_skill/test_access_control.py b/tests/e2e/real_experience_skill/test_access_control.py index 5cfef98b0..84e4cdc5a 100644 --- a/tests/e2e/real_experience_skill/test_access_control.py +++ b/tests/e2e/real_experience_skill/test_access_control.py @@ -32,21 +32,25 @@ from powercontext.server.authz import ( AccessAction, AccessAuditContext, + AccessBinding, + AccessConflictError, AccessDeniedError, AccessResourceType, AccessRole, + BindingReplacement, CreateBinding, PrincipalRef, + ReplaceBinding, ResourceRef, ) from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.authz.repository import ( ACCESS_AUDIT_EVENTS_TABLE, + ACCESS_BINDING_LEASES_TABLE, ACCESS_BINDINGS_TABLE, ACCESS_CANDIDATE_OWNERS_TABLE, ACCESS_IDEMPOTENCY_TABLE, ACCESS_OWNERS_TABLE, - ACCESS_RECEIVER_LEASES_TABLE, ) from powercontext.server.settings import ServerSettings @@ -64,6 +68,11 @@ def test_configured_database_persists_exact_skill_grant_and_revocation(pytestcon deployment_id = f"configured-real-access-{suffix}" admin = PrincipalRef(type="service", id=f"{deployment_id}:admin") receiver = PrincipalRef(type="user", id=f"{deployment_id}:receiver") + competing_receiver = PrincipalRef(type="user", id=f"{deployment_id}:competing-receiver") + replacement_receivers = ( + PrincipalRef(type="user", id=f"{deployment_id}:replacement-a"), + PrincipalRef(type="user", id=f"{deployment_id}:replacement-b"), + ) async def scenario() -> None: exact = ResourceRef.artifact( @@ -76,7 +85,13 @@ async def scenario() -> None: family="skill", artifact_id=f"other-skill-{suffix}", ) + handoff = ResourceRef.artifact( + scope_id, + family="handoff", + artifact_id=f"handoff-{suffix}", + ) context = AccessAuditContext(transport="test", operation="configured-real-access") + persisted_receiver: PrincipalRef | None = None try: async with open_builtin_access_control( settings.database, @@ -95,6 +110,12 @@ async def scenario() -> None: idempotency_key=f"owner-other-skill-{suffix}", context=context, ) + await access.establish_artifact_owner( + handoff, + admin, + idempotency_key=f"owner-handoff-{suffix}", + context=context, + ) binding = await access.create_binding( admin, CreateBinding( @@ -140,12 +161,91 @@ async def scenario() -> None: context=context, ) ).total == 0 + + async def create_receiver(subject: PrincipalRef) -> AccessBinding: + return await access.create_binding( + admin, + CreateBinding( + subject=subject, + resource=handoff, + role=AccessRole.HANDOFF_RECEIVER, + idempotency_key=f"handoff-receiver-{subject.id}", + ), + context=context, + ) + + create_results = await asyncio.gather( + create_receiver(receiver), + create_receiver(competing_receiver), + return_exceptions=True, + ) + created = [result for result in create_results if isinstance(result, AccessBinding)] + create_conflicts = [result for result in create_results if isinstance(result, AccessConflictError)] + assert len(created) == 1 + assert len(create_conflicts) == 1 + + original = created[0] + + async def replace_receiver(subject: PrincipalRef) -> BindingReplacement: + return await access.replace_binding( + admin, + ReplaceBinding( + binding_id=original.binding_id, + expected_version=original.version, + subject=subject, + idempotency_key=f"replace-handoff-receiver-{subject.id}", + ), + context=context, + ) + + replace_results = await asyncio.gather( + *(replace_receiver(subject) for subject in replacement_receivers), + return_exceptions=True, + ) + replacements = [result for result in replace_results if isinstance(result, BindingReplacement)] + replace_conflicts = [result for result in replace_results if isinstance(result, AccessConflictError)] + assert len(replacements) == 1 + assert len(replace_conflicts) == 1 + replacement = replacements[0] + assert ( + await access.replace_binding( + admin, + ReplaceBinding( + binding_id=original.binding_id, + expected_version=original.version, + subject=replacement.current.subject, + idempotency_key=replacement.current.idempotency_key, + ), + context=context, + ) + ) == replacement + assert isinstance(replacement.current.subject, PrincipalRef) + persisted_receiver = replacement.current.subject + + assert persisted_receiver is not None + async with open_builtin_access_control( + settings.database, + deployment_id=deployment_id, + ) as reopened: + assert ( + await reopened.require( + persisted_receiver, + AccessAction.HANDOFF_ACKNOWLEDGE, + handoff, + context=context, + ) + ).allowed finally: remaining = await _purge_scope( settings.database, scope_id=scope_id, deployment_id=deployment_id, - actor_ids=(admin.id, receiver.id), + actor_ids=( + admin.id, + receiver.id, + competing_receiver.id, + *(principal.id for principal in replacement_receivers), + ), ) assert remaining == 0 @@ -169,8 +269,8 @@ async def _purge_scope( ) ) await connection.execute( - delete(ACCESS_RECEIVER_LEASES_TABLE).where( - ACCESS_RECEIVER_LEASES_TABLE.c.binding_id.in_( + delete(ACCESS_BINDING_LEASES_TABLE).where( + ACCESS_BINDING_LEASES_TABLE.c.binding_id.in_( select(ACCESS_BINDINGS_TABLE.c.binding_id).where( or_( ACCESS_BINDINGS_TABLE.c.scope_id == scope_id, diff --git a/tests/test_access_control.py b/tests/test_access_control.py index f71702471..0da0cbf14 100644 --- a/tests/test_access_control.py +++ b/tests/test_access_control.py @@ -16,11 +16,14 @@ import asyncio from datetime import UTC, datetime +from hashlib import sha256 import pytest +from sqlalchemy import insert from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.server.authz import ( + ROLE_CARDINALITIES, AccessAction, AccessAuditContext, AccessBinding, @@ -32,17 +35,22 @@ AccessProviderCapabilities, AccessResourceType, AccessRole, + AccessRoleCardinality, AccessUnavailableError, BuiltinAuthorizationProvider, CreateBinding, GroupRef, MemoryEntrySelector, PrincipalRef, - ReassignHandoffReceiver, + ReplaceBinding, ResourceRef, ) from powercontext.server.authz.composition import open_builtin_access_control -from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository +from powercontext.server.authz.repository import ( + ACCESS_BINDING_LEASES_TABLE, + ACCESS_TABLES, + RelationalAccessRepository, +) ADMIN = PrincipalRef(type="service", id="admin", description="deployment administrator") ALICE = PrincipalRef(type="user", id="alice", description="artifact owner") @@ -168,7 +176,7 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_only_one_handoff_receiver_and_reassignment_is_atomic() -> None: +def test_singleton_binding_replacement_is_atomic() -> None: async def scenario() -> None: async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="handoff") @@ -185,7 +193,7 @@ async def scenario() -> None: ), context=AUDIT, ) - with pytest.raises(AccessConflictError, match="receiver"): + with pytest.raises(AccessConflictError, match="maximum active Bindings"): await service.create_binding( ADMIN, CreateBinding( @@ -197,25 +205,26 @@ async def scenario() -> None: context=AUDIT, ) - changed = await service.reassign_handoff_receiver( - ADMIN, - ReassignHandoffReceiver( - binding_id=first.binding_id, - expected_version=1, - subject=ALICE, - idempotency_key="reassign-to-alice", - ), - context=AUDIT, + request = ReplaceBinding( + binding_id=first.binding_id, + expected_version=1, + subject=ALICE, + idempotency_key="reassign-to-alice", ) - assert changed.revoked_binding.state is AccessBindingState.REVOKED - assert changed.created_binding.subject == ALICE + changed = await service.replace_binding(ADMIN, request, context=AUDIT) + replayed = await service.replace_binding(ADMIN, request, context=AUDIT) + assert changed.previous.state is AccessBindingState.REVOKED + assert changed.current.subject == ALICE + assert changed.current.resource == handoff + assert changed.current.role is AccessRole.HANDOFF_RECEIVER + assert replayed == changed with pytest.raises(AccessDeniedError): await service.require(BOB, AccessAction.HANDOFF_ACKNOWLEDGE, handoff, context=AUDIT) assert (await service.require(ALICE, AccessAction.HANDOFF_ACKNOWLEDGE, handoff, context=AUDIT)).allowed await service.revoke_binding( ADMIN, - changed.created_binding.binding_id, + changed.current.binding_id, expected_version=1, idempotency_key="revoke-alice-receiver", context=AUDIT, @@ -235,6 +244,88 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_singleton_binding_fails_closed_when_a_claimed_lease_binding_is_not_visible() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_server_admin(repository) + service = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + ) + handoff = ResourceRef.artifact("scope-a", family="handoff", artifact_id="concurrent-handoff") + await service.establish_artifact_owner( + handoff, + ALICE, + idempotency_key="owner-concurrent-handoff", + context=AUDIT, + ) + async with profile.database.transaction() as connection: + await connection.execute( + insert(ACCESS_BINDING_LEASES_TABLE).values( + resource_key_hash=sha256(handoff.key.encode()).hexdigest(), + role=AccessRole.HANDOFF_RECEIVER.value, + binding_id="not-yet-visible-binding", + ) + ) + + with pytest.raises(AccessConflictError, match="maximum active Bindings"): + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=handoff, + role=AccessRole.HANDOFF_RECEIVER, + idempotency_key="concurrent-handoff-receiver", + ), + context=AUDIT, + ) + + asyncio.run(scenario()) + + +def test_access_role_cardinalities_are_explicit() -> None: + assert ROLE_CARDINALITIES[AccessRole.HANDOFF_RECEIVER] is AccessRoleCardinality.ONE_PER_RESOURCE + assert ROLE_CARDINALITIES[AccessRole.ARTIFACT_OWNER] is AccessRoleCardinality.ONE_PER_RESOURCE + assert ROLE_CARDINALITIES[AccessRole.ARTIFACT_VIEWER] is AccessRoleCardinality.MANY_PER_RESOURCE + + +def test_many_binding_can_be_replaced_without_domain_specific_behavior() -> None: + async def scenario() -> None: + async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: + skill = ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a") + await service.establish_artifact_owner(skill, ALICE, idempotency_key="owner-replaced-skill", context=AUDIT) + original = await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=skill, + role=AccessRole.ARTIFACT_VIEWER, + idempotency_key="viewer-bob", + ), + context=AUDIT, + ) + + changed = await service.replace_binding( + ADMIN, + ReplaceBinding( + binding_id=original.binding_id, + expected_version=1, + subject=ALICE, + idempotency_key="replace-viewer-with-alice", + ), + context=AUDIT, + ) + + assert changed.previous.state is AccessBindingState.REVOKED + assert changed.current.subject == ALICE + assert changed.current.resource == skill + assert changed.current.role is AccessRole.ARTIFACT_VIEWER + + asyncio.run(scenario()) + + def test_resource_cursor_is_bound_to_policy_revision() -> None: async def scenario() -> None: async with open_builtin_access_control(SQLiteConfig(), bootstrap_administrators=(ADMIN,)) as service: diff --git a/tests/test_access_http.py b/tests/test_access_http.py index fc6ec7141..b51f9f526 100644 --- a/tests/test_access_http.py +++ b/tests/test_access_http.py @@ -226,8 +226,13 @@ async def scenario() -> None: "/v1/access/check", headers=_auth("delegated-token"), json={ - "action": "scope.read", - "resource": {"type": "scope", "scope_id": "scope-a"}, + "match": "all", + "requirements": [ + { + "action": "scope.read", + "resource": {"type": "scope", "scope_id": "scope-a"}, + } + ], }, ) assert checked.status_code == 200 @@ -255,6 +260,221 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_compound_access_check_supports_all_and_any_without_a_batch_route() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) + service = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + ) + app = _app(service, principal=ADMIN, token="admin-token") # noqa: S106 - test credential. + requirements = [ + { + "action": "server.admin", + "resource": {"type": "server", "deployment_id": "powercontext"}, + }, + { + "action": "scope.read", + "resource": {"type": "scope", "scope_id": "scope-a"}, + }, + ] + async with _client(app) as client: + all_required = await client.post( + "/v1/access/check", + headers=_auth("admin-token"), + json={"match": "all", "requirements": requirements}, + ) + any_required = await client.post( + "/v1/access/check", + headers=_auth("admin-token"), + json={"match": "any", "requirements": requirements}, + ) + removed_batch = await client.post( + "/v1/access/check-batch", + headers=_auth("admin-token"), + json={"checks": []}, + ) + + assert all_required.status_code == 200 + assert all_required.json()["allowed"] is False + assert [decision["allowed"] for decision in all_required.json()["decisions"]] == [True, False] + assert any_required.status_code == 200 + assert any_required.json()["allowed"] is True + assert any_required.json()["decisions"] == all_required.json()["decisions"] + assert removed_batch.status_code == 404 + + asyncio.run(scenario()) + + +def test_access_binding_replace_is_generic_and_atomic() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) + service = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + ) + original = await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + idempotency_key="scope-viewer-bob", + ), + context=AUDIT, + ) + app = _app(service, principal=ADMIN, token="admin-token") # noqa: S106 - test credential. + async with _client(app) as client: + response = await client.post( + "/v1/access/bindings/replace", + headers=_auth("admin-token"), + json={ + "binding_id": original.binding_id, + "expected_version": 1, + "replacement": { + "subject": {"type": "user", "id": "alice"}, + "reason": "transfer scope visibility", + "expires_at": None, + }, + "idempotency_key": "replace-scope-viewer-with-alice", + }, + ) + removed_special_case = await client.post( + "/v1/access/bindings/reassign-handoff-receiver", + headers=_auth("admin-token"), + json={}, + ) + + assert response.status_code == 200, response.json() + assert response.json()["previous"]["state"] == "revoked" + assert response.json()["previous"]["version"] == 2 + assert response.json()["current"]["subject"]["id"] == "alice" + assert response.json()["current"]["resource"] == { + "type": "scope", + "scope_id": "scope-a", + } + assert response.json()["current"]["role"] == "scope.viewer" + assert removed_special_case.status_code == 404 + + asyncio.run(scenario()) + + +def test_connector_checkpoint_routes_enforce_the_nested_scope_boundary() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) + service = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + ) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_CONTRIBUTOR, + idempotency_key="connector-worker-scope-a", + ), + context=AUDIT, + ) + binding = { + "scope_id": "scope-a", + "binding_id": "connector-a", + "connector_name": "test-connector", + "connector_version": "1", + } + requests = ( + ("/v1/connector-checkpoints/get", {"binding": binding}), + ( + "/v1/connector-checkpoints/commit", + {"binding": binding, "expected": None, "checkpoint": {"cursor": 1}}, + ), + ) + + contributor_app = _app(service, principal=BOB, token="bob-token") # noqa: S106 - test credential. + denied_app = _app(service, principal=ALICE, token="alice-token") # noqa: S106 - test credential. + async with _client(contributor_app) as contributor, _client(denied_app) as denied: + for path, payload in requests: + authorized = await contributor.post(path, headers=_auth("bob-token"), json=payload) + unauthorized = await denied.post(path, headers=_auth("alice-token"), json=payload) + assert authorized.status_code == 503 + assert authorized.json()["error"]["code"] == "runtime_not_ready" + assert unauthorized.status_code == 403 + + asyncio.run(scenario()) + + +def test_standard_skill_lifecycle_routes_enforce_access_before_runtime() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) + service = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + ) + await service.establish_artifact_owner( + ResourceRef.artifact("scope-a", family="skill", artifact_id="skill-a"), + ADMIN, + idempotency_key="owner-skill-a-lifecycle", + context=AUDIT, + ) + app = _app(service, principal=BOB, token="bob-token") # noqa: S106 - test credential. + artifact = {"family": "skill", "artifact_id": "skill-a", "revision": 1} + requests = ( + ("/v1/skill/library", {"scope_id": "scope-a"}), + ( + "/v1/skill/lifecycle", + { + "scope_id": "scope-a", + "artifact_id": "skill-a", + "expected_generation": 0, + "lifecycle_state": "active", + }, + ), + ( + "/v1/skill/usage", + { + "scope_id": "scope-a", + "observation_id": "usage-a", + "skill_ref": artifact, + "package_digest": f"sha256:{'a' * 64}", + "target_id": "codex-project", + "selected": True, + "invoked": "true", + "validation": "passed", + "outcome": "success", + }, + ), + ("/v1/skill/remote/targets", {"scope_id": "scope-a"}), + ( + "/v1/skill/remote/publication/publish", + { + "scope_id": "scope-a", + "target_id": "target-a", + "artifact": artifact, + "expected_generation": 0, + }, + ), + ) + async with _client(app) as client: + for path, payload in requests: + response = await client.post(path, headers=_auth("bob-token"), json=payload) + assert response.status_code == 403, (path, response.json()) + assert response.json()["error"]["code"] == "forbidden" + + asyncio.run(scenario()) + + class _HandoffShareability: def for_scope(self, scope_id: str) -> Self: del scope_id @@ -372,6 +592,11 @@ async def scenario() -> None: "artifact.viewer", } assert all(item["artifact_families"] == ["skill"] for item in roles.json()["items"]) + role_cardinalities = {item["role"]: item["cardinality"] for item in roles.json()["items"]} + assert role_cardinalities == { + "artifact.owner": "one_per_resource", + "artifact.viewer": "many_per_resource", + } created = await admin.post( "/v1/access/bindings/create", headers=_auth("admin-token"), @@ -402,10 +627,14 @@ async def scenario() -> None: decision = await bob.post( "/v1/access/check", headers=_auth("bob-token"), - json={"action": "handoff.acknowledge", "resource": exact}, + json={ + "match": "all", + "requirements": [{"action": "handoff.acknowledge", "resource": exact}], + }, ) assert decision.status_code == 200 assert decision.json()["allowed"] is True + assert decision.json()["decisions"][0]["allowed"] is True resources = await bob.post( "/v1/access/resources/list", @@ -701,6 +930,16 @@ async def scenario() -> None: ), context=AUDIT, ) + await service.create_binding( + ADMIN, + CreateBinding( + subject=BOB, + resource=ResourceRef.server(), + role=AccessRole.SERVER_OBSERVER, + idempotency_key="bob-dashboard-observer", + ), + context=AUDIT, + ) app = _app(service, principal=BOB, token="bob-token") # noqa: S106 - test credential. mount_web_ui( app, @@ -710,9 +949,22 @@ async def scenario() -> None: ) async with _client(app) as client: response = await client.get("/dashboard/scopes", headers=_auth("bob-token")) + visible_library = await client.post( + "/dashboard/skills/library", + headers=_auth("bob-token"), + json={"scope_id": "scope-visible"}, + ) + hidden_library = await client.post( + "/dashboard/skills/library", + headers=_auth("bob-token"), + json={"scope_id": "scope-hidden"}, + ) assert response.status_code == 200 assert response.json() == [{"scope_id": "scope-visible", "display_name": "Visible"}] assert "scope-hidden" not in response.text + assert visible_library.status_code == 503 + assert visible_library.json()["error"]["code"] == "runtime_not_ready" + assert hidden_library.status_code == 403 asyncio.run(scenario()) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 4ae81bdf8..4f9ddce6b 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -161,6 +161,48 @@ def test_every_access_protected_operation_declares_the_unavailable_response() -> assert operation["responses"]["503"] == {"$ref": "#/components/responses/Unavailable"} +def test_source_ingestion_operations_preserve_the_access_boundary() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + expected = { + "/v1/source-definitions/register": { + "action": "server.admin", + "resource": {"type": "server"}, + }, + "/v1/connector-checkpoints/get": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "binding.scope_id"}, + }, + "/v1/source-observations": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "/v1/connector-checkpoints/commit": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "binding.scope_id"}, + }, + } + + for path, requirement in expected.items(): + operation = contract["paths"][path]["post"] + assert operation["x-powercontext-access"] == requirement + + +def test_access_contract_uses_compound_checks_and_generic_binding_replacement() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + paths = contract["paths"] + schemas = contract["components"]["schemas"] + + assert "/v1/access/check-batch" not in paths + assert "/v1/access/bindings/reassign-handoff-receiver" not in paths + assert paths["/v1/access/check"]["post"]["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessCheckResponse" + } + assert schemas["AccessCheckRequest"]["required"] == ["match", "requirements"] + assert schemas["AccessRequirementMatch"]["enum"] == ["all", "any"] + assert paths["/v1/access/bindings/replace"]["post"]["operationId"] == "replace_access_binding" + assert schemas["AccessRoleCardinality"]["enum"] == ["many_per_resource", "one_per_resource"] + + def test_capabilities_report_semantics_without_runtime_tuning_values() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) schemas = contract["components"]["schemas"] diff --git a/tests/test_client.py b/tests/test_client.py index eb92af891..5ac7816fb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -32,11 +32,17 @@ from powercontext.http import ( AccessAction, AccessArtifactIdentity, + AccessBindingReplacementInput, AccessCheckRequest, + AccessCheckRequirement, + AccessPrincipal, + AccessRequirementMatch, AccessResource, + AccessSubject, ArtifactAccessResource, CaptureContentSourceRequest, GetHandoffReportRequest, + ReplaceAccessBindingRequest, ) @@ -48,28 +54,94 @@ def respond(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response( 200, - json={"allowed": True, "reason_code": "role-binding"}, + json={ + "allowed": True, + "decisions": [{"allowed": True, "reason_code": "role-binding"}], + }, ) async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: client = PowerContextClient("https://memory.example", http_client=http_client) decision = await client.check_access( AccessCheckRequest( - action=AccessAction.ARTIFACT_READ, - resource=AccessResource( - root=ArtifactAccessResource( - type="artifact", - scope_id="scope-a", - identity=AccessArtifactIdentity(family="handoff", artifact_id="handoff-a"), - selector=None, + match=AccessRequirementMatch.ALL, + requirements=[ + AccessCheckRequirement( + action=AccessAction.ARTIFACT_READ, + resource=AccessResource( + root=ArtifactAccessResource( + type="artifact", + scope_id="scope-a", + identity=AccessArtifactIdentity(family="handoff", artifact_id="handoff-a"), + selector=None, + ) + ), ) - ), + ], ) ) assert decision.allowed is True assert requests[0].url.path == "/v1/access/check" - assert json.loads(requests[0].content)["resource"]["identity"]["artifact_id"] == "handoff-a" + payload = json.loads(requests[0].content) + assert payload["requirements"][0]["resource"]["identity"]["artifact_id"] == "handoff-a" + + asyncio.run(scenario()) + + +def test_client_exposes_typed_access_binding_replacement() -> None: + async def scenario() -> None: + requests: list[httpx.Request] = [] + previous = { + "binding_id": "binding-bob", + "subject": {"type": "user", "id": "bob", "description": None}, + "resource": {"type": "scope", "scope_id": "scope-a"}, + "role": "scope.viewer", + "granted_by": {"type": "service", "id": "admin", "description": None}, + "reason": None, + "created_at": "2026-09-03T00:00:00Z", + "expires_at": None, + "state": "revoked", + "version": 2, + "policy_revision": "2", + "idempotency_key": "create-bob", + "revoked_at": "2026-09-03T01:00:00Z", + "revoked_by": {"type": "service", "id": "admin", "description": None}, + } + current = previous | { + "binding_id": "binding-alice", + "subject": {"type": "user", "id": "alice", "description": None}, + "reason": "transfer", + "created_at": "2026-09-03T01:00:00Z", + "state": "active", + "version": 1, + "idempotency_key": "replace-with-alice", + "revoked_at": None, + "revoked_by": None, + } + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"previous": previous, "current": current}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client = PowerContextClient("https://memory.example", http_client=http_client) + replacement = await client.replace_access_binding( + ReplaceAccessBindingRequest( + binding_id="binding-bob", + expected_version=1, + replacement=AccessBindingReplacementInput( + subject=AccessSubject(root=AccessPrincipal(type="user", id="alice")), + reason="transfer", + ), + idempotency_key="replace-with-alice", + ) + ) + + assert replacement.previous.state.value == "revoked" + assert replacement.current.subject.root.id == "alice" + assert requests[0].url.path == "/v1/access/bindings/replace" + assert json.loads(requests[0].content)["replacement"]["subject"]["id"] == "alice" asyncio.run(scenario()) From 8a99fecc4448fd07cca8ef573dbcf2a9905fe524 Mon Sep 17 00:00:00 2001 From: Teingi Date: Thu, 3 Sep 2026 21:20:16 +0800 Subject: [PATCH 14/22] fix(access): refine sharing and configuration --- .env.example | 7 +- docker/README.md | 3 +- .../remote-access-implementation.md | 2 - docs/en/docs/how-to/configure-claude-code.md | 2 - docs/en/docs/how-to/configure-codex.md | 2 - docs/en/docs/how-to/configure-dsh.md | 2 - docs/en/docs/how-to/configure-openclaw.md | 2 - docs/en/docs/how-to/configure-pi.md | 2 - docs/en/docs/how-to/configure-workbuddy.md | 2 - docs/en/docs/how-to/deploy-server.md | 4 - docs/en/docs/reference/configuration.md | 30 +- docs/en/docs/reference/http-api.md | 8 +- docs/en/rfcs/1396_handoff_access_control.md | 468 +++++++++--------- .../remote-access-implementation.md | 2 - docs/zh/docs/how-to/configure-claude-code.md | 2 - docs/zh/docs/how-to/configure-codex.md | 2 - docs/zh/docs/how-to/configure-dsh.md | 2 - docs/zh/docs/how-to/configure-openclaw.md | 2 - docs/zh/docs/how-to/configure-pi.md | 2 - docs/zh/docs/how-to/configure-workbuddy.md | 2 - docs/zh/docs/how-to/deploy-server.md | 4 - docs/zh/docs/reference/configuration.md | 25 +- docs/zh/docs/reference/http-api.md | 6 +- docs/zh/rfcs/1396_handoff_access_control.md | 438 ++++++++-------- .../powercontext/openapi/powercontext.yaml | 3 + .../langgraph/examples/_local_server.py | 6 +- integrations/openclaw/README.md | 2 - openapi/powercontext.yaml | 3 + src/powercontext/cli/config.py | 1 - src/powercontext/http/_generated/schema.py | 14 + src/powercontext/server/app.py | 55 +- src/powercontext/server/authz/profiles.py | 4 +- src/powercontext/server/cli.py | 3 + src/powercontext/server/factory.py | 62 ++- src/powercontext/server/settings.py | 56 +-- tests/e2e/real_experience_skill/harness.py | 2 +- .../test_access_control.py | 2 +- tests/e2e/test_access_control_http.py | 27 +- tests/e2e/test_claude_code_service_chain.py | 10 +- tests/e2e/test_codex_service_chain.py | 6 +- tests/e2e/test_langgraph_chain.py | 6 +- tests/e2e/test_statistics_flow.py | 5 +- tests/e2e/test_workbuddy_service_chain.py | 6 +- tests/test_access_http.py | 71 ++- tests/test_api_contract.py | 6 +- tests/test_cli.py | 7 +- tests/test_dashboard.py | 22 +- tests/test_server.py | 49 +- tests/test_transport.py | 9 +- 49 files changed, 710 insertions(+), 748 deletions(-) diff --git a/.env.example b/.env.example index 271a1a0c4..257347d48 100644 --- a/.env.example +++ b/.env.example @@ -23,13 +23,12 @@ POWERCONTEXT_SERVER_MCP_ENABLED=true POWERCONTEXT_SERVER_MCP_PATH=/mcp # Access Control -------------------------------------------------------------- -# ACCESS_MODE is the only security switch. In enforced mode, select both Providers. +# ACCESS_MODE is the only supported Access Control switch. POWERCONTEXT_SERVER_ACCESS_MODE=disabled -# POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer +# Legacy static Bearer compatibility. An injected Authentication Provider takes precedence. +# POWERCONTEXT_SERVER_AUTH_ENABLED=true # POWERCONTEXT_SERVER_AUTH_TOKEN=replace-me -# POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=powercontext -POWERCONTEXT_SERVER_ACCESS_STATIC_PRESET=true # Multi-user deployments with scheduled jobs must bind an explicit service Principal. # POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_ID=service:scheduled-processing # POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION=Scheduled processing diff --git a/docker/README.md b/docker/README.md index a3db699b9..38b0dd0eb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -42,8 +42,7 @@ port is reachable, and its network namespace is the controlled boundary that opt it by default and the `docker run` above starts without extra configuration. Access is still governed by which ports you publish (`--publish`) and the surrounding network. For an exposed deployment, put the Server behind a TLS-terminating proxy and enable enforced Access Control with -`POWERCONTEXT_SERVER_ACCESS_MODE=enforced`, `POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer`, -`POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin`, and `POWERCONTEXT_SERVER_AUTH_TOKEN=...`; in enforced mode the +`POWERCONTEXT_SERVER_ACCESS_MODE=enforced` and `POWERCONTEXT_SERVER_AUTH_TOKEN=...`; in enforced mode the opt-in is no longer required. The `Build Docker image` GitHub workflow builds downloadable Linux amd64 and arm64 image archives for pull requests, diff --git a/docs/en/development/remote-access-implementation.md b/docs/en/development/remote-access-implementation.md index e12d44d7f..387ed1e0e 100644 --- a/docs/en/development/remote-access-implementation.md +++ b/docs/en/development/remote-access-implementation.md @@ -26,8 +26,6 @@ is otherwise controlled. ```bash # Recommended: authenticate the Server, then bind a routable address (put TLS in front in production). POWERCONTEXT_SERVER_ACCESS_MODE=enforced \ -POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer \ -POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin \ POWERCONTEXT_SERVER_AUTH_TOKEN="replace-with-a-strong-token" \ uv run powercontext server run --host 0.0.0.0 --port 8080 ``` diff --git a/docs/en/docs/how-to/configure-claude-code.md b/docs/en/docs/how-to/configure-claude-code.md index f70ce65a6..bc3f37acd 100644 --- a/docs/en/docs/how-to/configure-claude-code.md +++ b/docs/en/docs/how-to/configure-claude-code.md @@ -138,8 +138,6 @@ Start the Server with its token loaded from your secret manager: ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-codex.md b/docs/en/docs/how-to/configure-codex.md index a808bd54f..2c1ad9cf0 100644 --- a/docs/en/docs/how-to/configure-codex.md +++ b/docs/en/docs/how-to/configure-codex.md @@ -96,8 +96,6 @@ Load one token from your local secret manager, then start the Server with authen ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-dsh.md b/docs/en/docs/how-to/configure-dsh.md index 04423a581..1328c7f54 100644 --- a/docs/en/docs/how-to/configure-dsh.md +++ b/docs/en/docs/how-to/configure-dsh.md @@ -55,8 +55,6 @@ This adds inference latency to each prompt and is not the normal interactive set ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-openclaw.md b/docs/en/docs/how-to/configure-openclaw.md index b5ebddb6f..214c4326e 100644 --- a/docs/en/docs/how-to/configure-openclaw.md +++ b/docs/en/docs/how-to/configure-openclaw.md @@ -69,8 +69,6 @@ Start an authenticated Server from a protected environment: ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-pi.md b/docs/en/docs/how-to/configure-pi.md index cede680a6..2498499c9 100644 --- a/docs/en/docs/how-to/configure-pi.md +++ b/docs/en/docs/how-to/configure-pi.md @@ -81,8 +81,6 @@ Start an authenticated Server from a protected environment: ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/configure-workbuddy.md b/docs/en/docs/how-to/configure-workbuddy.md index 3d25699b4..caf59323e 100644 --- a/docs/en/docs/how-to/configure-workbuddy.md +++ b/docs/en/docs/how-to/configure-workbuddy.md @@ -248,8 +248,6 @@ authentication enabled: ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/en/docs/how-to/deploy-server.md b/docs/en/docs/how-to/deploy-server.md index af0752b3e..d5e4b9ca2 100644 --- a/docs/en/docs/how-to/deploy-server.md +++ b/docs/en/docs/how-to/deploy-server.md @@ -118,8 +118,6 @@ Load a strong token from your secret manager into the Server process environment ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_DEPLOYMENT_TOKEN" powercontext server run ``` @@ -132,8 +130,6 @@ docker run --rm \ --publish 127.0.0.1:8000:8000 \ --volume powercontext-data:/data \ --env POWERCONTEXT_SERVER_ACCESS_MODE=enforced \ - --env POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer \ - --env POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin \ --env POWERCONTEXT_SERVER_AUTH_TOKEN \ powercontext-server:local ``` diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index 2e7014bf0..cd9f05907 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -46,13 +46,9 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_WORKSPACE` | Server startup directory | Resolution root for local project Agent Skill folders | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | Enable Streamable HTTP MCP | | `POWERCONTEXT_SERVER_MCP_PATH` | `/mcp` | MCP path | -| `POWERCONTEXT_SERVER_AUTH_PROVIDER` | unset | Authentication Provider: `static-bearer`, `oidc`, or `trusted-header`; required in `enforced` mode | -| `POWERCONTEXT_SERVER_AUTH_TOKEN` | unset | Static bearer token; valid only with `AUTH_PROVIDER=static-bearer` | -| `POWERCONTEXT_SERVER_AUTH_PRINCIPAL_ID` | `server-token` | Deployment-wide unique Principal ID represented by the static token | -| `POWERCONTEXT_SERVER_AUTH_PRINCIPAL_DESCRIPTION` | `PowerContext static bearer` | Optional display-only description for the static Principal | -| `POWERCONTEXT_SERVER_ACCESS_MODE` | `disabled` | Sole security switch: `disabled` or `enforced` | -| `POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER` | unset | Authorization Provider: `builtin`, `casbin`, or `external`; required in `enforced` mode | -| `POWERCONTEXT_SERVER_ACCESS_STATIC_PRESET` | `true` | Materialize the explicit built-in roles needed by a single-Principal static deployment | +| `POWERCONTEXT_SERVER_AUTH_ENABLED` | `false` | Legacy static bearer switch; `true` maps to `ACCESS_MODE=enforced` and requires `AUTH_TOKEN` | +| `POWERCONTEXT_SERVER_AUTH_TOKEN` | unset | Legacy static bearer token; used as compatibility authentication and mapped to the built-in administrator when no Authentication Provider is injected | +| `POWERCONTEXT_SERVER_ACCESS_MODE` | `disabled` | The only supported Access switch: `disabled` or `enforced` | | `POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID` | `powercontext` | Stable deployment identity used by the `server` Access Resource | | `POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_ID` | unset | Explicit service Principal for scheduled jobs in a multi-user enforced deployment | | `POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION` | unset | Optional display-only description for the scheduled service Principal | @@ -109,17 +105,19 @@ when TLS is terminated upstream or the network is otherwise controlled, set `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` to opt in explicitly. Use TLS before exposing an authenticated Server over a network. -`POWERCONTEXT_SERVER_ACCESS_MODE` is the only switch. `disabled` rejects authentication and authorization Provider -configuration and bypasses authorization decisions inside the trusted local boundary. `enforced` requires both -`AUTH_PROVIDER` and `AUTHORIZATION_PROVIDER`; it enables one policy enforcement point and the configured Provider's -Binding and audit behavior. +`POWERCONTEXT_SERVER_ACCESS_MODE` is the only supported switch. `disabled` bypasses authorization decisions inside the +trusted local boundary. `enforced` enables one policy enforcement point plus Binding and audit behavior. Authorization +defaults to the built-in implementation and can be replaced through `create_server_app(access_control=...)`; +Authentication is supplied through `create_server_app(authentication_provider=...)`. Without an injected Authentication +Provider, the Server accepts only the legacy `AUTH_TOKEN` fallback and bootstraps its fixed `server-token` Principal as a +built-in administrator. Startup fails when neither is available. The old `AUTH_ENABLED=true` plus `AUTH_TOKEN` +configuration maps automatically to `ACCESS_MODE=enforced`. Authentication establishes a Principal; Access Control decides what that Principal may do. Principal IDs are deployment-wide unique, non-reused identifiers; `description` is display metadata and is not part of identity. The -built-in static token always represents one service Principal, so it cannot distinguish user A from user B. With the -built-in Authorization Provider, `ACCESS_STATIC_PRESET=true` materializes explicit Server and per-scope roles for -that Principal. Use `oidc` or `trusted-header` with a deployment-supplied Authentication Provider and an appropriate -Authorization Provider when different users or groups need different access. +built-in static token always represents one service Principal, so it cannot distinguish user A from user B. The +compatibility token materializes explicit Server and per-scope roles for that Principal. Inject the deployment +Authentication Provider and corresponding AccessControlService when different users or groups need different access. Scheduled Source processing and Experience incubation run as the fixed static Principal, or as the service Principal selected by `ACCESS_BACKGROUND_PRINCIPAL_ID`. That Principal must have `scope.contribute` for each processed scope; @@ -197,7 +195,7 @@ The non-loopback opt-in in this example is independent of the Receiver transport Server routes on this listener are reachable without the Server-wide bearer token. Prefer enabling authentication or terminating TLS in front of a loopback-bound Server whenever the deployment permits it. -When `AUTH_PROVIDER=static-bearer` is enforced, the HTML shells at `/`, `/skills`, `/reviews`, and `/handoff-reports`, plus +When compatibility static Bearer authentication is enforced, the HTML shells at `/`, `/skills`, `/reviews`, and `/handoff-reports`, plus their static assets, remain public so the browser can render the sign-in form. Data requests stay protected. Enter the Server token in that form; the browser keeps it only in the current tab's session storage. Disable both Dashboard and Handoff Report if even these sign-in pages must not be exposed. diff --git a/docs/en/docs/reference/http-api.md b/docs/en/docs/reference/http-api.md index f60755498..03eb0b76a 100644 --- a/docs/en/docs/reference/http-api.md +++ b/docs/en/docs/reference/http-api.md @@ -116,9 +116,11 @@ curl --fail \ "$POWERCONTEXT_URL/v1/access/bindings/create" ``` -The receiver can read and acknowledge the Handoff's history, current Revision, and future Revisions. It cannot use -scope-wide latest-Handoff discovery, read another Handoff, or access Memory in the parent scope unless a separate -scope role allows it. Use `/v1/access/me` to +The receiver can read and acknowledge the Handoff's history, current Revision, and future Revisions. Continue exposes +the citations in the selected Revision's immutable manifest and checks those cited resources without requiring a +second Binding for each citation. This manifest-scoped inspection does not authorize generic Source, Memory, or +Artifact endpoints: the receiver still cannot discover another Handoff or read the parent scope unless a separate +scope or Artifact role allows it. It may request `latest` only for the bound logical Handoff. Use `/v1/access/me` to verify which Principal the deployment established, `/v1/access/check` for one compound `all` or `any` requirement, and `/v1/access/resources/list` for a non-discovering list of already visible resources. Creation is idempotent per grantor and key; revocation uses `binding_id` plus `expected_version`. An atomic `/v1/access/bindings/replace` diff --git a/docs/en/rfcs/1396_handoff_access_control.md b/docs/en/rfcs/1396_handoff_access_control.md index 8b91cb162..4ba9a6d98 100644 --- a/docs/en/rfcs/1396_handoff_access_control.md +++ b/docs/en/rfcs/1396_handoff_access_control.md @@ -44,7 +44,8 @@ The first version defines three stable Resource Kinds: - `server`: the current PowerContext deployment; - `scope`: one exact Workstream scope; -- `artifact`: an exact Artifact Revision or Family-owned selector interpreted by an Artifact Family Access Profile. +- `artifact`: one logical Artifact identity or Family-owned logical selector interpreted by an Artifact Family Access + Profile. The `artifact` Resource Kind initially registers Artifact Family Access Profiles for `handoff`, `memory`, `experience`, `skill`, and `prompt`. `ArtifactReference.family` is the only Profile discriminator. A client does not @@ -53,13 +54,13 @@ submit a second content type that could conflict with it. User A can collaborate in two ways: - grant a Workstream role to a long-term collaborator; or -- grant B access to one exact persisted or approved resource. +- grant B access to one logical persisted or approved resource. -The second option is the least-privilege path in the first version. B may read the shared exact resource and perform -only the actions defined by its Artifact Family Access Profile. An exact Handoff receiver may inspect the evidence -explicitly cited by that Handoff through its resolver and leave a Receipt for the same Revision. An exact Memory, -Artifact, or Prompt grant does -not open the rest of the scope, the current head, later Revisions, search results, or resources referenced by lineage. +The second option is the least-privilege path in the first version. B may read existing and future versions of the +shared logical resource and perform only the actions defined by its Artifact Family Access Profile. A Handoff receiver +may inspect the evidence explicitly cited by the selected Revision through its resolver and leave a Receipt for that +Revision. A logical Memory, Artifact, or Prompt grant does not open the rest of the scope, aggregate search or list +results, another logical resource, or resources referenced by lineage. Reading a Skill, publishing it to a target, and allowing a host to load or execute it are separate authorization boundaries. An `accepted` Receipt, Artifact approval, Prompt read, or Skill publication never grants tools, network, filesystem, model Provider, or credential access. @@ -79,12 +80,12 @@ operation. The Server cannot express that: - A administers a Workstream while B can see only one transfer; - B may acknowledge a transfer but may not publish another milestone; - a team member may view a Handoff Report but may not approve an Experience or Skill; -- B may read one shared Memory Entry Version but may not search the scope or follow later versions; -- B may read an approved Experience or managed Skill Revision but may not review a Candidate; -- B may use one exact Prompt but may not silently promote it to a host system or developer instruction; -- a publisher may publish one exact managed Skill but cannot thereby modify its source Revision or gain host execution +- B may read the versions of one shared Memory Entry but may not search the scope or read another entry; +- B may read Revisions of one approved Experience or managed Skill but may not review a Candidate; +- B may use one logical Prompt but may not silently promote it to a host system or developer instruction; +- a publisher may publish a selected managed Skill Revision but cannot thereby modify its source or gain host execution authority; -- a revoked receiver may not read later Revisions; +- an active Handoff Binding covers later Revisions, while a revoked receiver may not read any Revision afterward; - HTTP, MCP, and the Dashboard make the same decision for the same Principal. RFC 0048 requires a receiver to be able to read the Handoff's scope and evidence. Adding B to the complete scope meets @@ -108,21 +109,22 @@ A Handoff answers “where is the work?” An Access Binding answers “who may different lifecycles: ```text -Prepared Handoff -> Commit -> immutable Handoff Revision +Prepared Handoff -> Commit -> logical Handoff -> immutable Revisions | +-> Access Binding for user B | - read / inspect / acknowledge + read any Revision / inspect / acknowledge | expire or revoke ``` -Committing a new Handoff does not share it automatically. Sharing does not change the Handoff content or Revision. -Revoking a Binding does not delete the Handoff, Receipt, or audit events. +The first commit does not share a Handoff automatically. Once a logical Handoff Binding exists, later immutable +Revisions of that same Handoff are covered without replacing the Binding. Sharing does not change Handoff content or +Revision. Revoking a Binding does not delete the Handoff, Receipt, or audit events. ## One Access Plane with Artifact Family-driven Profiles -The Access Control core answers only whether the current Principal may perform an action on an exact resource. A +The Access Control core answers only whether the current Principal may perform an action on a logical resource. A Resource Kind defines the shape of an authorization object. An Artifact Family Access Profile defines the authorization semantics for one kind of content: @@ -142,17 +144,17 @@ Each Artifact Family Access Profile must define: | Family profile contract | Required definition | | --- | --- | -| share unit | Whether the grant covers an exact Revision or a Family-owned exact selector | +| share unit | Which logical identity or Family-owned logical selector the grant covers across versions | | shareable state | Which lifecycle states, such as committed, approved, or retained, allow Binding creation | | parent | How scope- or server-level roles imply child-resource actions in one direction | | actions | Stable actions for reading, using, acknowledging, publishing, and administration | | grantable roles | Fixed roles that may bind to the resource and who may create those Bindings | | resolution | Operations that can resolve the resource from a validated request and what they may not read first | -| listing | How an exact grant is discovered and which aggregate lists still require scope or server authority | +| listing | How a logical grant is discovered and which aggregate lists still require scope or server authority | | transitivity | Whether reading the resource also reads lineage, citations, or other related resources | All Families reuse the same `/v1/access/*` API. They do not add parallel authorization endpoints such as -`/memory/share`, `/experience/share`, `/skill/share`, or `/prompt/share`. A new exact-read Family that reuses +`/memory/share`, `/experience/share`, `/skill/share`, or `/prompt/share`. A new logical-resource Family that reuses `artifact.read` does not require another ResourceRef variant, but it must be registered explicitly. A Family that introduces a semantic action, selector, or role must update OpenAPI, the fixed action and role vocabulary, Server-owned resolvers, Provider conformance vectors, and generated transport artifacts together. Unknown Families are not @@ -161,18 +163,18 @@ shareable by default. Resource visibility, context selection, and external execution authority are separate planes: ```text -Access Plane: Which exact resource the Principal may read or use +Access Plane: Which logical resource the Principal may read or use across versions Context Plane: Which authorized content enters bounded PreparedContext after explicit selection Execution Plane: Whether a host installs, loads, or executes a Skill or Prompt and which tools it may use ``` -An allow decision does not propagate across planes. An exact Memory, Artifact, or Prompt grant does not place content +An allow decision does not propagate across planes. A logical Memory, Artifact, or Prompt grant does not place content in normal scope recall automatically. A receiver first discovers it in a “Shared with me” view, then explicitly reads it, attaches it to the current task, or forks it into a scope where the receiver may contribute. Shared content remains `untrusted_history` or untrusted instruction; Context builders and hosts still enforce their own budgets, precedence, approval, and sandbox policy. -## A transfers one exact Handoff to B +## A transfers one logical Handoff to B Assume A administers the `project:payments` Workstream and has prepared a transfer. The normal flow is: @@ -189,12 +191,12 @@ Assume A administers the `project:payments` Workstream and has prepared a transf 2. A explicitly selects B. The Dashboard or integration resolves B through the deployment's identity directory to a trusted canonical Principal. Model output, a display name, or email text cannot replace this resolution. 3. The Server checks whether A has `scope.delegate` on `project:payments`. -4. The Server creates an Access Binding with the `handoff.receiver` role for that exact Revision and optionally sets an - expiration time. -5. B signs in using B's own credential. `resources/list` returns exact Handoffs B may read. B never receives A's token +4. The Server validates that the selected Revision belongs to a committed Handoff, then creates an Access Binding with + the `handoff.receiver` role for that logical Handoff and optionally sets an expiration time. +5. B signs in using B's own credential. `resources/list` returns logical Handoffs B may read. B never receives A's token or a new bearer share link. -6. B calls Continue with an exact selection. The Server reads the same Revision and resolves only the evidence it - explicitly cites. +6. B calls Continue with an exact or latest selection. The Server reads the selected Revision and resolves only the + evidence it explicitly cites. Existing and future Revisions of the same logical Handoff use the same Binding. 7. After checking the live workspace, capability, and authorization state, B may leave an `accepted`, `needs_clarification`, or `declined` Receipt for the same Revision. @@ -210,11 +212,11 @@ An example Binding creation request is: "resource": { "type": "artifact", "scope_id": "project:payments", - "reference": { + "identity": { "family": "handoff", - "artifact_id": "project:payments", - "revision": 12 - } + "artifact_id": "project:payments" + }, + "selector": null }, "role": "handoff.receiver", "expires_at": "2026-09-06T12:00:00Z", @@ -227,49 +229,50 @@ The Server supplies `granted_by`, creation time, and policy revision. The caller ## What B can see -`handoff.receiver` is an exact-resource role, not a scope role: +`handoff.receiver` is a logical-resource role, not a scope role: | Operation | Result | Reason | | --- | --- | --- | -| Read Handoff Revision 12 | Allowed | The Binding identifies this exact Revision | -| Inspect the citations of Revision 12 through Continue | Allowed | `handoff.evidence.read` covers only this Revision's citation manifest | -| Acknowledge Revision 12 | Allowed | A receiver may leave a Receipt for the exact Handoff it inspected | -| Request `latest` | Denied | Latest may be a later Revision that was never granted to B | -| Read Revision 11 or 13 | Denied | An exact Binding does not inherit to adjacent Revisions | +| Read Handoff Revision 12 | Allowed | The Binding identifies the logical Handoff | +| Inspect the citations of Revision 12 through Continue | Allowed | `handoff.evidence.inspect` covers only the selected Revision's immutable citation manifest | +| Acknowledge Revision 12 | Allowed | A receiver may leave a Receipt for the selected Handoff Revision it inspected | +| Request `latest` | Allowed | Latest resolves within the same bound logical Handoff | +| Read Revision 11 or 13 | Allowed when present | Historical and future Revisions of the same logical Handoff share one Access identity | | Open the aggregate Handoff Report | Denied | The Report contains scope-level history and statistics | | Search scope Memory or list Sources | Denied | A Handoff Binding does not grant general scope read | | Commit a Handoff or record a Task Outcome | Denied | Those operations require `scope.contribute` | | Approve a Candidate | Denied | Approval requires independent `scope.review` authority | Least-privilege evidence access does not copy each Source or Memory item, and it does not require an external PDP to -store every citation. The Server first builds the exact Handoff `ArtifactResourceRef` from the validated request and -checks both `artifact.read` and `handoff.evidence.read` for B. Only when both decisions allow access may it read the +store every citation. The Server first builds the logical Handoff `ArtifactResourceRef` from the validated request and +checks both `artifact.read` and `handoff.evidence.inspect` for B. Only when both decisions allow access may it select an immutable Handoff Revision, obtain its citation manifest, and dereference exact citations in that manifest through the -Handoff resolver. B cannot reuse that permission by placing an arbitrary Source ID in a general read API. +Handoff resolver. The manifest is the bounded transitive authorization edge: B cannot reuse it by placing an arbitrary +Source, Memory, or Artifact identifier in a general read API. -If a citation has been deleted, retired, corrupted, or denied by a higher-order policy, Continue marks the -corresponding evidence unavailable. A Handoff Binding does not override retention, legal hold, data classification, or -an explicit deny policy. +If a citation has been deleted, retired, corrupted, or cannot be resolved, Continue marks the corresponding evidence +unavailable. A Handoff Binding does not override retention, legal hold, or data classification policy. ## Sharing other Artifact Families -Other Artifact Families use the same exact-share flow without inheriting Handoff evidence or Receipt semantics: +Other Artifact Families use the same logical-share flow without inheriting Handoff evidence or Receipt semantics: -1. A selects an exact persisted resource that can be authorized. Memory uses a complete `MemoryCitation`; Experience, - managed Skill, and Prompt use an `ArtifactReference` with a positive integer Revision. +1. A selects a persisted version to identify a resource that can be authorized. The Server normalizes Memory to its + logical `entry_id` selector and other Artifacts to `{family, artifact_id}`; Revision fields do not enter the Binding. 2. The Server checks whether A may create the relevant Binding in the resource's scope, then verifies that the resource exists and is in a shareable state. -3. B discovers the exact resource through `access/resources/list` and reads or explicitly uses it as B's own Principal. +3. B discovers the logical resource through `access/resources/list` and reads existing or future versions, or + explicitly uses it, as B's own Principal. 4. To modify or maintain the content, B explicitly forks or proposes a Candidate in a scope where B has `scope.contribute`. The original resource and Binding do not change. -First-version exact grants behave as follows: +First-version logical grants behave as follows: | Family role | Allows | Does not allow | | --- | --- | --- | -| `artifact.viewer` on a `family=memory` selector | Exact get of one `entry_version_id` | Search, list, changes, current head, revise, retire, or another entry/version | -| `artifact.viewer` | Exact get of one approved Experience or managed Skill Revision | Candidate read/review, later Revisions, publication, or lineage bodies | -| `artifact.viewer` on `family=prompt` | Exact get of one approved Prompt Revision | Render/use, later Revisions, or automatic injection | +| `artifact.viewer` on a `family=memory` selector | Exact get of any version of one `entry_id` | Search, list, changes, revise, retire, or another entry | +| `artifact.viewer` | Exact get of any approved Revision of one Experience or managed Skill identity | Candidate read/review, publication, another Artifact, or lineage bodies | +| `artifact.viewer` on `family=prompt` | Exact get of any approved Revision of one Prompt identity | Render/use, another Prompt, or automatic injection | | `prompt.user` | `artifact.viewer` plus explicit render/use | Changing instruction priority, enabling tools, or reading credentials | Ordinary user input remains Source evidence; the word “prompt” in its content does not make it a Prompt Artifact. A later @@ -279,15 +282,17 @@ Experience or Skill generation, and Handoff generation are Server implementation apply a capability, how to perform it, and how to validate it should be a managed Skill rather than a duplicate Prompt Artifact. -An exact resource response may return lineage or citation identities defined by its schema, but the grant does not -propagate to those referenced resources. A general Source, Memory, or Artifact get still requires an independent -decision for the target. A Provider must not create `can_read` inheritance merely because “A references B.” +Except for Handoff's manifest-scoped evidence resolver, a logical resource response may return lineage or citation +identities defined by its schema, but the grant does not propagate to those referenced resources. A general Source, +Memory, or Artifact get still requires an independent decision for the target. A Provider must not create `can_read` +inheritance merely because “A references B.” -## Sharing is a read-only snapshot, not collaborative editing +## Sharing is read-only access to one evolving identity, not collaborative editing -An exact-resource Binding grants only read, explicit use, or a controlled publication operation to a Server-configured -target. It does not transfer content authority over the original resource. The Binding itself cannot authorize the -receiver to revise, retire, replace, commit a later Revision, or overwrite the shared content in place. If the receiver +An Artifact Binding grants only read, explicit use, or a controlled publication operation to a Server-configured +target. It does not transfer content authority over the original resource. A later Revision created by an authorized +owner becomes visible through the logical Binding, but the Binding itself cannot authorize the receiver to revise, +retire, replace, commit a later Revision, or overwrite the shared content in place. If the receiver separately has `scope.contribute` or stronger authority in the original scope, that write authority comes from the independent scope role, not from the share. @@ -301,7 +306,7 @@ State produced by the receiver remains separate from the shared original: | Fork, import, or copy | Requires `scope.contribute` on the destination scope; creates a new identity or Candidate with lineage to the original | Product surfaces should offer actions such as “View,” “Use,” “Acknowledge,” “Request changes,” “Copy to my scope,” or -“Publish to configured target.” They should not present an exact share as “Edit shared content.” Ongoing co-maintenance +“Publish to configured target.” They should not present a logical share as “Edit shared content.” Ongoing co-maintenance requires a separate scope role. For an Artifact Family with Review, a contributor still creates a Candidate and uses the Review lifecycle to produce a new Revision instead of editing an approved Revision in place. Revocation prevents later access, but it cannot erase content already seen by the receiver or automatically revoke a Receipt, projection, @@ -313,22 +318,22 @@ Reading Skill content and publishing it to a configured host-local Agent target publication request accepts only an exact managed Skill `ArtifactReference` and an opaque Server-configured `target_id`. It does not accept a destination path, Agent home, SSH credential, or arbitrary filesystem locator. Before it reads the Skill body, resolves `target_id`, inspects target host state, or writes a projection, the Server -must obtain both allow decisions on the same exact Skill Artifact: +must allow `artifact.read` on the logical Skill identity: ```text -artifact.read AND skill.publish on exact family=skill Artifact +artifact.read on logical family=skill Artifact ``` -`skill.publisher` binds only to one exact managed Skill Revision and grants both actions. `target_id` is an opaque -operation parameter configured by `server.admin`, not a `ResourceRef`, Access Binding, or authorization resource in +The business request still selects one exact managed Skill Revision, but the Access Resource contains no Revision. +`target_id` is an opaque operation parameter configured by `server.admin`, not a `ResourceRef`, Access Binding, or authorization resource in `/access/resources/list`. Only after authorization may the Server confirm that `target_id` is registered and resolve it to host-local Agent projection configuration. An unknown or disabled target rejects publication. Host IDs, destination paths, Agent homes, credential references, and locators do not enter the request, Binding, ordinary audit, or public errors. An ordinary publisher selects a target through `POST /v1/skills/publication-targets/list`. The request contains the -`scope_id` and exact Skill `ArtifactReference`, and the Server reuses the two requirements above. It reads the Skill -Repository and target registry only after every decision allows access. The response lists only enabled targets and +`scope_id` and exact Skill `ArtifactReference`, and the Server resolves that request to the logical identity before the +authorization check. It reads the Skill Repository and target registry only after the decision allows access. The response lists only enabled targets and their opaque `target_id`, Agent kind, installation scope, and safe capabilities. It does not return desired or applied state, host paths, Agent homes, credential references, or underlying errors. This operation belongs to the Skill publication domain contract; it is not Access Resource listing and creates no target Binding. @@ -354,8 +359,8 @@ publication domain contract; it is not Access Resource listing and creates no ta } ``` -The first version does not support per-target delegation. A Principal with `skill.publisher` on an exact Skill may -publish that Revision to any enabled configured target in the current deployment. Only `server.admin` may configure, +The first version does not support per-target delegation. A Principal with `artifact.read` on a logical Skill may +publish any selected Revision of it to any enabled configured target in the current deployment. Only `server.admin` may configure, change, or remove targets. Target status is operational information protected by `server.observe` or `server.admin`. If the product must express “B may publish to X but not Y,” a separate distribution RFC introduces a generic `execution_target` Resource instead of mixing a Skill-specific target into the Artifact sharing model. @@ -373,7 +378,7 @@ must separately grant `scope.contributor`: ```text handoff.receiver - = read one exact Handoff + inspect its citations + acknowledge it + = read one logical Handoff across Revisions + inspect selected manifest citations + acknowledge a selected Revision scope.contributor = read the Workstream + contribute Sources + prepare/commit Handoffs @@ -393,11 +398,11 @@ A stable team can receive scope roles instead of a new Binding for each Revision - `scope.contributor` writes work evidence, Memory contributions, Handoffs, and Outcomes and proposes Artifact or Prompt Candidates in addition to viewer access; - `scope.reviewer` reviews Artifact Candidates in addition to viewer access; -- `scope.delegator` shares exact Handoffs with receivers in addition to viewer access; +- `scope.delegator` shares logical Handoffs with receivers in addition to viewer access; - `scope.admin` administers all roles and policies for the scope. `scope.delegate` continues to authorize only viewer or receiver Bindings for `family=handoff` Artifacts in this RFC. In -the first version, only `scope.admin` may create exact Bindings for other Artifact Families. An existing Handoff +the first version, only `scope.admin` may create logical Bindings for other Artifact Families. An existing Handoff delegator does not silently gain a wider sharing boundary. A later resource-specific delegation action is an explicit wire-contract change. `server.admin` manages publication targets through deployment configuration; targets do not receive Access Bindings. @@ -407,7 +412,7 @@ map organization roles, teams, or relationships to these actions. ## Revocation and expiration -A, the applicable grant administrator, or a scope administrator can revoke an exact Artifact Binding within its +A, the applicable grant administrator, or a scope administrator can revoke a logical Artifact Binding within its administration boundary. For a Handoff, after revocation: - B's later read, Continue, and acknowledge requests return 403; @@ -443,10 +448,10 @@ This RFC aims to: - establish one Server PEP in front of HTTP, MCP, and the Dashboard; - establish a Principal from a credential without allowing the request to override it; -- support scope-level RBAC and exact Handoff receiver Bindings; -- define stable Resource Kinds and an Artifact Family Access Profile contract, with exact authorization for Handoff, +- support scope-level RBAC and logical Handoff receiver Bindings; +- define stable Resource Kinds and an Artifact Family Access Profile contract, with logical authorization for Handoff, Memory, Experience, Skill, and Prompt resources; -- resolve evidence cited by an exact Handoff safely without opening the complete scope; +- resolve evidence cited by a selected Revision of an authorized Handoff safely without opening the complete scope; - separate resource reads, context selection, Skill publication, and host execution authority; - provide a replaceable decision interface and an optional relationship mutation interface; - provide APIs for self-checks, resource discovery, Binding administration, and audit; @@ -462,8 +467,8 @@ This RFC does not define: - redaction, cross-organization export, legal hold, or retention policy; - approval workflows, temporary elevation, or an Agent requesting more authority automatically; - PowerContext as a general-purpose IAM product; -- multi-writer collaborative editing of an exact shared resource or ownership transfer through a Binding; -- dynamic subscription sharing for Memory collections, Artifact catalogs, or resources that follow `latest`; +- multi-writer collaborative editing of a shared logical resource or ownership transfer through a Binding; +- dynamic subscription sharing for Memory collections or Artifact catalogs whose membership changes over time; - the Prompt Artifact content schema, variable language, Review lifecycle, or host instruction-precedence policy; - per-target publication delegation or a general `execution_target` Resource; - remote managed Skill projection or a Receiver distribution contract; or @@ -481,22 +486,24 @@ An implementation must preserve these invariants: action. 5. `is_internal_bridge()` may skip repeated transport authentication but never authorization. 6. Every protected operation receives a decision before it accesses a Repository or application service. -7. An exact Handoff grant does not allow `latest` and does not cover other Revisions of the same Artifact. +7. A logical Handoff grant allows exact and latest selection for existing and future Revisions of the same Artifact, + but no other Handoff or parent-scope collection. 8. An `accepted` Receipt does not create, update, or inherit an Access Binding. 9. A model may suggest a receiver or explain a denial, but it cannot choose a canonical Principal or invoke an allow-all fallback. -10. An exact Memory Entry grant consists of an exact `family=memory` `ArtifactReference` and a complete `memory_entry` - selector. Every other exact Artifact grant contains a positive integer Revision; none allows `latest` or inherits - to later Revisions. The Server derives the Access Profile only from `ArtifactReference.family`; it rejects an - independent content profile, an unknown Family, or a selector mismatch. +10. A Memory Entry grant consists of the logical `family=memory` Artifact identity and a `memory_entry` selector that + contains only `entry_id`. Every other Artifact grant contains only `{family, artifact_id}`. A business request may + select a positive integer Revision or version, but those fields never enter the Access Resource or Binding. The + Server derives the Access Profile only from `identity.family`; it rejects an independent content profile, an + unknown Family, or a selector mismatch. 11. Reading Memory, Artifact, or Prompt content does not grant its lineage or citation targets and does not place it in PreparedContext automatically. -12. An exact-resource Binding does not grant revise, retire, replace, commit-next-Revision, or any other mutation of +12. A logical-resource Binding does not grant revise, retire, replace, commit-next-Revision, or any other mutation of shared content. Receipts, feedback, projections, and forks are separate resources or operations that require independent authorization and do not modify the original resource identity, content, or Revision. -13. `prompt.use` does not change host instruction precedence. `skill.publish` does not authorize host loading, +13. `prompt.use` does not change host instruction precedence. Skill publication does not authorize host loading, execution, tools, networks, filesystems, or secrets. -14. Skill publication requires both `artifact.read` and `skill.publish` on the exact `family=skill` Artifact before +14. Skill publication requires `artifact.read` on the logical `family=skill` Artifact before resolving `target_id` or performing any host or filesystem inspection. `target_id` is not an authorization resource, and the first version resolves only configured host-local targets. 15. Public errors, logs, metrics, and traces do not contain credentials, Handoff, Memory, Artifact, or Prompt content, @@ -539,7 +546,7 @@ contain `:`, `/`, or user data into policy strings: | --- | --- | --- | | `server` | Deployment identifier | None | | `scope` | Exact `scope_id` | Server | -| `artifact` | Exact `ArtifactReference`, optional Family-owned selector, and `scope_id` | Scope | +| `artifact` | Logical `{family, artifact_id}`, optional Family-owned logical selector, and `scope_id` | Scope | `ResourceRef` is an OpenAPI discriminated union. Each variant uses `additionalProperties: false` and accepts only these fields: @@ -548,36 +555,36 @@ fields: | --- | --- | | `server` | `deployment_id` | | `scope` | `scope_id` | -| `artifact` | `scope_id`, `reference`, and optional `selector` | +| `artifact` | `scope_id`, `identity`, and optional `selector` | -An ordinary Artifact Revision has no selector: +An ordinary logical Artifact has no selector or Revision: ```json { "type": "artifact", "scope_id": "project:payments", - "reference": {"family": "experience", "artifact_id": "exp-retry-budget", "revision": 3} + "identity": {"family": "experience", "artifact_id": "exp-retry-budget"}, + "selector": null } ``` -Memory Entry uses an exact selector owned by the `memory` Family. The combination of `reference` and `selector` is a -complete `MemoryCitation`: +Memory Entry uses a logical selector owned by the `memory` Family. `entry_version_id` and the backing Memory Artifact +Revision remain in business citations, not in Access Resources: ```json { "type": "artifact", "scope_id": "project:payments", - "reference": {"family": "memory", "artifact_id": "memory", "revision": 18}, + "identity": {"family": "memory", "artifact_id": "memory"}, "selector": { "type": "memory_entry", - "entry_id": "retry-policy", - "entry_version_id": "01K..." + "entry_id": "retry-policy" } } ``` -`ArtifactResourceRef.reference.family` is the only Artifact Family Access Profile discriminator. A request contains no -separate `profile` field. The Server derives the Profile from the validated exact `ArtifactReference`, avoiding +`ArtifactResourceRef.identity.family` is the only Artifact Family Access Profile discriminator. A request contains no +separate `profile` field. The Server derives the Profile from the validated logical identity, avoiding conflicts such as `profile=prompt` with `family=skill`. Each Family declares its selector required, forbidden, or one specific discriminated-union variant. The first version requires a `memory_entry` selector for `memory` and forbids a selector for `handoff`, `experience`, `skill`, and `prompt`. @@ -588,34 +595,34 @@ contains at least: | Field | Requirement | | --- | --- | | `family` | Stable name that exactly matches `ArtifactReference.family` | -| `share_unit` | `revision` or one explicit Family-owned selector type | +| `share_unit` | `artifact` or one explicit Family-owned logical selector type | | `shareable_states` | Lifecycle states in which a Binding may be created | | `base_action` | `artifact.read` in the first version | | `additional_actions` | Family-specific use, acknowledge, or publish actions | -| `grantable_roles` | Fixed exact roles compatible with the Family | +| `grantable_roles` | Fixed logical-resource roles compatible with the Family | | `parent_implications` | Child actions implied by scope roles in one direction | | `transitivity` | Whether lineage, citations, or other related resources need separate decisions; the default is none | -| `resolver` | How to resolve the exact resource after authorization and which safe identity to return | +| `resolver` | How to resolve a selected business version after logical authorization and which safe identity to return | The first-version registry is: -| Artifact Family | Share unit | Shareable state | Exact actions | Grantable exact roles | +| Artifact Family | Share unit | Shareable state | Actions | Grantable resource roles | | --- | --- | --- | --- | --- | -| `handoff` | Revision | committed | `artifact.read`, `handoff.evidence.read`, `handoff.acknowledge` | `handoff.viewer`, `handoff.receiver` | -| `memory` | `memory_entry` selector | active in the referenced Revision | `artifact.read` | `artifact.viewer` | -| `experience` | Revision | approved | `artifact.read` | `artifact.viewer` | -| `skill` | Revision | approved | `artifact.read`, `skill.publish` | `artifact.viewer`, `skill.publisher` | -| `prompt` | Revision | approved | `artifact.read`, `prompt.use` | `artifact.viewer`, `prompt.user` | +| `handoff` | logical Artifact | at least one committed Revision | `artifact.read`, `handoff.evidence.inspect`, `handoff.acknowledge` | `handoff.viewer`, `handoff.receiver` | +| `memory` | logical `memory_entry` selector | entry exists | `artifact.read` | `artifact.viewer` | +| `experience` | logical Artifact | at least one approved Revision | `artifact.read` | `artifact.viewer` | +| `skill` | logical Artifact | at least one approved Revision | `artifact.read` | `artifact.viewer` | +| `prompt` | logical Artifact | at least one approved Revision | `artifact.read`, `prompt.use` | `artifact.viewer`, `prompt.user` | -A Prepared Handoff has no persistent identity and cannot receive an exact Access Binding. A least-privilege cross-user +A Prepared Handoff has no persistent identity and cannot receive an Access Binding. A least-privilege cross-user transfer must be committed first. A pending or rejected Candidate likewise cannot receive an Artifact Binding. Even a new Family that reuses only `artifact.read` must be registered explicitly as shareable. Unknown, disabled, or -selector-incompatible Families are denied by default. `revision=latest`, an `entry_id` alone, a Memory current head, or -a search query is not a stable authorization identity. Later Artifact Revisions and Memory Entry Versions do not -inherit an exact Binding. +selector-incompatible Families are denied by default. `revision`, `entry_version_id`, a Memory current head, and a +search query are not authorization identities. Later Artifact Revisions and Memory Entry Versions are covered by the +same logical Binding, while aggregate discovery still requires scope authority. Each Resource Kind defines a stable canonical serialization for adapter object IDs. An Artifact key includes -`scope_id`, `family`, `artifact_id`, a positive integer `revision`, and the complete selector. The same business +`scope_id`, `family`, `artifact_id`, and the logical selector when present. The same business identity produces the same key over HTTP, MCP, and the Dashboard. Different Families or selectors cannot share a Binding through string collisions. @@ -634,36 +641,34 @@ First-version actions are stable lowercase dotted strings: | `scope.read` | scope | Read general resources, approved content, and projections in a Workstream | | `scope.contribute` | scope | Write Sources, Memory contributions, Handoffs/Outcomes, and propose Artifact/Prompt Candidates | | `scope.review` | scope | Review Artifact Candidates in the scope | -| `scope.delegate` | scope | Create viewer or receiver Bindings for exact Handoffs | +| `scope.delegate` | scope | Create viewer or receiver Bindings for logical Handoffs | | `scope.admin` | scope | Administer roles, Bindings, and policy for the scope | -| `artifact.read` | exact artifact | Read the exact Revision or selector defined by its Family Profile | -| `handoff.evidence.read` | `family=handoff` artifact | Resolve that Revision's citation manifest through the Handoff resolver | -| `handoff.acknowledge` | `family=handoff` artifact | Create a Handoff Receipt for that Revision | +| `artifact.read` | logical artifact | Read selected existing and future versions of the identity or selector defined by its Family Profile | +| `handoff.evidence.inspect` | `family=handoff` artifact | Resolve a selected Revision's citation manifest through the Handoff resolver | +| `handoff.acknowledge` | `family=handoff` artifact | Create a Handoff Receipt for a selected Revision | | `prompt.use` | `family=prompt` artifact | Explicitly render or attach an authorized Prompt without deciding host instruction precedence | -| `skill.publish` | `family=skill` artifact | Discover safe target choices and select one exact managed Skill Revision for publication | -`artifact.read` has one meaning across every Family: read only the exact Revision or selector named by the Binding. It -does not include Handoff evidence, Prompt use, Skill publication, lineage bodies, or any mutation. A Family adds a +`artifact.read` has one meaning across every Family: read versions of only the logical identity or selector named by +the Binding. It does not include Handoff evidence, Prompt use, lineage bodies, or any mutation. Managed Skill +publication is a controlled projection of a selected readable Revision to a Server-configured target. A Family adds a semantic action only for an operation with a genuinely different security effect. Business operations check actions rather than role names. External role and relationship models can therefore evolve without changing application code. -Policy may make `scope.read` imply `artifact.read` for every registered Family, `handoff.evidence.read` for Handoffs, +Policy may make `scope.read` imply `artifact.read` for every registered Family, `handoff.evidence.inspect` for Handoffs, and `prompt.use` for Prompts under the scope. `scope.contribute` may imply acknowledge, prepare, commit, Memory -contribution, Artifact or Prompt Candidate proposal, and Outcome writes. The reverse implication never holds: an exact +contribution, Artifact or Prompt Candidate proposal, and Outcome writes. The reverse implication never holds: a resource viewer or user role does not gain `scope.read` or `scope.contribute`. -`scope.read` does not imply `skill.publish`. ## Built-in roles | Role | Granted actions | | --- | --- | -| `handoff.viewer` | `artifact.read`, `handoff.evidence.read` on one exact `family=handoff` Artifact | -| `handoff.receiver` | Viewer actions plus `handoff.acknowledge` on one exact Handoff | -| `artifact.viewer` | `artifact.read` on one compatible exact Artifact Revision or selector | -| `prompt.user` | `artifact.read`, `prompt.use` on one exact `family=prompt` Artifact | -| `skill.publisher` | `artifact.read`, `skill.publish` on one exact managed Skill Revision | +| `handoff.viewer` | `artifact.read`, `handoff.evidence.inspect` on one logical `family=handoff` Artifact | +| `handoff.receiver` | Viewer actions plus `handoff.acknowledge` on one logical Handoff | +| `artifact.viewer` | `artifact.read` on one compatible logical Artifact or selector | +| `prompt.user` | `artifact.read`, `prompt.use` on one logical `family=prompt` Artifact | | `scope.viewer` | `scope.read` | | `scope.contributor` | `scope.read`, `scope.contribute` | | `scope.reviewer` | `scope.read`, `scope.review` | @@ -672,33 +677,33 @@ viewer or user role does not gain `scope.read` or `scope.contribute`. | `server.observer` | `server.observe` | | `server.admin` | Every server, scope, and Artifact Family action | -Every exact-resource role is read-only with respect to its bound content. `handoff.receiver` adds only the creation of -a separate Receipt. `skill.publisher` adds only a projection write to a Server-configured target. Neither -role may modify the source Handoff or Skill Revision. Mutation of the original resource requires an independent scope -role and the relevant domain lifecycle. +Every resource role is read-only with respect to its bound content. `handoff.receiver` adds only the creation of a +separate Receipt. Publishing a readable Skill writes only a projection to a Server-configured target. Neither operation +may modify the source Handoff or Skill Revision. Mutation of the original resource requires an independent scope role +and the relevant domain lifecycle. The first version does not allow the public API to create roles or change role-to-action mappings. Fixed roles give OpenAPI, the Dashboard, and adapter conformance tests stable semantics. An enterprise PDP may map custom organization roles to the actions externally. A Principal with `scope.delegate` may create only `handoff.viewer` or `handoff.receiver`, and only for an existing -exact Handoff in that scope. Creating a scope role requires `scope.admin`. Creating `server.admin` requires an existing +logical Handoff in that scope. Creating a scope role requires `scope.admin`. Creating `server.admin` requires an existing `server.admin` and permission from deployment policy. A Principal cannot grant itself authority beyond the caller's administration boundary. -In the first version, only `scope.admin` may create `artifact.viewer`, `prompt.user`, or `skill.publisher` Bindings in -an administered scope. `artifact.viewer` may bind only to an exact Revision or selector declared compatible by the -Family registry. `prompt.user` and `skill.publisher` may bind only to approved `family=prompt` and `family=skill` -Artifacts, respectively. A role and Artifact Family Access Profile or Resource Kind mismatch returns 422; insufficient +In the first version, only `scope.admin` may create `artifact.viewer` or `prompt.user` Bindings in +an administered scope. `artifact.viewer` may bind only to a logical Artifact or selector declared compatible by the +Family registry. `prompt.user` may bind only to an approved `family=prompt` Artifact. A role and Artifact Family Access +Profile or Resource Kind mismatch returns 422; insufficient authority returns 403. The Server must not forward an incompatible role string unchanged to an external RelationshipWriter. -| Resource or Artifact Family Profile | Grantable exact roles | Binding administrator | +| Resource or Artifact Family Profile | Grantable resource roles | Binding administrator | | --- | --- | --- | | `artifact` with `family=handoff` | `handoff.viewer`, `handoff.receiver` | `scope.delegate`, `scope.admin`, or `server.admin` | | `artifact` with `family=memory` and a `memory_entry` selector | `artifact.viewer` | `scope.admin` or `server.admin` | | `artifact` with `family=experience` | `artifact.viewer` | `scope.admin` or `server.admin` | -| `artifact` with `family=skill` | `artifact.viewer`, `skill.publisher` | `scope.admin` or `server.admin` | +| `artifact` with `family=skill` | `artifact.viewer` | `scope.admin` or `server.admin` | | `artifact` with `family=prompt` | `artifact.viewer`, `prompt.user` | `scope.admin` or `server.admin` | ## Authorization request and decision @@ -736,11 +741,11 @@ A normalized request is: "resource": { "type": "artifact", "scope_id": "project:payments", - "reference": { + "identity": { "family": "handoff", - "artifact_id": "project:payments", - "revision": 12 - } + "artifact_id": "project:payments" + }, + "selector": null }, "context": { "request_id": "pc-01K...", @@ -782,36 +787,29 @@ For example, managed Skill publication resolves to: "resource": { "type": "artifact", "scope_id": "project:payments", - "reference": {"family": "skill", "artifact_id": "retry-runbook", "revision": 4} - } - }, - { - "action": {"name": "skill.publish"}, - "resource": { - "type": "artifact", - "scope_id": "project:payments", - "reference": {"family": "skill", "artifact_id": "retry-runbook", "revision": 4} + "identity": {"family": "skill", "artifact_id": "retry-runbook"}, + "selector": null } } ] } ``` -The business request's `target_id` does not enter these requirements. The Server resolves that parameter only after -both decisions allow access. +The business request's Revision and `target_id` do not enter the Access Resource. The Server resolves those business +parameters only after the decision allows access. -Alternatives such as “scope role or exact role” do not require an `any` expression. The PEP requests the child-resource -action. A Provider uses a trusted parent relationship to decide whether a scope role implies that action, while an exact +Alternatives such as “scope role or resource role” do not require an `any` expression. The PEP requests the child-resource +action. A Provider uses a trusted parent relationship to decide whether a scope role implies that action, while a logical Binding applies directly to the child. Providers therefore do not need an arbitrary nested policy expression language. `resolve_resource_filter` is required for safe list operations. An `AuthorizedResourceFilter` is specific to the -current Principal and action. It contains bounded canonical resource keys produced by exact Bindings and bounded +current Principal and action. It contains bounded canonical resource keys produced by logical Bindings and bounded server or scope constraints produced by parent roles. A parent constraint means that a Repository may query only within that parent, requested Resource Kind, and Family; it is not a client-authored wildcard. The filter also carries -the policy revision. The Server validates its structure and bounds, then pushes the union of exact keys and parent +the policy revision. The Server validates its structure and bounds, then pushes the union of logical resource keys and parent constraints into one Repository query before totals, ordering, or pagination are computed. -The built-in Provider derives exact keys and parent constraints directly from its Binding Store, so it does not mirror +The built-in Provider derives logical resource keys and parent constraints directly from its Binding Store, so it does not mirror the complete Artifact catalog. An external Provider returns an equivalent authorization filter, or its adapter builds one from trusted relationship search. A point-check-only Provider that cannot produce this filter must not query all Artifacts, Projects, or Scopes and filter them afterward. The affected list operation returns 503, or configuration @@ -852,7 +850,7 @@ The built-in Binding Store records at least: | --- | --- | | `binding_id` | Server-generated opaque ID | | `subject` | Canonical `PrincipalRef` | -| `resource` | Canonical exact `ResourceRef` | +| `resource` | Canonical logical `ResourceRef` | | `role` | One fixed role name | | `granted_by` | Authenticated Principal recorded by the Server | | `reason` | Optional bounded human explanation | @@ -882,7 +880,7 @@ The OpenAPI source of truth adds these operations: | `POST /v1/access/resources/list` | List resource identities available to the current Principal | Current Principal only | | `POST /v1/access/roles/list` | Return fixed roles and action vocabulary | Authenticated Principal | | `POST /v1/access/bindings/list` | List Bindings the caller may administer | `scope.delegate`, `scope.admin`, or `server.admin` | -| `POST /v1/access/bindings/create` | Create a Family-compatible exact-resource or administrative Binding | Resource-specific administration action | +| `POST /v1/access/bindings/create` | Create a Family-compatible logical-resource or administrative Binding | Resource-specific administration action | | `POST /v1/access/bindings/revoke` | Revoke a Binding using CAS | Same administration boundary | | `POST /v1/access/bindings/replace` | Atomically revoke an immutable Binding and create its successor | Same administration boundary | | `POST /v1/access/audit/list` | Query security audit events | `scope.admin` or `server.admin` | @@ -915,44 +913,46 @@ The first-version Handoff mappings are: | --- | --- | | `prepare_handoff`, `finalize_handoff`, `handoff_current_work` | `scope.contribute` on request `scope_id` | | `commit_handoff` | `scope.contribute` on request `scope_id` | -| `continue_handoff(selection=latest)` | `scope.read` on request `scope_id` | -| `continue_handoff(selection=exact)` | `artifact.read` and `handoff.evidence.read` on the exact `family=handoff` Artifact, directly or through parent `scope.read` | +| `continue_handoff(selection=latest)` | `artifact.read` and `handoff.evidence.inspect` on the logical `family=handoff` Artifact, directly or through parent `scope.read` | +| `continue_handoff(selection=exact)` | `artifact.read` and `handoff.evidence.inspect` on the logical `family=handoff` Artifact, directly or through parent `scope.read` | | `continue_handoff(selection=prepared)` | `scope.read` on request `scope_id` | -| `acknowledge_handoff` with an exact Receipt | `scope.contribute` or `handoff.acknowledge` on the exact Revision | +| `acknowledge_handoff` with an exact Receipt | `scope.contribute` or `handoff.acknowledge` on the logical Handoff selected by the exact Revision | | `record_task_outcome` | `scope.contribute` on request `scope_id` | -| Aggregate Handoff Report queries | Scope-level read; an exact Handoff grant is insufficient | +| Aggregate Handoff Report queries | Scope-level read; a logical Handoff grant is insufficient | | Handoff Report administration | `scope.admin` or an appropriate server administration action | -When an exact receiver calls Continue, the request provides `selection=exact` and an exact `ArtifactReference`. The -Server builds the Handoff ArtifactResourceRef and evaluates it before reading the Revision. It cannot resolve latest before -the check or fall back to latest when the exact Revision is absent. +When a receiver calls Continue, the Server builds the logical Handoff ArtifactResourceRef before reading a Revision. +For `selection=exact`, it derives the logical identity from the request's exact `ArtifactReference`; for +`selection=latest`, it uses the registered logical Handoff identity for the scope. Only after authorization may it +resolve the requested Revision and its manifest. A Prepared Handoff may contain complete caller-supplied content, so the narrow grant path does not accept `selection=prepared`. Only a Principal with `scope.read` may use a prepared selection to resolve scope evidence. ## Artifact Family operation requirements -Family operations map as follows. “Scope or exact” behavior is implemented by Provider parent relationships, not by a +Family operations map as follows. “Scope or logical resource” behavior is implemented by Provider parent relationships, not by a client-selected bypass path: | Operation family | Required authorization | | --- | --- | -| Memory search/list/changes | `scope.read` on request `scope_id`; an exact Memory grant is insufficient | -| Exact Memory get | `artifact.read` on an exact `family=memory` Artifact plus complete `memory_entry` selector, directly or through parent `scope.read` | -| Memory flush/remember/revise/retire | `scope.contribute`; an exact viewer grant is insufficient | -| Approved Experience/managed Skill exact get | `artifact.read` on an exact `ArtifactReference`, directly or through parent `scope.read` | +| Memory search/list/changes | `scope.read` on request `scope_id`; a logical Memory Entry grant is insufficient | +| Exact Memory get | `artifact.read` on the logical `family=memory` Artifact plus `memory_entry.entry_id`, directly or through parent `scope.read` | +| Memory flush/remember/revise/retire | `scope.contribute`; a logical viewer grant is insufficient | +| Approved Experience/managed Skill exact get | `artifact.read` on the logical Artifact identity derived from the exact request, directly or through parent `scope.read` | | Experience/Skill propose or generate | `scope.contribute` | -| Candidate list/get | `scope.read`; an exact Artifact grant does not expose Candidates | +| Candidate list/get | `scope.read`; a logical Artifact grant does not expose Candidates | | Candidate revise/approve/reject | `scope.review` | -| Approved Prompt exact get | `artifact.read` on an exact `family=prompt` Artifact, directly or through parent `scope.read` | +| Approved Prompt exact get | `artifact.read` on a logical `family=prompt` Artifact, directly or through parent `scope.read` | | Approved Prompt render/use | `prompt.use`, directly or through parent `scope.read` | | Prompt propose/revise | Candidate operation defined by the Prompt lifecycle plus `scope.contribute` | -| List enabled publication targets for an exact managed Skill | `artifact.read` **and** `skill.publish` on the same exact `family=skill` Artifact | -| Publish managed Skill | `artifact.read` **and** `skill.publish` on the same exact `family=skill` Artifact | +| List enabled publication targets for an exact managed Skill | `artifact.read` on the logical `family=skill` Artifact | +| Publish managed Skill | `artifact.read` on the logical `family=skill` Artifact | -An exact-get resolver obtains the complete identity directly from a validated request. A Memory `entry_id`, Artifact -`artifact_id`, or Prompt name alone is not an authorization key. Search, current-head selection, aggregate projections, -and the Candidate Inbox remain collection operations; an exact grant cannot enter them. +An exact-get resolver derives the complete logical identity from a validated business request and discards Revision +fields for authorization. A bare Memory `entry_id`, Artifact `artifact_id`, or Prompt name without its scope and Family +is not an authorization key. Search, aggregate projections, and the Candidate Inbox remain collection operations; a +logical grant cannot enter them. The Prompt Family Access Profile specifies authorization vocabulary and resolver behavior only. A deployment reports that Family as enabled only after it registers an immutable approved `family=prompt` Artifact lifecycle and exposes @@ -963,7 +963,7 @@ other Families, but it must reject `family=prompt` Bindings and must not claim ` `server.admin` may configure, modify, or remove a target; `server.observe` or `server.admin` protects detailed target status. An operator status response contains only target ID, Agent kind, capabilities, desired and applied exact Revisions, a stable state, and a safe reason code. It does not expose host paths, Agent homes, credentials, or raw OS -errors. For publication and publisher target-list requests, the Server must allow both requirements on the exact Skill +errors. For publication and publisher target-list requests, the Server must allow `artifact.read` on the logical Skill before resolving `target_id` or reading the target registry. A standalone operator status request first checks the server-level action. @@ -994,21 +994,21 @@ x-powercontext-access: A resolver is deterministic, Server-owned, and unit-tested. It builds an AccessRequest only from the validated request model and route metadata. It cannot read a business Repository before deciding what to authorize. -Operations that need multiple requirements use a resolver. Publisher target selection and publication reuse the same -exact Skill resolver: +Operations whose resource is derived from business input use a resolver. Publisher target selection and publication +reuse the same logical Skill resolver: ```yaml /v1/skills/publication-targets/list: post: operationId: list_skill_publication_targets x-powercontext-access: - resolver: publish_managed_skill_access + resolver: exact_skill_access /v1/skills/publish: post: operationId: publish_managed_skill x-powercontext-access: - resolver: publish_managed_skill_access + resolver: exact_skill_access ``` Generated `Operation.access` represents either one static requirement or a named resolver. The Server-side resolver @@ -1067,7 +1067,7 @@ safe order is: ```text AuthorizationProvider.resolve_resource_filter - -> validate bounded exact keys and parent constraints + -> validate bounded logical resource keys and parent constraints -> Repository query applying their union -> stable pagination -> response @@ -1080,14 +1080,14 @@ Repository.list_all -> page -> check each item -> remove denied rows ``` It leaks totals, cursors, holes, and timing, and can prevent an authorized user from ever reaching later rows. The -Repository applies the union of exact keys and parent constraints in one query. `total`, cursors, and page boundaries +Repository applies the union of logical resource keys and parent constraints in one query. `total`, cursors, and page boundaries describe only the authorized collection. -An exact Artifact receiver discovers granted resources through Resource Kind and Family filters on +A logical Artifact receiver discovers granted resources through Resource Kind and Family filters on `/v1/access/resources/list`. This does not place those resources in aggregate Project, Workstream, Memory search, Artifact catalog, or Candidate Inbox results. Only scope-level read permits the corresponding aggregate query. A publication target is not an authorization resource and does not appear in this list. A Principal authorized to -publish the exact Skill obtains redacted target choices through the Skill-domain preflight. Detailed operational +publish a selected Revision of the Skill obtains redacted target choices through the Skill-domain preflight. Detailed operational status is queried through a Server operation protected by `server.observe` or `server.admin`. ## Audit and diagnostics @@ -1117,19 +1117,19 @@ component states and safe reasons. Detailed provider diagnostics stay in a prote Committing a Handoff and creating an external authorization relationship are not a disguised cross-system transaction. A “send to B” UI performs recoverable steps: -1. commit or reuse the same exact Handoff Revision; +1. commit or reuse a Handoff Revision belonging to the same logical Handoff; 2. create the Binding using a stable idempotency key; 3. display “shared” only after both steps succeed; 4. if the second step fails, display “Handoff saved, but not yet visible to B” and retry only Binding creation; 5. do not prepare, commit, or create another Revision. When the Binding succeeded but the client lost the response, the same idempotency key returns the original Binding. -If an external RelationshipWriter cannot provide equivalent idempotency, its adapter performs a safe exact +If an external RelationshipWriter cannot provide equivalent idempotency, its adapter performs a safe canonical relationship lookup first or declares self-service mutation unsupported. Every Artifact Family follows the same “persist or approve first, bind second” sharing rule. A failed Binding creation does not roll back or recreate a business Revision; the client retries only the same idempotent Binding mutation. -Skill publication is a projection operation protected by two decisions. It creates no content Revision and +Skill publication is a projection operation protected by the logical Skill read decision. It creates no content Revision and creates no target Binding or change to target authorization state. A failed target apply retains retryable desired/applied state and a safe reason without placing local paths or underlying errors in public audit. @@ -1143,7 +1143,7 @@ window and records the decision revision. The first version does not cache allow ### Built-in provider The built-in profile uses fixed roles and a Server-owned Binding Store. It supports point checks, batch checks, -pushdown `AuthorizedResourceFilter` generation from exact, scope, and server Bindings, creation, revocation, and audit. +pushdown `AuthorizedResourceFilter` generation from logical Artifact, scope, and server Bindings, creation, revocation, and audit. It does not need a business-resource inventory. It is the reference semantics for local deployments and conformance tests and does not provide passwords, a directory, or a custom policy language. @@ -1159,14 +1159,15 @@ A Casbin adapter can use RBAC with domains: - role assignment and policy mutation use the Casbin management API and a persistence adapter. The Casbin domain is an adapter policy namespace. It does not turn `scope_id` into authentication or tenant proof. The -adapter derives the domain from a trusted ResourceRef supplied by the Server. For list filtering, exact-object policy +adapter derives the domain from a trusted ResourceRef supplied by the Server. For list filtering, logical-object policy produces canonical keys while scope or server role assignments produce parent constraints; the Casbin adapter does not enumerate the business Repository. ### OpenFGA adapter -OpenFGA naturally represents relationships among users, groups, scopes, and exact child resources. Every Artifact -Family uses one `artifact` object type. The object ID contains the canonical Family, Revision, and selector; the Server +OpenFGA naturally represents relationships among users, groups, scopes, and logical child resources. Every Artifact +Family uses one `artifact` object type. The object ID contains the canonical scope, Family, Artifact ID, and selector; +it contains no Revision. The Server validates relation compatibility through the Family registry before a tuple write. A new read-only Family therefore does not require a new OpenFGA type: @@ -1201,12 +1202,10 @@ type artifact define handoff_viewer: [user] define handoff_receiver: [user] define prompt_user: [user] - define skill_publisher: [user] - define can_read: viewer or handoff_viewer or handoff_receiver or prompt_user or skill_publisher or can_read from parent + define can_read: viewer or handoff_viewer or handoff_receiver or prompt_user or can_read from parent define can_read_handoff_evidence: handoff_viewer or handoff_receiver or can_read from parent define can_acknowledge_handoff: handoff_receiver or can_contribute from parent define can_use_prompt: prompt_user or can_read from parent - define can_publish_skill: skill_publisher or can_admin from parent ``` The adapter maps `server.observe` to `server#can_observe` and `server.admin` to `server#can_admin`. `admin from parent` @@ -1216,9 +1215,9 @@ direction. `server.observer` gains none of those permissions. The adapter uses an explicit authorization model ID for Check, ListObjects, and tuple writes. Tuples contain only opaque IDs, never email addresses or Handoff content. Model migration switches the configured model ID explicitly; it does not use an implicit latest model. -For lists, exact relations may produce canonical keys through ListObjects, while scope or server roles produce trusted +For lists, logical resource relations may produce canonical keys through ListObjects, while scope or server roles produce trusted parent constraints directly. The adapter does not require an object tuple for every business Artifact that has no -exact Binding. +logical Binding. ### AuthZEN, OPA, and Cerbos adapters @@ -1234,13 +1233,12 @@ from PDP search or trusted relationship data also reports `safe_resource_filteri ## Configuration and compatibility -The Server provides three explicit modes: +The Server provides two explicit modes: | Mode | Behavior | | --- | --- | | `disabled` | Preserve existing single-user, single-trust-domain behavior; Access API unavailable; no multi-user isolation claim | -| `legacy-static-admin` | Map the current static Bearer to a deployment-local `server.admin` Principal | -| `enforced` | Require both an authentication Provider and AuthorizationProvider; run the PEP for every business operation | +| `enforced` | Require an Authentication Provider and AccessControlService; run the PEP for every business operation | An upgrade cannot fall back to `disabled` because external identity is configured but a PDP is missing. Mode is explicit. Capabilities and readiness report the current mode and whether relationship management, batch checks, and @@ -1301,12 +1299,12 @@ Implementation proceeds in independently verifiable slices: Principal, and stable errors. 2. **Built-in PEP/PDP**: fixed roles, Binding Store, `_add_route()` authorization wrapper, point/batch checks, and audit. -3. **Exact Handoff receiver**: post-commit Binding creation, exact Continue, citation-manifest resolver, exact - acknowledge, revocation, and expiration. -4. **Artifact Family Access Profiles**: unified ArtifactResourceRef, Family registry, Memory selector, exact read/use +3. **Logical Handoff receiver**: post-commit Binding creation, exact/latest Continue, citation-manifest resolver, + exact acknowledge, future-Revision visibility, revocation, and expiration. +4. **Artifact Family Access Profiles**: unified ArtifactResourceRef, Family registry, Memory selector, logical read/use resolvers, role compatibility, and non-transitive lineage. 5. **Skill publication**: a Server-configured host-local target registry, publisher-safe selection, operator status, - read-plus-publish requirements on the same exact Skill, and redacted failure state. + logical Skill authorization for exact business publication, and redacted failure state. 6. **Safe listing and UI**: authorized resource listing, Handoff inbox, “Shared with me,” Dashboard permission projection, and authorization-aware pagination. 7. **MCP parity**: Principal propagation through the internal bridge, tool-discovery UX, and invocation-time @@ -1324,52 +1322,52 @@ the PEP, or hide only Dashboard controls without API enforcement. The implementation of this RFC is complete only when these observable scenarios pass: - an unauthenticated request to a protected operation returns 401; -- A with `scope.delegate` can grant B only an existing committed exact Handoff Revision in that scope, using +- A with `scope.delegate` can grant B an existing logical Handoff with at least one committed Revision in that scope, using `handoff.viewer` or `handoff.receiver`; another Artifact Family or role returns 422, while a missing action returns 403, and neither failure writes a Binding; -- B can read, Continue, and acknowledge the granted exact Revision; -- B is denied latest, adjacent Revisions, the aggregate Handoff Report, Memory lists, Source lists, and Task Outcome - writes; +- B can read and Continue historical, current, and future Revisions of the granted Handoff, use `latest`, and + acknowledge a selected exact Revision; +- B is denied another Handoff, the aggregate Handoff Report, Memory lists, Source lists, and Task Outcome writes; - B reads manifest citations only through the authorized Handoff resolver and cannot submit an arbitrary citation to a general read endpoint; - `handoff.viewer` cannot acknowledge while `handoff.receiver` can; - an `accepted` Receipt creates no Binding or scope role; -- after revocation or expiration, B's later access is denied and authorized resource listing omits the Revision; +- after revocation or expiration, B's access is denied and authorized resource listing omits the logical Handoff; - Binding creation and revocation have stable CAS, idempotency, and audit behavior; - 403 does not leak resource existence, and list cursors and totals describe only the authorized collection; - an unavailable PDP returns 503 without calling an application service, Repository, or mutation; - the MCP internal bridge uses the original Principal and returns the same denial as HTTP; - the API denies a request even when Dashboard controls are bypassed or fail to hide it; -- a legacy static token becomes local admin only in the explicit compatibility mode; +- in explicit `enforced` mode, a legacy static token becomes local admin only when no Authentication Provider is injected; - `server.observer` can read protected service and publication status but cannot modify access or target configuration; `server.admin` can perform both classes of operation, with equivalent Built-in, Casbin, and OpenFGA results; - built-in, Casbin/OpenFGA, and AuthZEN adapters return equivalent decisions for the same conformance vectors; -- a request cannot submit an independent content profile; an unknown or disabled Family, `revision=latest`, a missing - or extra selector, or a Family-role mismatch returns 422 and writes no Binding; +- a request cannot submit an independent content profile or Revision in an Access Resource; an unknown or disabled + Family, a missing or extra selector, or a Family-role mismatch returns 422 and writes no Binding; - `artifact.viewer` always maps only to `artifact.read` for Experience, Skill, Prompt, and a `memory_entry` selector; the Family never adds use, publish, acknowledge, or mutation implicitly; -- `artifact.viewer` can get an authorized Memory Entry through `family=memory` and a complete `memory_entry` selector, - but cannot search, list, select current, revise, retire, or read adjacent versions; -- an exact Artifact viewer can read an approved Experience or managed Skill Revision but cannot see Candidates, later - Revisions, or dereference lineage bodies; +- `artifact.viewer` can get historical and future versions of an authorized Memory Entry through `family=memory` and + an `entry_id` selector, but cannot search, list, revise, retire, or read another entry; +- a logical Artifact viewer can read approved Revisions of one Experience or managed Skill but cannot see Candidates, + another Artifact, or dereference lineage bodies; - `artifact.viewer` may only read a Prompt while `prompt.user` may use it explicitly; neither role changes host instruction precedence or places the Prompt in normal recall automatically; -- an exact-resource role cannot revise, retire, replace, or commit a later Revision of the shared original, even when +- a logical-resource role cannot revise, retire, replace, or commit a later Revision of the shared original, even when the request supplies the expected version; - a Receipt created by acknowledgement and a target projection created by publication do not change the source identity, content, Revision, or digest; - a fork, import, or copy is denied without `scope.contribute` on the destination scope; when allowed, it creates a new identity or Candidate and leaves the original unchanged; -- managed Skill publication runs only when both `artifact.read` and `skill.publish` allow access on the same exact - Skill; any denial or unavailable decision prevents `target_id` resolution, host-path inspection, and projection +- managed Skill publication runs only when `artifact.read` allows access on the logical Skill; any denial or + unavailable decision prevents `target_id` resolution, host-path inspection, and projection writes; after authorization, an unknown or disabled target still rejects publication; -- the publisher target list reads the registry only after both requirements on the same exact Skill allow access and +- the publisher target list reads the registry only after `artifact.read` on the logical Skill allows access and returns only safe identities and capabilities for enabled targets; detailed status still requires `server.observe` or `server.admin`; - the first version rejects a remote Receiver target without reading remote credentials or opening a network connection; -- `skill.publisher` may publish its authorized exact Skill to any enabled target in the deployment; the first version - has no target Binding or per-target delegation; +- a Principal with `artifact.read` may publish a selected exact Revision of its authorized logical Skill to any enabled + target in the deployment; the first version has no target Binding or per-target delegation; - `resources/list` totals, cursors, and rows describe only the selected Resource Kind and Artifact Family resources discoverable by the current Principal; - a deployment without a Prompt lifecycle rejects `family=prompt` Bindings; one without an available publication @@ -1387,7 +1385,7 @@ Every business request adds an authorization decision. A remote PDP adds a netwo require a bounded pushdown `AuthorizedResourceFilter`, so a point-check-only adapter cannot support every Dashboard list. -An exact Handoff transfer must be committed first. A temporary Prepared Handoff cannot become a revocable cross-user +A logical Handoff transfer must be committed first. A temporary Prepared Handoff cannot become a revocable cross-user resource. That adds a persistence step but avoids inventing a second identity and ACL model for temporary payloads. Separating decisions from relationship management makes the adapter surface more complex than a single `check()`. @@ -1398,10 +1396,10 @@ Handoffs, Memory, Artifacts, or Prompts containing highly sensitive material sti data classification, and export controls. Artifact Family Access Profiles add a registry, selectors, a role compatibility matrix, and conformance vectors. Skill -publication also checks `artifact.read` and `skill.publish` on the same exact Artifact. A remote PDP without an atomic +publication checks `artifact.read` on the logical Artifact before resolving the exact business Revision. A remote PDP without an atomic multi-requirement decision adds latency and a bounded TOCTOU risk whose policy revision must be recorded. -The first version does not place targets in authorization policy. A Principal with `skill.publisher` on an exact Skill +The first version does not place targets in authorization policy. A Principal with `artifact.read` on a logical Skill may publish it to any enabled target in the deployment. A deployment that needs target-specific isolation must defer the capability, isolate deployments, or wait for a separate RFC to define a generic `execution_target` Resource. This RFC does not prematurely encode that model as a Skill-specific resource. @@ -1434,7 +1432,7 @@ should not receive a new Revision whenever team membership changes. This alterna Granting only `scope.viewer` is easy, but B then sees the complete Workstream's Memory, Sources, history, and Report. That violates least privilege for a temporary relay. Scope roles remain available for long-term collaboration; -exact-resource Bindings serve one-off transfers or asset sharing. +logical-resource Bindings serve one-off transfers or asset sharing. ## Alternative: add one share API per domain @@ -1445,7 +1443,7 @@ API with one ArtifactResourceRef, Family role compatibility, and resolvers. Each ## Alternative: one Resource Kind per Artifact Family Separate `ResourceRef.type` values for `handoff`, `memory_entry`, `experience`, `skill`, and `prompt` would duplicate -scope parentage, exact Revision identity, canonical keys, and read-only sharing structure. Every new Family would also +scope parentage, logical Artifact identity, canonical keys, and read-only sharing structure. Every new Family would also extend the OpenAPI discriminator and external PDP object types. More importantly, `ResourceRef.type` and `ArtifactReference.family` would become two potentially conflicting content discriminators. This RFC uses one `artifact` Resource Kind and lets the Server derive the Access Profile from `ArtifactReference.family`. Only a Family @@ -1453,7 +1451,7 @@ such as Memory that needs a narrower authorization unit adds an explicit selecto ## Alternative: recall every shared resource automatically -Adding every exact grant to PreparedContext conflates visibility with relevance, expands token budgets, and lets an +Adding every logical grant to PreparedContext conflates visibility with relevance, expands token budgets, and lets an untrusted Prompt or Skill affect a receiver's model without explicit selection. The first version provides authorized discovery and explicit attachment only. A later shared collection or subscription still passes through an independent Context selection policy. @@ -1483,7 +1481,7 @@ defines semantics and a conformance contract rather than one engine. ## Alternative: store roles in access tokens -Token roles are simple but poorly suited to exact Handoff grants, revocation, large resource sets, and policy updates. +Token roles are simple but poorly suited to logical Handoff grants, revocation, large resource sets, and policy updates. A token may carry trusted identity and group claims, but the PDP still makes the final resource decision. ## Alternative: authorize inside every Runtime method @@ -1529,14 +1527,14 @@ The RFC must resolve these choices before merge, but they do not change the core does not provide directory search; - whether an enforced deployment requires `safe_resource_filtering` or may disable the corresponding Dashboard lists; - whether deployment policy sets a default expiration for `handoff.receiver` or the UI requires an explicit choice; -- whether the UI suggests a separate `scope.contributor` grant after an exact receiver creates a Receipt, without ever +- whether the UI suggests a separate `scope.contributor` grant after a Handoff receiver creates a Receipt, without ever performing that upgrade automatically; - whether the later Prompt Artifact lifecycle uses one fixed Review policy or distinguishes private personal templates from organization-approved templates. Custom roles, organization hierarchy, cross-tenant export, anonymous share links, temporary elevation, approval -workflows, general Source object-level ACLs, dynamic Memory collections, Artifact catalog sharing, and automatic -following of future Revisions are explicitly deferred. They require separate threat models and RFCs. +workflows, general Source object-level ACLs, dynamic Memory collections, and Artifact catalog sharing are explicitly +deferred. They require separate threat models and RFCs. # Future possibilities @@ -1559,5 +1557,5 @@ The subject/action/resource contract can later support: - a bounded decision cache after a clear revocation-staleness guarantee exists. These extensions cannot change the first-version invariants: `scope_id` is not an ACL, resource content does not grant -authority, exact grants do not follow later Revisions, reads do not enter Context or grant execution automatically, and -every transport fails closed at the Server PEP. +authority, logical grants cover only the same identity across Revisions, reads do not enter Context or grant execution +automatically, and every transport fails closed at the Server PEP. diff --git a/docs/zh/development/remote-access-implementation.md b/docs/zh/development/remote-access-implementation.md index 2a8452756..e20cadd82 100644 --- a/docs/zh/development/remote-access-implementation.md +++ b/docs/zh/development/remote-access-implementation.md @@ -23,8 +23,6 @@ uv run powercontext server run ```bash # 推荐:先为 Server 启用认证,再绑定可路由地址(生产环境在前面加 TLS)。 POWERCONTEXT_SERVER_ACCESS_MODE=enforced \ -POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer \ -POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin \ POWERCONTEXT_SERVER_AUTH_TOKEN="replace-with-a-strong-token" \ uv run powercontext server run --host 0.0.0.0 --port 8080 ``` diff --git a/docs/zh/docs/how-to/configure-claude-code.md b/docs/zh/docs/how-to/configure-claude-code.md index b6aa1d286..859566cec 100644 --- a/docs/zh/docs/how-to/configure-claude-code.md +++ b/docs/zh/docs/how-to/configure-claude-code.md @@ -128,8 +128,6 @@ timeout 和 flush 控制项见[配置参考](../reference/configuration.md)。 ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-codex.md b/docs/zh/docs/how-to/configure-codex.md index 6e5eaaeb8..08e62d68a 100644 --- a/docs/zh/docs/how-to/configure-codex.md +++ b/docs/zh/docs/how-to/configure-codex.md @@ -88,8 +88,6 @@ export POWERCONTEXT_CODEX_FLUSH_ON_CAPTURE=true ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-dsh.md b/docs/zh/docs/how-to/configure-dsh.md index 88bb08351..96d885f89 100644 --- a/docs/zh/docs/how-to/configure-dsh.md +++ b/docs/zh/docs/how-to/configure-dsh.md @@ -55,8 +55,6 @@ export POWERCONTEXT_DSH_FLUSH_ON_CAPTURE=true ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-openclaw.md b/docs/zh/docs/how-to/configure-openclaw.md index 338555205..925dddceb 100644 --- a/docs/zh/docs/how-to/configure-openclaw.md +++ b/docs/zh/docs/how-to/configure-openclaw.md @@ -67,8 +67,6 @@ project scope 仅在 OpenClaw 为一次 turn 提供唯一可信项目身份时 ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-pi.md b/docs/zh/docs/how-to/configure-pi.md index f45252061..824d00547 100644 --- a/docs/zh/docs/how-to/configure-pi.md +++ b/docs/zh/docs/how-to/configure-pi.md @@ -78,8 +78,6 @@ flush。 ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/configure-workbuddy.md b/docs/zh/docs/how-to/configure-workbuddy.md index f9597c4ff..6bef09a5a 100644 --- a/docs/zh/docs/how-to/configure-workbuddy.md +++ b/docs/zh/docs/how-to/configure-workbuddy.md @@ -216,8 +216,6 @@ WorkBuddy 按以下顺序解析 scope: ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/docs/zh/docs/how-to/deploy-server.md b/docs/zh/docs/how-to/deploy-server.md index 5da4eae28..5a41195e4 100644 --- a/docs/zh/docs/how-to/deploy-server.md +++ b/docs/zh/docs/how-to/deploy-server.md @@ -114,8 +114,6 @@ SQLite 数据库和 scheduler 状态。 ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_DEPLOYMENT_TOKEN" powercontext server run ``` @@ -128,8 +126,6 @@ docker run --rm \ --publish 127.0.0.1:8000:8000 \ --volume powercontext-data:/data \ --env POWERCONTEXT_SERVER_ACCESS_MODE=enforced \ - --env POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer \ - --env POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin \ --env POWERCONTEXT_SERVER_AUTH_TOKEN \ powercontext-server:local ``` diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index 8a7eff1d0..4eb2f0fd7 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -42,13 +42,9 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_WORKSPACE` | Server 启动目录 | 本机项目级 Agent Skill 目录的解析根目录 | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | 启用 Streamable HTTP MCP | | `POWERCONTEXT_SERVER_MCP_PATH` | `/mcp` | MCP 路径 | -| `POWERCONTEXT_SERVER_AUTH_PROVIDER` | 未设置 | Authentication Provider:`static-bearer`、`oidc` 或 `trusted-header`;`enforced` 模式必须设置 | -| `POWERCONTEXT_SERVER_AUTH_TOKEN` | 未设置 | 静态 Bearer token;仅可与 `AUTH_PROVIDER=static-bearer` 一起使用 | -| `POWERCONTEXT_SERVER_AUTH_PRINCIPAL_ID` | `server-token` | 静态 token 所代表的部署内全局唯一 Principal ID | -| `POWERCONTEXT_SERVER_AUTH_PRINCIPAL_DESCRIPTION` | `PowerContext static bearer` | 静态 Principal 的可选展示描述,不参与身份判定 | -| `POWERCONTEXT_SERVER_ACCESS_MODE` | `disabled` | 唯一安全开关:`disabled` 或 `enforced` | -| `POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER` | 未设置 | Authorization Provider:`builtin`、`casbin` 或 `external`;`enforced` 模式必须设置 | -| `POWERCONTEXT_SERVER_ACCESS_STATIC_PRESET` | `true` | 为单 Principal 静态部署显式写入所需的内置 role | +| `POWERCONTEXT_SERVER_AUTH_ENABLED` | `false` | 旧静态 Bearer 兼容开关;`true` 自动映射为 `ACCESS_MODE=enforced`,并要求设置 `AUTH_TOKEN` | +| `POWERCONTEXT_SERVER_AUTH_TOKEN` | 未设置 | 旧静态 Bearer token;未注入 Authentication Provider 时作为兼容认证并映射为内置管理员 | +| `POWERCONTEXT_SERVER_ACCESS_MODE` | `disabled` | 唯一正式 Access 开关:`disabled` 或 `enforced` | | `POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID` | `powercontext` | `server` Access Resource 使用的稳定部署标识 | | `POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_ID` | 未设置 | 多用户 enforced 部署中供定时任务使用的显式 service Principal | | `POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION` | 未设置 | 定时 service Principal 的可选展示描述 | @@ -104,15 +100,16 @@ TLS 由上游终止或网络本身受控的场景下, 显式设置 `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` 主动选择接受。通过网络暴露启用鉴权的 Server 前必须配置 TLS。 -`POWERCONTEXT_SERVER_ACCESS_MODE` 是唯一开关。`disabled` 会拒绝 Authentication/Authorization Provider 配置,并在 -可信本地边界内跳过授权决策。`enforced` 必须同时设置 `AUTH_PROVIDER` 和 `AUTHORIZATION_PROVIDER`,启用统一策略执行点, -并使用所选 Provider 的 Binding 与审计能力。 +`POWERCONTEXT_SERVER_ACCESS_MODE` 是唯一正式开关。`disabled` 在可信本地边界内跳过授权决策;`enforced` 启用统一策略执行点、 +Binding 和审计。Authorization 默认使用 builtin,实现替换通过 `create_server_app(access_control=...)` 注入;Authentication +通过 `create_server_app(authentication_provider=...)` 注入。若没有注入 Authentication Provider,Server 只接受旧 +`AUTH_TOKEN` 作为静态 Bearer 兼容认证,并把固定的 `server-token` Principal 初始化为内置管理员。两者都没有时拒绝启动。 +旧 `AUTH_ENABLED=true + AUTH_TOKEN` 配置会自动映射为 `ACCESS_MODE=enforced`。 Authentication 负责建立 Principal,Access Control 负责判断该 Principal 能做什么。Principal ID 是部署内全局唯一且不复用 的标识;`description` 只用于展示,不参与身份判定。内置静态 token 始终只代表一个 service Principal,因此不能区分 -用户 A 和用户 B。使用内置 Authorization Provider 时,`ACCESS_STATIC_PRESET=true` 会为这个 Principal 显式写入 Server -与各 scope 所需的 role。需要让不同用户或 group 获得不同权限时,应使用 `oidc` 或 `trusted-header`,并注入部署侧 -Authentication Provider 与合适的 Authorization Provider。 +用户 A 和用户 B。兼容静态 token 会为这个 Principal 显式写入 Server 与各 scope 所需的 role。需要让不同用户或 group +获得不同权限时,应注入部署侧 Authentication Provider 与相应的 AccessControlService。 定时 Source 处理和 Experience 孵化使用固定静态 Principal,或 `ACCESS_BACKGROUND_PRINCIPAL_ID` 指定的 service Principal。 该 Principal 必须在每个被处理的 scope 上拥有 `scope.contribute`;新 Memory Entry 和 Candidate 会保留它作为直接 owner 或 @@ -179,7 +176,7 @@ powercontext --server-url http://powercontext.internal.example:8765 \ 示例中的非 loopback opt-in 与 Receiver 传输例外彼此独立:它表示操作者接受该监听器上的所有 Server route 在没有 Server 级 Bearer token 时可达。部署条件允许时,应优先启用鉴权,或在仅绑定 loopback 的 Server 前终止 TLS。 -在 `AUTH_PROVIDER=static-bearer` 且 `enforced` 时,`/`、`/skills`、`/reviews`、`/handoff-reports` 的 HTML 外壳及其静态资源仍保持公开,以便 +使用兼容静态 Bearer 且 `enforced` 时,`/`、`/skills`、`/reviews`、`/handoff-reports` 的 HTML 外壳及其静态资源仍保持公开,以便 浏览器渲染登录表单;数据请求仍受鉴权保护。在表单中输入 Server token 后,浏览器只把它保存在当前标签页的 session storage 中。如果连这些登录页也不能暴露,应同时关闭 Dashboard 和 Handoff Report。 diff --git a/docs/zh/docs/reference/http-api.md b/docs/zh/docs/reference/http-api.md index 9676b3264..8649187be 100644 --- a/docs/zh/docs/reference/http-api.md +++ b/docs/zh/docs/reference/http-api.md @@ -108,8 +108,10 @@ curl --fail \ "$POWERCONTEXT_URL/v1/access/bindings/create" ``` -接收者可以读取和确认这个 Handoff 的历史、当前及未来 Revision;除非另有 scope role,否则不能在 scope 范围发现 -latest Handoff、读取其他 Handoff,也不能访问父 scope 的 Memory。用 `/v1/access/me` 确认部署建立的 Principal,用 `/v1/access/check` +接收者可以读取和确认这个 Handoff 的历史、当前及未来 Revision。Continue 会展示所选 Revision 的不可变 manifest 中的 +citation,并检查这些被引用资源,不需要为每条 citation 再创建 Binding。这种 manifest 范围内的检查不会授权通用的 +Source、Memory 或 Artifact 接口;除非另有 scope 或 Artifact role,否则接收者仍不能发现其他 Handoff 或读取父 scope。 +它只能对已绑定的逻辑 Handoff 请求 `latest`。用 `/v1/access/me` 确认部署建立的 Principal,用 `/v1/access/check` 检查一个由 `all` 或 `any` 组合的权限要求, 用 `/v1/access/resources/list` 非发现式地列出已经可见的资源。创建操作按授权者与幂等键保证幂等;撤销时必须提交 `binding_id` 和 `expected_version`。`/v1/access/bindings/replace` 会原子撤销一个不可变 Binding,并用相同 Resource 和 role diff --git a/docs/zh/rfcs/1396_handoff_access_control.md b/docs/zh/rfcs/1396_handoff_access_control.md index c3569c379..6aa30c066 100644 --- a/docs/zh/rfcs/1396_handoff_access_control.md +++ b/docs/zh/rfcs/1396_handoff_access_control.md @@ -59,7 +59,7 @@ PowerContext Server 策略执行点(PEP) - `server`:当前 PowerContext deployment; - `scope`:一个精确 Workstream scope; -- `artifact`:一个由 Artifact Family Access Profile 解释的精确 Artifact Revision 或 Family-owned selector。 +- `artifact`:一个由 Artifact Family Access Profile 解释的逻辑 Artifact identity 或 Family-owned 逻辑 selector。 `artifact` Resource Kind 首版注册 `handoff`、`memory`、`experience`、`skill` 和 `prompt` 五个 Artifact Family Access Profile。`ArtifactReference.family` 是唯一的 Profile discriminator;客户端不再提交第二个可能与它冲突的内容类型。 @@ -67,12 +67,12 @@ Profile。`ArtifactReference.family` 是唯一的 Profile discriminator;客户 用户 A 可以选择两种协作方式: - 为长期协作者授予 Workstream 级角色; -- 只把一个已持久化或已批准的精确 resource 授予 B。 +- 只把一个已持久化或已批准的逻辑 resource 授予 B。 -第二种方式是首版的最小权限路径。B 可以读取被分享的精确资源,并只能执行对应 Artifact Family Access Profile 明确 -授予的 action。精确 Handoff receiver 可以通过 Handoff resolver 检查其中明确引用的 evidence,并对同一个 Revision 留下 Receipt;精确 -Memory、Artifact 或 Prompt grant 不自动开放同一 scope、current head、未来 Revision、搜索结果或 lineage 中引用的 -其他资源。Skill 的读取、发布到一个 target,以及宿主最终加载或执行是彼此独立的授权边界。`accepted` Receipt、Artifact +第二种方式是首版的最小权限路径。B 可以读取被分享逻辑资源的已有及未来版本,并只能执行对应 Artifact Family Access +Profile 明确授予的 action。Handoff receiver 可以通过 Handoff resolver 检查所选 Revision 明确引用的 evidence,并对该 +Revision 留下 Receipt;逻辑 Memory、Artifact 或 Prompt grant 不自动开放同一 scope、聚合搜索/列表、其他逻辑资源或 +lineage 中引用的资源。Skill 的读取、发布到一个 target,以及宿主最终加载或执行是彼此独立的授权边界。`accepted` Receipt、Artifact approval、Prompt read 或 Skill publication 都不会授予工具、网络、文件系统、模型 Provider 或凭据权限。 PowerContext 定义稳定的授权 request/decision、内置角色、Access API 和 OpenAPI extension,但不绑定一个策略引擎。 @@ -88,11 +88,11 @@ Memory Entry Version、approved Experience/managed Skill Revision 和 host-local - A 可以管理 Workstream,而 B 只能看一份交接; - B 可以确认接收,但不能提交新的里程碑; - 团队成员可以查看 Handoff Report,但不能审批 Experience 或 Skill; -- B 只能读取一条被分享的 Memory Entry Version,不能搜索整个 scope 或跟随它的未来版本; -- B 可以读取一个 approved Experience 或 managed Skill Revision,但不能评审 Candidate; -- B 可以使用一个精确 Prompt,但不能把它静默提升为宿主的 system/developer instruction; -- 发布者可以发布一个精确 managed Skill,但不能借此修改源 Revision 或获得宿主执行权限; -- 被撤销的接收方不能继续读取后续 Revision; +- B 可以读取一条被分享 Memory Entry 的各版本,但不能搜索整个 scope 或读取其他 Entry; +- B 可以读取一个 approved Experience 或 managed Skill 的各 Revision,但不能评审 Candidate; +- B 可以使用一个逻辑 Prompt,但不能把它静默提升为宿主的 system/developer instruction; +- 发布者可以发布选定的 managed Skill Revision,但不能借此修改源资源或获得宿主执行权限; +- 有效 Handoff Binding 覆盖后续 Revision,而被撤销的接收方之后不能读取任何 Revision; - HTTP、MCP 和 Dashboard 对同一个 Principal 得到相同判定。 RFC 0048 要求接收方能够读取 Handoff 所属 scope 及其 evidence。直接把 B 加入整个 scope 虽然满足该要求,却会暴露 @@ -112,20 +112,21 @@ RFC 1223 中 `acknowledge_handoff` 的 authorization check 是接收方对实时 Handoff 回答“工作到了哪里”;Access Binding 回答“谁现在可以对这份交接做什么”。两者具有不同生命周期: ```text -Prepared Handoff -> Commit -> immutable Handoff Revision +Prepared Handoff -> Commit -> logical Handoff -> immutable Revisions | +-> Access Binding for user B | - read / inspect / acknowledge + read any Revision / inspect / acknowledge | expire or revoke ``` -提交新 Handoff 不会自动分享,分享也不修改 Handoff 内容或 Revision。撤销 Binding 不删除 Handoff、Receipt 或审计事件。 +第一次提交 Handoff 不会自动分享;逻辑 Handoff Binding 建立后,同一 Handoff 的后续不可变 Revision 无需替换 Binding 即可 +访问。分享不修改 Handoff 内容或 Revision,撤销 Binding 不删除 Handoff、Receipt 或审计事件。 ## 同一 Access Plane,Artifact Family 驱动的 Profile -Access Control 核心只回答“当前 Principal 是否可以对这个精确资源执行这个 action”。Resource Kind 定义授权对象的结构; +Access Control 核心只回答“当前 Principal 是否可以对这个逻辑资源执行这个 action”。Resource Kind 定义授权对象的结构; Artifact Family Access Profile 定义一种内容的授权语义: ```text @@ -144,33 +145,33 @@ Protected Resource | Family profile contract | 必须定义的内容 | | --- | --- | -| share unit | 分享整个精确 Revision,还是一个 Family-owned exact selector | +| share unit | 跨版本分享哪个逻辑 identity 或 Family-owned 逻辑 selector | | shareable state | committed、approved、retained 等哪些 lifecycle state 可以创建 Binding | | parent | scope 或 server 级角色如何单向蕴含子资源 action | | actions | 读取、使用、确认、发布和管理分别使用什么稳定 action | | grantable roles | 哪些固定角色可以绑定到该资源,以及谁可以创建这些 Binding | | resolution | 哪些 operation 可以从已验证 request 确定资源,不得在授权前读取什么 | -| listing | exact grant 如何被发现,以及哪些聚合列表仍要求 scope 或 server 权限 | +| listing | 逻辑 grant 如何被发现,以及哪些聚合列表仍要求 scope 或 server 权限 | | transitivity | 读取资源是否同时允许读取 lineage、citation 或其他关联资源 | 所有 Family 复用同一个 `/v1/access/*` API,不增加 `/memory/share`、`/experience/share`、`/skill/share` 或 -`/prompt/share` 等平行授权接口。新增 Family 必须显式注册;只复用 `artifact.read` 的 exact-read Family 不需要增加新的 +`/prompt/share` 等平行授权接口。新增 Family 必须显式注册;只复用 `artifact.read` 的逻辑资源 Family 不需要增加新的 ResourceRef variant。若 Family 引入新的 semantic action、selector 或 role,则必须同步 OpenAPI、固定 action/role vocabulary、Server-owned resolver、Provider conformance vector 和生成的 transport artifact。未知 Family 默认不可分享。 资源可读、进入上下文和获得外部执行能力是三个不同平面: ```text -Access Plane: Principal 可以读取或使用哪个 exact resource +Access Plane: Principal 可以跨版本读取或使用哪个逻辑 resource Context Plane: 哪些已授权内容经显式选择进入有界 PreparedContext Execution Plane: 宿主是否安装、加载或执行 Skill/Prompt,以及能使用哪些工具和凭据 ``` -一个 allow decision 不能跨平面传播。精确 Memory、Artifact 或 Prompt grant 不会让内容自动进入普通 scope recall;接收方 +一个 allow decision 不能跨平面传播。逻辑 Memory、Artifact 或 Prompt grant 不会让内容自动进入普通 scope recall;接收方 先在 “Shared with me” 视图发现资源,再显式读取、附加到当前任务或 fork 到自己可贡献的 scope。共享内容继续视为 `untrusted_history` 或不可信 instruction,Context builder 和宿主仍执行各自的预算、优先级、approval 与 sandbox policy。 -## A 把一份精确 Handoff 交给 B +## A 把一个逻辑 Handoff 交给 B 假设 A 负责 `project:payments` Workstream,并已完成一份交接。正常流程如下: @@ -187,10 +188,12 @@ Execution Plane: 宿主是否安装、加载或执行 Skill/Prompt,以及能 2. A 明确选择接收方 B。Dashboard 或集成层把 B 从企业身份目录解析为可信的 canonical Principal;模型输出、显示名或 邮箱文本不能替代该解析。 3. Server 检查 A 对 `project:payments` 是否拥有 `scope.delegate`。 -4. Server 创建角色为 `handoff.receiver` 的 Access Binding,资源是上面的精确 Revision,可选设置过期时间。 -5. B 使用自己的凭据登录。`resources/list` 返回 B 有权读取的精确 Handoff,B 不需要知道 A 的 token,也不接收新的 +4. Server 验证所选 Revision 属于已提交 Handoff,再为该逻辑 Handoff 创建角色为 `handoff.receiver` 的 Access Binding, + 可选设置过期时间。 +5. B 使用自己的凭据登录。`resources/list` 返回 B 有权读取的逻辑 Handoff,B 不需要知道 A 的 token,也不接收新的 bearer share link。 -6. B 使用 exact selection 调用 Continue。Server 读取同一 Revision,并只解析它明确引用的 evidence。 +6. B 使用 exact 或 latest selection 调用 Continue。Server 读取所选 Revision,并只解析它明确引用的 evidence;同一逻辑 + Handoff 的已有和未来 Revision 共用一个 Binding。 7. B 检查当前 workspace、能力和授权状态后,可以对同一 Revision 留下 `accepted`、`needs_clarification` 或 `declined` Receipt。 @@ -206,11 +209,11 @@ Execution Plane: 宿主是否安装、加载或执行 Skill/Prompt,以及能 "resource": { "type": "artifact", "scope_id": "project:payments", - "reference": { + "identity": { "family": "handoff", - "artifact_id": "project:payments", - "revision": 12 - } + "artifact_id": "project:payments" + }, + "selector": null }, "role": "handoff.receiver", "expires_at": "2026-09-06T12:00:00Z", @@ -223,46 +226,46 @@ Execution Plane: 宿主是否安装、加载或执行 Skill/Prompt,以及能 ## B 能看到什么 -`handoff.receiver` 是精确资源角色,不是 scope role: +`handoff.receiver` 是逻辑资源角色,不是 scope role: | 操作 | 结果 | 原因 | | --- | --- | --- | -| 读取 Handoff Revision 12 | 允许 | Binding 指向该精确 Revision | -| 通过 Continue 检查 Revision 12 的引用 | 允许 | `handoff.evidence.read` 只覆盖该 Revision 的 citation manifest | -| Acknowledge Revision 12 | 允许 | receiver 可以为已检查的 exact Handoff 留 Receipt | -| 请求 `latest` | 拒绝 | latest 可能是 B 未获授权的后续 Revision | -| 读取 Revision 11 或 13 | 拒绝 | 精确 Binding 不继承到其他 Revision | +| 读取 Handoff Revision 12 | 允许 | Binding 指向该逻辑 Handoff | +| 通过 Continue 检查 Revision 12 的引用 | 允许 | `handoff.evidence.inspect` 只覆盖所选 Revision 的不可变 citation manifest | +| Acknowledge Revision 12 | 允许 | receiver 可以为已检查的所选 Handoff Revision 留 Receipt | +| 请求 `latest` | 允许 | latest 只在已绑定的同一逻辑 Handoff 内解析 | +| 读取 Revision 11 或 13 | 存在时允许 | 同一逻辑 Handoff 的历史和未来 Revision 共用一个 Access identity | | 打开聚合 Handoff Report | 拒绝 | Report 包含 scope 级历史和统计 | | 搜索 scope Memory 或列出 Source | 拒绝 | Handoff Binding 不授予通用 scope read | | Commit 新 Handoff 或记录 Task Outcome | 拒绝 | 需要 `scope.contribute` | | 审批 Candidate | 拒绝 | 需要独立的 `scope.review` | Evidence 的最小权限不是逐条复制 Source 或 Memory,也不是让外部 PDP 保存全部 citation。Server 先从已验证请求构造 -exact Handoff `ArtifactResourceRef`,同时检查 B 的 `artifact.read` 和 `handoff.evidence.read`;只有两个 decision 都允许后, -才能读取不可变 Handoff Revision、取得 citation manifest,并通过 Handoff resolver 解引用其中的 exact citation。B 不能把 -任意 Source ID 填入通用读取 API 来复用这项权限。 +逻辑 Handoff `ArtifactResourceRef`,同时检查 B 的 `artifact.read` 和 `handoff.evidence.inspect`;只有两个 decision 都允许后, +才能选择不可变 Handoff Revision、取得 citation manifest,并通过 Handoff resolver 解引用其中的 exact citation。manifest +是有界的传递授权边:B 不能把任意 Source、Memory 或 Artifact ID 填入通用读取 API 来复用这项权限。 -如果一条 citation 已被删除、retire、损坏或因更高层策略被拒绝,Continue 把对应 evidence 标记为 unavailable。 -Handoff Binding 不覆盖 retention、legal hold、数据分类或显式 deny policy。 +如果一条 citation 已被删除、retire、损坏或无法解析,Continue 把对应 evidence 标记为 unavailable。Handoff Binding +不覆盖 retention、legal hold 或数据分类策略。 ## 分享其他 Artifact Family -其他 Artifact Family 使用相同的 exact-share 流程,但不会继承 Handoff 的 evidence 和 Receipt 语义: +其他 Artifact Family 使用相同的逻辑分享流程,但不会继承 Handoff 的 evidence 和 Receipt 语义: -1. A 选择一个已经持久化且可授权的精确资源;Memory 使用完整 `MemoryCitation`,Experience、managed Skill 和 Prompt - 使用带正整数 Revision 的 `ArtifactReference`。 +1. A 选择一个已持久化版本来标识可授权资源;Server 把 Memory 归一化为逻辑 `entry_id` selector,把其他 Artifact + 归一化为 `{family, artifact_id}`,Revision 不进入 Binding。 2. Server 先检查 A 是否可以在该资源所属 scope 创建对应 Binding,再验证资源存在且处于可分享状态。 -3. B 通过 `access/resources/list` 发现 exact resource,并使用自己的 Principal 读取或显式使用它。 +3. B 通过 `access/resources/list` 发现逻辑 resource,并使用自己的 Principal 读取它的已有或未来版本,或显式使用它。 4. B 若要修改或长期维护内容,需要在自己拥有 `scope.contribute` 的 scope 中显式 fork 或提出新 Candidate;原资源和 Binding 不被修改。 -首版 exact grant 的行为如下: +首版逻辑 grant 的行为如下: | Family role | 允许 | 不允许 | | --- | --- | --- | -| `artifact.viewer` on `family=memory` selector | exact get 一个 `entry_version_id` | search、list、changes、current head、revise、retire、其他 entry/version | -| `artifact.viewer` | exact get 一个 approved Experience 或 managed Skill Revision | Candidate read/review、future Revision、publication、lineage body | -| `artifact.viewer` on `family=prompt` | exact get 一个 approved Prompt Revision | render/use、future Revision、自动注入 | +| `artifact.viewer` on `family=memory` selector | exact get 同一 `entry_id` 的任一版本 | search、list、changes、revise、retire、其他 entry | +| `artifact.viewer` | exact get 同一 Experience 或 managed Skill identity 的任一 approved Revision | Candidate read/review、publication、其他 Artifact、lineage body | +| `artifact.viewer` on `family=prompt` | exact get 同一 Prompt identity 的任一 approved Revision | render/use、其他 Prompt、自动注入 | | `prompt.user` | `artifact.viewer` 加显式 render/use | 改变 instruction priority、自动启用工具或读取凭据 | 普通用户输入仍是 Source evidence,不因包含文字 “prompt” 就成为 Prompt Artifact。可复用、参数化的任务模板可以由后续 @@ -270,14 +273,15 @@ Prompt Artifact lifecycle 定义;Memory extraction、Experience/Skill generati 属于 Server implementation/configuration,由 `server.admin` 管理,不通过 `family=prompt` Artifact Binding 分享。如果一个 内容描述 Agent 何时使用、如何执行和如何验证一项能力,它应建模为 managed Skill,而不是重复创建 Prompt Artifact。 -精确资源响应可以返回 schema 已定义的 lineage/citation identity,但 grant 不向引用目标传递。调用通用 Source、Memory 或 -Artifact get operation 仍需对目标资源独立判定;Provider 不得因为 “A references B” 自动创建 `can_read` 继承。 +除 Handoff 的 manifest 范围 evidence resolver 外,逻辑资源响应可以返回 schema 已定义的 lineage/citation identity,但 +grant 不向引用目标传递。调用通用 Source、Memory 或 Artifact get operation 仍需对目标资源独立判定;Provider 不得因为 +“A references B” 自动创建 `can_read` 继承。 -## 分享是只读快照,不是共同编辑 +## 分享是对一个演进 identity 的只读访问,不是共同编辑 -Exact-resource Binding 只授予读取、显式使用或向 Server-configured target 执行受控发布 operation 的权限,不转移原资源的 -content authority。 -Binding 本身不能授权接收方 revise、retire、replace、提交下一 Revision,或原地覆盖共享内容。即使接收方另外拥有原 scope +Artifact Binding 只授予读取、显式使用或向 Server-configured target 执行受控发布 operation 的权限,不转移原资源的 +content authority。授权 owner 创建的后续 Revision 会通过逻辑 Binding 对接收方可见,但 Binding 本身不能授权接收方 +revise、retire、replace、提交下一 Revision,或原地覆盖共享内容。即使接收方另外拥有原 scope 的 `scope.contribute` 或更高权限,其写入能力也来自该独立的 scope role,而不是这次分享。 接收方产生的状态必须与共享原件分离: @@ -289,7 +293,7 @@ Binding 本身不能授权接收方 revise、retire、replace、提交下一 Rev | 发布 managed Skill | 写入 Server 配置目标的 projection/state,不修改源 Skill Revision | | fork、import 或 copy | 必须对目标 scope 拥有 `scope.contribute`;创建新的 identity 或 Candidate,并保留到原资源的 lineage | -产品界面应使用“查看”“使用”“确认接收”“请求变更”“复制到我的 scope”或“发布到配置目标”等动作,不应把 exact share +产品界面应使用“查看”“使用”“确认接收”“请求变更”“复制到我的 scope”或“发布到配置目标”等动作,不应把逻辑 share 呈现为“编辑共享内容”。持续共同维护需要单独授予 scope role;对于需要 Review 的 Artifact Family,贡献者仍通过 Candidate 和 Review lifecycle 产生新 Revision,而不是原地改写 approved Revision。撤销分享会阻止后续访问,但不能删除接收方已经 看到的内容,也不能自动撤销此前经独立授权创建的 Receipt、projection 或 fork。 @@ -298,20 +302,20 @@ Binding 本身不能授权接收方 revise、retire、replace、提交下一 Rev 读取 Skill 内容和把 Skill 发布到配置的 host-local Agent target 是不同 operation。发布请求只接受 exact managed Skill `ArtifactReference` 和 Server 配置的 opaque `target_id`,不接受 destination path、Agent home、SSH credential 或任意 -filesystem locator。Server 必须在读取 Skill body、解析 `target_id`、检查 target host 状态或写入 projection 前同时得到两个 -关于同一个 exact Skill Artifact 的 allow decision: +filesystem locator。Server 必须在读取 Skill body、解析 `target_id`、检查 target host 状态或写入 projection 前允许逻辑 +Skill identity 上的 `artifact.read`: ```text -artifact.read AND skill.publish on exact family=skill Artifact +artifact.read on logical family=skill Artifact ``` -`skill.publisher` 只绑定到一个 exact managed Skill Revision,并同时授予这两个 action。`target_id` 是由 `server.admin` +业务请求仍选择一个精确 managed Skill Revision,但 Access Resource 不包含 Revision。`target_id` 是由 `server.admin` 配置的 opaque operation parameter,不是 `ResourceRef`、Access Binding 或 `/access/resources/list` 中的授权资源。授权通过后, Server 才能确认 `target_id` 已注册并把它解析为 host-local Agent projection configuration;未注册或 disabled target 拒绝 发布。Host ID、destination path、Agent home、credential reference 和 locator 不进入请求、Binding、普通 audit 或公共错误。 普通 publisher 通过 `POST /v1/skills/publication-targets/list` 选择 target。请求携带 `scope_id` 和 exact Skill -`ArtifactReference`,Server 复用上述两个 requirement;只有全部 allow 后才读取 Skill Repository 和 target registry。响应 +`ArtifactReference`,Server 在授权前把它解析为逻辑 identity;只有 allow 后才读取 Skill Repository 和 target registry。响应 只列出 enabled target 的 opaque `target_id`、Agent kind、installation scope 和安全 capability,不返回 desired/applied state、host path、Agent home、credential reference 或底层错误。该 operation 是 Skill publication domain contract,不是 Access Resource listing,也不为 target 创建 Binding。 @@ -337,8 +341,8 @@ Access Resource listing,也不为 target 创建 Binding。 } ``` -首版不提供 per-target delegation:获得一个 exact Skill 的 `skill.publisher` 后,可以把该 Revision 发布到当前 deployment -中任意 enabled configured target。只有 `server.admin` 能配置、修改或删除 target;target 状态属于受 `server.observe` 或 +首版不提供 per-target delegation:拥有逻辑 Skill 的 `artifact.read` 后,可以把它的任一选定 Revision 发布到当前 +deployment 中任意 enabled configured target。只有 `server.admin` 能配置、修改或删除 target;target 状态属于受 `server.observe` 或 `server.admin` 保护的运维信息。若产品需要表达“B 可以发布到 X,但不能发布到 Y”,后续由独立分发 RFC 定义通用 `execution_target` Resource,而不把 Skill 专用 target 混入 Artifact 分享模型。 @@ -352,7 +356,7 @@ Access Resource listing,也不为 target 创建 Binding。 ```text handoff.receiver - = read one exact Handoff + inspect its citations + acknowledge it + = read one logical Handoff across Revisions + inspect selected manifest citations + acknowledge a selected Revision scope.contributor = read the Workstream + contribute Sources + prepare/commit Handoffs @@ -371,11 +375,11 @@ PowerContext 权限只控制 PowerContext 资源和 operation。修改 Git 仓 - `scope.contributor`:在 viewer 基础上写入工作 evidence、Memory contribution、Handoff 和 Outcome,并提出 Artifact/Prompt Candidate; - `scope.reviewer`:在 viewer 基础上评审 Artifact Candidate; -- `scope.delegator`:在 viewer 基础上把精确 Handoff 分享给接收方; +- `scope.delegator`:在 viewer 基础上把逻辑 Handoff 分享给接收方; - `scope.admin`:管理该 scope 的全部角色和策略。 `scope.delegate` 在本 RFC 中继续只允许为 `family=handoff` Artifact 创建 viewer/receiver Binding。首版其他 Artifact -Family 的 exact Binding 只能由 `scope.admin` 创建,不能因为已有 Handoff delegator 就静默扩大分享边界。后续可以增加 +Family 的逻辑 Binding 只能由 `scope.admin` 创建,不能因为已有 Handoff delegator 就静默扩大分享边界。后续可以增加 资源级 delegation action,但必须作为显式 wire-contract 变更。发布 target 由 `server.admin` 通过 deployment configuration 管理,不创建 Access Binding。 @@ -384,7 +388,7 @@ Family 的 exact Binding 只能由 `scope.admin` 创建,不能因为已有 Han ## 撤销和过期 -A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 exact Artifact Binding。对于 Handoff,撤销后: +A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的逻辑 Artifact Binding。对于 Handoff,撤销后: - B 的后续 read、Continue 和 acknowledge 返回 403; - B 不再从 `resources/list` 看到该 Handoff; @@ -417,10 +421,10 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 - 在 HTTP、MCP 和 Dashboard 前建立同一个 Server PEP; - 从认证凭据建立不可由请求覆盖的 Principal; -- 支持 scope 级 RBAC 和精确 Handoff receiver Binding; +- 支持 scope 级 RBAC 和逻辑 Handoff receiver Binding; - 定义稳定 Resource Kind 和 Artifact Family Access Profile contract,并规范 Handoff、Memory、Experience、Skill 和 Prompt - 的精确授权; -- 允许安全解引用精确 Handoff 已引用的 evidence,而不开放整个 scope; + 的逻辑资源授权; +- 允许安全解引用已授权 Handoff 所选 Revision 引用的 evidence,而不开放整个 scope; - 区分资源读取、上下文选择、Skill 发布与宿主执行权限; - 提供可替换的判定接口和可选的关系写入接口; - 提供自助检查、资源发现、Binding 管理和审计 API; @@ -436,8 +440,8 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 - 数据脱敏、cross-organization export、legal hold 或 retention policy; - 审批工作流、临时提权流程或 Agent 自动请求更高权限; - 把 PowerContext 改造成通用 IAM 产品; -- 对 exact shared resource 进行 multi-writer collaborative editing,或通过 Binding 转移 ownership; -- Memory collection、Artifact catalog 或 “自动跟随 latest” 的动态订阅分享; +- 对 shared logical resource 进行 multi-writer collaborative editing,或通过 Binding 转移 ownership; +- 成员会动态变化的 Memory collection 或 Artifact catalog 订阅分享; - Prompt Artifact 的内容 schema、变量语言、Review lifecycle 或宿主 instruction-priority policy; - per-target publication delegation 或通用 `execution_target` Resource; - remote managed Skill projection 或 Receiver distribution contract; @@ -453,19 +457,21 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 4. Handoff、Memory、Artifact 和 Prompt 内容是 `untrusted_history` 或不可信 instruction,不能授予 action。 5. `is_internal_bridge()` 只能跳过重复 transport authentication,不能跳过 authorization。 6. 每个受保护的 operation 在访问 Repository 或 application service 前完成判定。 -7. 精确 Handoff grant 不允许 `latest`,不自动覆盖同 Artifact 的其他 Revision。 +7. 逻辑 Handoff grant 允许对同一 Artifact 的已有和未来 Revision 使用 exact/latest selection,但不开放其他 Handoff 或 + 父 scope collection。 8. `accepted` Receipt 不创建、更新或继承 Access Binding。 9. 模型可以建议接收方或解释拒绝原因,但不能自行确定 canonical Principal 或调用 allow-all fallback。 -10. Exact Memory Entry grant 必须由 `family=memory` 的精确 `ArtifactReference` 和完整 `memory_entry` selector 组成;其他 - exact Artifact grant 必须包含正整数 Revision,不允许 `latest` 或自动继承到未来 Revision。Server 只从 - `ArtifactReference.family` 派生 Access Profile;独立 content profile、未知 Family 或 selector mismatch 必须拒绝。 +10. Memory Entry grant 由逻辑 `family=memory` Artifact identity 和仅含 `entry_id` 的 `memory_entry` selector 组成;其他 + Artifact grant 只含 `{family, artifact_id}`。业务请求可以选择正整数 Revision 或 version,但这些字段永远不进入 Access + Resource 或 Binding。Server 只从 `identity.family` 派生 Access Profile;独立 content profile、未知 Family 或 + selector mismatch 必须拒绝。 11. 读取 Memory、Artifact 或 Prompt 不自动授予其 lineage/citation target,也不自动进入 PreparedContext。 -12. Exact-resource Binding 本身不授予 revise、retire、replace、提交下一 Revision 或其他修改共享内容的 operation; +12. Logical-resource Binding 本身不授予 revise、retire、replace、提交下一 Revision 或其他修改共享内容的 operation; Receipt、feedback、projection 和 fork 是独立资源或 operation,必须分别授权,并且不能修改原资源的 identity、content 或 Revision。 -13. `prompt.use` 不改变宿主 instruction priority;`skill.publish` 不授予宿主加载、执行、工具、网络、文件系统或 secret +13. `prompt.use` 不改变宿主 instruction priority;Skill publication 不授予宿主加载、执行、工具、网络、文件系统或 secret 权限。 -14. Skill publish 必须同时允许 exact `family=skill` Artifact 的 `artifact.read` 和 `skill.publish`,且授权发生在解析 +14. Skill publish 必须允许逻辑 `family=skill` Artifact 的 `artifact.read`,且授权发生在解析 `target_id` 或任何 host/filesystem inspection 前;`target_id` 不是授权资源,首版只解析已配置的 host-local target。 15. Public error、log、metric 和 trace 不包含 credential、Handoff/Memory/Artifact/Prompt 正文、Source body、target locator 或 PDP 原始响应。 @@ -505,7 +511,7 @@ Agent 名称、host、session ID 和模型名称属于 provenance,不默认成 | --- | --- | --- | | `server` | deployment identifier | none | | `scope` | exact `scope_id` | server | -| `artifact` | exact `ArtifactReference`、可选 Family-owned selector 和 `scope_id` | scope | +| `artifact` | 逻辑 `{family, artifact_id}`、可选 Family-owned 逻辑 selector 和 `scope_id` | scope | `ResourceRef` 是 OpenAPI discriminated union。每个 variant 使用 `additionalProperties: false`,并且只接受下表字段: @@ -513,36 +519,36 @@ Agent 名称、host、session ID 和模型名称属于 provenance,不默认成 | --- | --- | | `server` | `deployment_id` | | `scope` | `scope_id` | -| `artifact` | `scope_id`, `reference`, and optional `selector` | +| `artifact` | `scope_id`, `identity`, and optional `selector` | -普通 Artifact Revision 不包含 selector: +普通逻辑 Artifact 不包含 selector 或 Revision: ```json { "type": "artifact", "scope_id": "project:payments", - "reference": {"family": "experience", "artifact_id": "exp-retry-budget", "revision": 3} + "identity": {"family": "experience", "artifact_id": "exp-retry-budget"}, + "selector": null } ``` -Memory Entry 使用 `memory` Family 拥有的 exact selector。`reference` 和 `selector` 合在一起等价于完整 -`MemoryCitation`: +Memory Entry 使用 `memory` Family 拥有的逻辑 selector。`entry_version_id` 与底层 Memory Artifact Revision 保留在业务 +citation 中,不进入 Access Resource: ```json { "type": "artifact", "scope_id": "project:payments", - "reference": {"family": "memory", "artifact_id": "memory", "revision": 18}, + "identity": {"family": "memory", "artifact_id": "memory"}, "selector": { "type": "memory_entry", - "entry_id": "retry-policy", - "entry_version_id": "01K..." + "entry_id": "retry-policy" } } ``` -`ArtifactResourceRef.reference.family` 是唯一的 Artifact Family Access Profile discriminator。请求不包含独立 `profile` -字段;Server 从已验证的 exact `ArtifactReference` 派生 Profile,避免 `profile=prompt` 与 `family=skill` 等不一致组合。 +`ArtifactResourceRef.identity.family` 是唯一的 Artifact Family Access Profile discriminator。请求不包含独立 `profile` +字段;Server 从已验证的逻辑 identity 派生 Profile,避免 `profile=prompt` 与 `family=skill` 等不一致组合。 每个 Family 声明 selector 为 required、forbidden 或某个固定 discriminated union variant。首版 `memory` 要求 `memory_entry` selector,`handoff`、`experience`、`skill` 和 `prompt` 禁止 selector。 @@ -551,32 +557,32 @@ Family registry 是 Server-owned 固定 contract,不是管理员可编辑的 p | Field | Requirement | | --- | --- | | `family` | 与 `ArtifactReference.family` 完全匹配的稳定名称 | -| `share_unit` | `revision` 或一个明确的 Family-owned selector type | +| `share_unit` | `artifact` 或一个明确的 Family-owned 逻辑 selector type | | `shareable_states` | 允许创建 Binding 的 lifecycle state | | `base_action` | 首版统一为 `artifact.read` | | `additional_actions` | Family 特有的 use、acknowledge 或 publish action | -| `grantable_roles` | 与该 Family 兼容的固定 exact roles | +| `grantable_roles` | 与该 Family 兼容的固定逻辑资源 roles | | `parent_implications` | scope role 可以单向蕴含哪些 child action | | `transitivity` | lineage、citation 或其他关联资源是否需要独立判定;未声明时为 none | -| `resolver` | 授权后如何解析 exact resource 以及返回什么安全 identity | +| `resolver` | 逻辑授权后如何解析所选业务版本,以及返回什么安全 identity | 首版 registry 为: -| Artifact Family | Share unit | Shareable state | Exact actions | Grantable exact roles | +| Artifact Family | Share unit | Shareable state | Actions | Grantable resource roles | | --- | --- | --- | --- | --- | -| `handoff` | Revision | committed | `artifact.read`, `handoff.evidence.read`, `handoff.acknowledge` | `handoff.viewer`, `handoff.receiver` | -| `memory` | `memory_entry` selector | active in the referenced Revision | `artifact.read` | `artifact.viewer` | -| `experience` | Revision | approved | `artifact.read` | `artifact.viewer` | -| `skill` | Revision | approved | `artifact.read`, `skill.publish` | `artifact.viewer`, `skill.publisher` | -| `prompt` | Revision | approved | `artifact.read`, `prompt.use` | `artifact.viewer`, `prompt.user` | +| `handoff` | 逻辑 Artifact | 至少一个 committed Revision | `artifact.read`, `handoff.evidence.inspect`, `handoff.acknowledge` | `handoff.viewer`, `handoff.receiver` | +| `memory` | 逻辑 `memory_entry` selector | Entry 存在 | `artifact.read` | `artifact.viewer` | +| `experience` | 逻辑 Artifact | 至少一个 approved Revision | `artifact.read` | `artifact.viewer` | +| `skill` | 逻辑 Artifact | 至少一个 approved Revision | `artifact.read` | `artifact.viewer` | +| `prompt` | 逻辑 Artifact | 至少一个 approved Revision | `artifact.read`, `prompt.use` | `artifact.viewer`, `prompt.user` | -Prepared Handoff 没有持久化 identity,不能创建精确 Access Binding。跨用户最小权限分享必须先 commit;pending/rejected +Prepared Handoff 没有持久化 identity,不能创建 Access Binding。跨用户最小权限分享必须先 commit;pending/rejected Candidate 同样不能创建 Artifact Binding。普通新 Family 即使只复用 `artifact.read`,也必须先显式注册为 shareable; -未知、disabled 或 selector 不匹配的 Family 默认拒绝。`revision=latest`、只有 `entry_id`、Memory current head 或 search query -都不是稳定授权身份。后续 Revision 或 Memory Entry Version 不继承 exact Binding。 +未知、disabled 或 selector 不匹配的 Family 默认拒绝。`revision`、`entry_version_id`、Memory current head 和 search query +都不是授权身份。后续 Artifact Revision 和 Memory Entry Version 由同一逻辑 Binding 覆盖;聚合发现仍要求 scope 权限。 每个 Resource Kind 都定义稳定的 canonical serialization 供 adapter 建立 object ID。Artifact key 必须包含 `scope_id`、 -`family`、`artifact_id`、正整数 `revision` 和完整 selector;相同业务身份在 HTTP、MCP 和 Dashboard 必须得到同一个 key。 +`family`、`artifact_id` 和存在时的逻辑 selector;相同业务身份在 HTTP、MCP 和 Dashboard 必须得到同一个 key。 不同 Family 或 selector 不得因字符串碰撞共享 Binding。 Adapter 负责把结构化 ResourceRef 映射成外部 PDP object ID。映射必须 canonical、可逆或稳定,并避免把 email、token、 @@ -593,34 +599,32 @@ Adapter 负责把结构化 ResourceRef 映射成外部 PDP object ID。映射必 | `scope.read` | scope | 读取该 Workstream 的通用只读资源、approved content 和投影 | | `scope.contribute` | scope | 写入 Source、Memory contribution、Handoff/Outcome,并提出 Artifact/Prompt Candidate | | `scope.review` | scope | 评审该 scope 的 Artifact Candidate | -| `scope.delegate` | scope | 为精确 Handoff 创建 viewer 或 receiver Binding | +| `scope.delegate` | scope | 为逻辑 Handoff 创建 viewer 或 receiver Binding | | `scope.admin` | scope | 管理该 scope 的角色、Binding 和 policy | -| `artifact.read` | exact artifact | 读取 Family Profile 定义的 exact Revision 或 selector | -| `handoff.evidence.read` | `family=handoff` artifact | 通过 Handoff resolver 解引用该 Revision 的 citation manifest | -| `handoff.acknowledge` | `family=handoff` artifact | 对该 Revision 创建 Handoff Receipt | +| `artifact.read` | logical artifact | 读取 Family Profile 定义的 identity 或 selector 的已有和未来版本 | +| `handoff.evidence.inspect` | `family=handoff` artifact | 通过 Handoff resolver 解引用所选 Revision 的 citation manifest | +| `handoff.acknowledge` | `family=handoff` artifact | 对所选 Revision 创建 Handoff Receipt | | `prompt.use` | `family=prompt` artifact | 显式 render 或附加一个已授权 Prompt;不决定宿主 instruction priority | -| `skill.publish` | `family=skill` artifact | 发现安全 target 选项,并选择一个 exact managed Skill Revision 用于发布 | -`artifact.read` 的含义在所有 Family 中保持固定:只读取 Binding 标识的 exact Revision 或 selector。它不自动包含 Handoff -evidence、Prompt use、Skill publish、lineage body 或任何 mutation。只有确实具有不同安全效果的 Family operation 才新增 -semantic action。 +`artifact.read` 的含义在所有 Family 中保持固定:只读取 Binding 标识的逻辑 identity 或 selector 的各版本。它不自动 +包含 Handoff evidence、Prompt use、lineage body 或任何 mutation。Managed Skill publication 是把所选可读 Revision +投影到 Server-configured target 的受控 operation。只有确实具有不同安全效果的 Family operation 才新增 semantic action。 业务 operation 检查 action,不检查 role name。这样可以调整外部角色或关系模型,而不改 application code。 -`scope.read` 可以通过策略蕴含 scope 下所有已注册 Family 的 `artifact.read`、Handoff 的 `handoff.evidence.read` 和 +`scope.read` 可以通过策略蕴含 scope 下所有已注册 Family 的 `artifact.read`、Handoff 的 `handoff.evidence.inspect` 和 Prompt 的 `prompt.use`;`scope.contribute` 可以蕴含 acknowledge、prepare、commit、Memory contribution、Artifact/Prompt -Candidate proposal 和 Outcome 写入。反向蕴含不成立:任何 exact viewer/user role 都不能得到 `scope.read` 或 -`scope.contribute`。`scope.read` 不蕴含 `skill.publish`。 +Candidate proposal 和 Outcome 写入。反向蕴含不成立:任何 resource viewer/user role 都不能得到 `scope.read` 或 +`scope.contribute`。 ## Built-in roles | Role | Granted actions | | --- | --- | -| `handoff.viewer` | `artifact.read`, `handoff.evidence.read` on one exact `family=handoff` Artifact | -| `handoff.receiver` | viewer actions plus `handoff.acknowledge` on one exact Handoff | -| `artifact.viewer` | `artifact.read` on one compatible exact Artifact Revision or selector | -| `prompt.user` | `artifact.read`, `prompt.use` on one exact `family=prompt` Artifact | -| `skill.publisher` | `artifact.read`, `skill.publish` on one exact managed Skill Revision | +| `handoff.viewer` | `artifact.read`, `handoff.evidence.inspect` on one logical `family=handoff` Artifact | +| `handoff.receiver` | viewer actions plus `handoff.acknowledge` on one logical Handoff | +| `artifact.viewer` | `artifact.read` on one compatible logical Artifact or selector | +| `prompt.user` | `artifact.read`, `prompt.use` on one logical `family=prompt` Artifact | | `scope.viewer` | `scope.read` | | `scope.contributor` | `scope.read`, `scope.contribute` | | `scope.reviewer` | `scope.read`, `scope.review` | @@ -629,29 +633,29 @@ Candidate proposal 和 Outcome 写入。反向蕴含不成立:任何 exact vie | `server.observer` | `server.observe` | | `server.admin` | all server, scope, and Artifact Family actions | -所有 exact-resource role 对其绑定内容都是只读的。`handoff.receiver` 只额外允许创建独立 Receipt;`skill.publisher` 只允许 -向 Server 配置的 target 写 projection。两者都不能修改源 Handoff 或 Skill Revision。原资源的 mutation 必须由独立的 +所有 resource role 对其绑定内容都是只读的。`handoff.receiver` 只额外允许创建独立 Receipt;发布可读 Skill 只向 Server +配置的 target 写 projection。两种 operation 都不能修改源 Handoff 或 Skill Revision。原资源的 mutation 必须由独立的 scope role 和对应领域 lifecycle 授权。 首版不允许通过公共 API 创建新 role 或修改 role-to-action mapping。固定角色让 OpenAPI、Dashboard 和 adapter conformance test 拥有稳定语义;企业 PDP 可以在外部把自定义组织角色映射为这些 action。 拥有 `scope.delegate` 的 Principal 只能创建 `handoff.viewer` 或 `handoff.receiver`,且只能针对该 scope 中已经存在的 -精确 Handoff。创建 scope role 需要 `scope.admin`;创建 `server.admin` 需要现有 `server.admin` 和 deployment policy +逻辑 Handoff。创建 scope role 需要 `scope.admin`;创建 `server.admin` 需要现有 `server.admin` 和 deployment policy 允许。任何 Principal 都不能授予自己高于调用方管理边界的权限。 -首版只有 `scope.admin` 可以在所管理的 scope 中创建 `artifact.viewer`、`prompt.user` 或 `skill.publisher` Binding。 -`artifact.viewer` 只能绑定到 Family registry 声明兼容的 exact Revision 或 selector;`prompt.user` 和 `skill.publisher` 分别 -只能绑定 approved `family=prompt` 和 `family=skill` Artifact。Role 与 Artifact Family Access Profile 或 Resource Kind +首版只有 `scope.admin` 可以在所管理的 scope 中创建 `artifact.viewer` 或 `prompt.user` Binding。 +`artifact.viewer` 只能绑定到 Family registry 声明兼容的逻辑 Artifact 或 selector;`prompt.user` 只能绑定 approved +`family=prompt` Artifact。Role 与 Artifact Family Access Profile 或 Resource Kind 不匹配时返回 422, 授权不足时返回 403;Server 不能把不匹配的 role text 原样交给外部 RelationshipWriter。 -| Resource or Artifact Family Profile | Grantable exact roles | Binding administrator | +| Resource or Artifact Family Profile | Grantable resource roles | Binding administrator | | --- | --- | --- | | `artifact` with `family=handoff` | `handoff.viewer`, `handoff.receiver` | `scope.delegate`, `scope.admin`, or `server.admin` | | `artifact` with `family=memory` and `memory_entry` selector | `artifact.viewer` | `scope.admin` or `server.admin` | | `artifact` with `family=experience` | `artifact.viewer` | `scope.admin` or `server.admin` | -| `artifact` with `family=skill` | `artifact.viewer`, `skill.publisher` | `scope.admin` or `server.admin` | +| `artifact` with `family=skill` | `artifact.viewer` | `scope.admin` or `server.admin` | | `artifact` with `family=prompt` | `artifact.viewer`, `prompt.user` | `scope.admin` or `server.admin` | ## Authorization request and decision @@ -689,11 +693,11 @@ class AuthorizationProvider(Protocol): "resource": { "type": "artifact", "scope_id": "project:payments", - "reference": { + "identity": { "family": "handoff", - "artifact_id": "project:payments", - "revision": 12 - } + "artifact_id": "project:payments" + }, + "selector": null }, "context": { "request_id": "pc-01K...", @@ -732,34 +736,27 @@ target adapter 或 filesystem。它不提供 client-authored Boolean policy DSL "resource": { "type": "artifact", "scope_id": "project:payments", - "reference": {"family": "skill", "artifact_id": "retry-runbook", "revision": 4} - } - }, - { - "action": {"name": "skill.publish"}, - "resource": { - "type": "artifact", - "scope_id": "project:payments", - "reference": {"family": "skill", "artifact_id": "retry-runbook", "revision": 4} + "identity": {"family": "skill", "artifact_id": "retry-runbook"}, + "selector": null } } ] } ``` -业务请求中的 `target_id` 不进入 requirements。只有上述两个 decision 都 allow 后,Server 才解析该参数。 +业务请求中的 Revision 和 `target_id` 不进入 Access Resource。只有 decision allow 后,Server 才解析这些业务参数。 -“scope role 或 exact role” 这类替代关系不需要 `any` 表达式。PEP 请求 child-resource action,Provider 根据可信 parent -relation 判断 scope role 是否蕴含该 action;exact Binding 则直接作用于 child resource。这样不同 Provider 不必实现任意 +“scope role 或 resource role” 这类替代关系不需要 `any` 表达式。PEP 请求 child-resource action,Provider 根据可信 parent +relation 判断 scope role 是否蕴含该 action;逻辑 Binding 则直接作用于 child resource。这样不同 Provider 不必实现任意 嵌套策略表达式。 `resolve_resource_filter` 是安全列表功能的必要能力。`AuthorizedResourceFilter` 是当前 Principal 和 action 专属的 -Server-consumable filter,由两类约束组成:exact Binding 产生的有界 canonical resource key,以及父级角色产生的有界 +Server-consumable filter,由两类约束组成:逻辑 Binding 产生的有界 canonical resource key,以及父级角色产生的有界 server/scope constraint。父级 constraint 表示“Repository 可以在该 parent、请求的 Resource Kind 和 Family 内查询”,不是 -客户端可提交的 wildcard。Filter 还携带 policy revision;Server 必须校验其结构和上限,再把 exact key 与 parent +客户端可提交的 wildcard。Filter 还携带 policy revision;Server 必须校验其结构和上限,再把逻辑 resource key 与 parent constraint 的并集下推到同一次 Repository query,在计算 total、排序和分页前完成过滤。 -内置 Provider 可以直接从 Binding Store 产生 exact key 和 parent constraint,因此不需要镜像整个 Artifact catalog。 +内置 Provider 可以直接从 Binding Store 产生逻辑 resource key 和 parent constraint,因此不需要镜像整个 Artifact catalog。 外部 Provider 可以返回等价的授权 filter,或由 adapter 根据可信 relationship search 生成。只支持 point check、无法安全 产生该 filter 的 Provider 不得先查询全部 Artifact、Project 或 Scope 再逐项过滤;对应 list operation 应返回 503,或在 配置阶段被判为不具备 `safe_resource_filtering` capability。 @@ -797,7 +794,7 @@ OPA、Cerbos 或通用 AuthZEN adapter 可以只提供 decision;此时 PowerCo | --- | --- | | `binding_id` | Server-generated opaque ID | | `subject` | canonical `PrincipalRef` | -| `resource` | canonical exact `ResourceRef` | +| `resource` | canonical logical `ResourceRef` | | `role` | one fixed role name | | `granted_by` | authenticated Principal recorded by Server | | `reason` | optional bounded human explanation | @@ -826,7 +823,7 @@ OpenAPI source of truth 增加以下 operation: | `POST /v1/access/resources/list` | 列出当前 Principal 可访问的资源 identity | current Principal only | | `POST /v1/access/roles/list` | 返回固定角色及 action vocabulary | authenticated Principal | | `POST /v1/access/bindings/list` | 列出调用方可管理的 Binding | `scope.delegate`, `scope.admin`, or `server.admin` | -| `POST /v1/access/bindings/create` | 创建 Family-compatible exact-resource 或管理级 Binding | resource-specific administration action | +| `POST /v1/access/bindings/create` | 创建 Family-compatible logical-resource 或管理级 Binding | resource-specific administration action | | `POST /v1/access/bindings/revoke` | CAS revoke 一个 Binding | same administration boundary | | `POST /v1/access/bindings/replace` | 原子撤销不可变 Binding 并创建其后继 Binding | same administration boundary | | `POST /v1/access/audit/list` | 查询安全审计事件 | `scope.admin` or `server.admin` | @@ -855,42 +852,43 @@ service。Access API 只用于解释和 UI preflight,不能替代业务请求 | --- | --- | | `prepare_handoff`, `finalize_handoff`, `handoff_current_work` | `scope.contribute` on request `scope_id` | | `commit_handoff` | `scope.contribute` on request `scope_id` | -| `continue_handoff(selection=latest)` | `scope.read` on request `scope_id` | -| `continue_handoff(selection=exact)` | `artifact.read` and `handoff.evidence.read` on exact `family=handoff` Artifact, directly or through parent `scope.read` | +| `continue_handoff(selection=latest)` | `artifact.read` and `handoff.evidence.inspect` on logical `family=handoff` Artifact, directly or through parent `scope.read` | +| `continue_handoff(selection=exact)` | `artifact.read` and `handoff.evidence.inspect` on logical `family=handoff` Artifact, directly or through parent `scope.read` | | `continue_handoff(selection=prepared)` | `scope.read` on request `scope_id` | -| `acknowledge_handoff` with exact receipt | `scope.contribute` or `handoff.acknowledge` on exact Revision | +| `acknowledge_handoff` with exact receipt | `scope.contribute` or `handoff.acknowledge` on the logical Handoff selected by the exact Revision | | `record_task_outcome` | `scope.contribute` on request `scope_id` | -| aggregated Handoff Report queries | scope-level read; exact Handoff grant is insufficient | +| aggregated Handoff Report queries | scope-level read; logical Handoff grant is insufficient | | Handoff Report administration | `scope.admin` or appropriate server administration action | -当 exact receiver 调用 Continue 时,请求必须提供 `selection=exact` 和 exact `ArtifactReference`。Server 先建立 Handoff -ArtifactResourceRef 并判定,再读取 Revision。它不能先解析 latest 再检查,也不能在 exact 缺失时回退到 latest。 +receiver 调用 Continue 时,Server 在读取 Revision 前先建立逻辑 Handoff ArtifactResourceRef。`selection=exact` 从请求的 +精确 `ArtifactReference` 派生逻辑 identity;`selection=latest` 使用该 scope 注册的逻辑 Handoff identity。授权通过后才 +解析所请求的 Revision 及其 manifest。 Prepared Handoff 可以包含由调用方提交的完整内容,因此窄授权模式不接受 `selection=prepared`。只有已经拥有 `scope.read` 的 Principal 才能用 prepared selection 解引用 scope evidence。 ## Artifact Family operation requirements -Family operation 映射如下。表中的 “scope or exact” 由 Provider 的 parent relation 实现,不让客户端选择绕过路径: +Family operation 映射如下。表中的 “scope or logical resource” 由 Provider 的 parent relation 实现,不让客户端选择绕过路径: | Operation family | Required authorization | | --- | --- | -| Memory search/list/changes | `scope.read` on request `scope_id`;exact Memory grant 不足 | -| exact Memory get | `artifact.read` on exact `family=memory` Artifact plus complete `memory_entry` selector, directly or through parent `scope.read` | -| Memory flush/remember/revise/retire | `scope.contribute`; exact viewer grant 不足 | -| approved Experience/managed Skill exact get | `artifact.read` on exact `ArtifactReference`, directly or through parent `scope.read` | +| Memory search/list/changes | `scope.read` on request `scope_id`;logical Memory Entry grant 不足 | +| exact Memory get | `artifact.read` on logical `family=memory` Artifact plus `memory_entry.entry_id`, directly or through parent `scope.read` | +| Memory flush/remember/revise/retire | `scope.contribute`; logical viewer grant 不足 | +| approved Experience/managed Skill exact get | `artifact.read` on the logical Artifact identity derived from the exact request, directly or through parent `scope.read` | | Experience/Skill propose or generate | `scope.contribute` | -| Candidate list/get | `scope.read`; exact Artifact grant 不暴露 Candidate | +| Candidate list/get | `scope.read`; logical Artifact grant 不暴露 Candidate | | Candidate revise/approve/reject | `scope.review` | -| approved Prompt exact get | `artifact.read` on exact `family=prompt` Artifact, directly or through parent `scope.read` | +| approved Prompt exact get | `artifact.read` on logical `family=prompt` Artifact, directly or through parent `scope.read` | | approved Prompt render/use | `prompt.use`, directly or through parent `scope.read` | | Prompt propose/revise | Prompt lifecycle 定义的 Candidate operation plus `scope.contribute` | -| list enabled publication targets for an exact managed Skill | `artifact.read` **and** `skill.publish` on the same exact `family=skill` Artifact | -| publish managed Skill | `artifact.read` **and** `skill.publish` on the same exact `family=skill` Artifact | +| list enabled publication targets for an exact managed Skill | `artifact.read` on the logical `family=skill` Artifact | +| publish managed Skill | `artifact.read` on the logical `family=skill` Artifact | -Exact get resolver 必须从已验证 request 中直接取得完整 identity。Memory `entry_id`、Artifact `artifact_id` 或 Prompt name -都不能单独作为授权 key。Search、current-head selection、aggregated projection 和 Candidate Inbox 仍是 collection -operation,不能通过一个 exact grant 进入。 +Exact get resolver 必须从已验证业务 request 派生完整逻辑 identity,并在授权时丢弃 Revision 字段。缺少 scope 和 Family +的 Memory `entry_id`、Artifact `artifact_id` 或 Prompt name 不能单独作为授权 key。Search、aggregated projection 和 +Candidate Inbox 仍是 collection operation,不能通过一个逻辑 grant 进入。 Prompt Family Access Profile 只规范 authorization vocabulary 和 resolver contract。部署只有在注册 `family=prompt` 的 immutable approved Artifact lifecycle,并提供与本节一致的 exact get/use operation 后,才能报告该 Family enabled。 @@ -900,7 +898,7 @@ immutable approved Artifact lifecycle,并提供与本节一致的 exact get/us `target_id` 是 Server 配置的发布 operation parameter,不是授权 key 或 Resource。只有 `server.admin` 可以配置、修改或 移除 target;详细 target status 由 `server.observe` 或 `server.admin` 保护。Operator status response 只能返回 target ID、 Agent kind、capability、desired/applied exact Revision、稳定 state 和安全 reason code,不能返回 host path、Agent home、 -credential 或原始 OS error。在发布和 publisher target-list 请求中,Server 必须先允许 exact Skill 的两个 requirement,再 +credential 或原始 OS error。在发布和 publisher target-list 请求中,Server 必须先允许逻辑 Skill 的 `artifact.read`,再 解析 `target_id` 或读取 target registry;独立的 operator status 请求则先判定 server-level action。 ## OpenAPI access metadata @@ -929,20 +927,20 @@ x-powercontext-access: Resolver 是 Server-owned、经过单元测试的确定性函数。它只能从已验证 request model 和 route metadata 建立 AccessRequest,不能读取业务 Repository 后才决定是否授权。 -需要多个 requirement 的 operation 使用 resolver。Publisher target selection 和 publish 复用同一个 exact Skill resolver: +需要从业务参数派生资源的 operation 使用 resolver。Publisher target selection 和 publish 复用同一个逻辑 Skill resolver: ```yaml /v1/skills/publication-targets/list: post: operationId: list_skill_publication_targets x-powercontext-access: - resolver: publish_managed_skill_access + resolver: exact_skill_access /v1/skills/publish: post: operationId: publish_managed_skill x-powercontext-access: - resolver: publish_managed_skill_access + resolver: exact_skill_access ``` 生成的 `Operation.access` 必须能够表示 static single requirement 或 named resolver。Resolver 的 Server-side return type @@ -994,7 +992,7 @@ HTTP 和 MCP 对同一 Principal、action、resource、policy revision 必须得 ```text AuthorizationProvider.resolve_resource_filter - -> validate bounded exact keys and parent constraints + -> validate bounded logical resource keys and parent constraints -> Repository query applying their union -> stable pagination -> response @@ -1007,11 +1005,11 @@ Repository.list_all -> page -> check each item -> remove denied rows ``` 这种实现会泄漏总数、cursor、空洞和时序,也可能让授权用户永远看不到后面的记录。Repository 必须在同一个 query 中 -应用 exact key 与 parent constraint 的并集;`total`、cursor 和 page boundary 必须只描述授权后的集合。 +应用逻辑 resource key 与 parent constraint 的并集;`total`、cursor 和 page boundary 必须只描述授权后的集合。 -Artifact exact receiver 通过 `/v1/access/resources/list` 的 Resource Kind 和 Family filter 发现授权资源;这些资源不会因此 +Artifact logical receiver 通过 `/v1/access/resources/list` 的 Resource Kind 和 Family filter 发现授权资源;这些资源不会因此 出现在聚合 Project、Workstream、Memory search、Artifact catalog 或 Candidate Inbox。只有 scope-level read 才允许进入 -对应聚合查询。发布 target 不是授权资源,不出现在该列表中。拥有 exact Skill 发布权限的 Principal 通过 Skill domain +对应聚合查询。发布 target 不是授权资源,不出现在该列表中。可以发布所选 Skill Revision 的 Principal 通过 Skill domain preflight 取得脱敏 target 选项;详细运维状态通过受 `server.observe` 或 `server.admin` 保护的 Server operation 查询。 ## Audit and diagnostics @@ -1040,17 +1038,17 @@ provider diagnostics 留在受保护的 operator channel。 Commit Handoff 与创建外部授权关系不是跨系统原子事务。UI 中的“发送给 B”按以下可恢复步骤执行: -1. commit 或复用同一精确 Handoff Revision; +1. commit 或复用属于同一逻辑 Handoff 的 Revision; 2. 使用稳定 idempotency key 创建 Binding; 3. 只有两步都成功才显示“已分享”; 4. 第二步失败时显示“交接已保存,但 B 尚不可见”,并只重试 Binding create; 5. 不重新 prepare、commit 或创建另一个 Revision。 Binding 已成功而客户端丢失响应时,同一 idempotency key 返回原 Binding。外部 RelationshipWriter 无法提供等价幂等 -保证时,adapter 必须先执行安全的 exact relationship lookup,或声明不支持 self-service mutation。 +保证时,adapter 必须先执行安全的 canonical relationship lookup,或声明不支持 self-service mutation。 所有 Artifact Family 分享遵循相同的 “persist/approve first, bind second” 原则。Binding create 失败不回滚或重建业务 -Revision;客户端只重试同一个 idempotent Binding mutation。Skill publish 则是一次受双重授权保护的 projection +Revision;客户端只重试同一个 idempotent Binding mutation。Skill publish 则是一次受逻辑 Skill read decision 保护的 projection operation,不创建内容 Revision,也不创建 target Binding 或改变 target authorization state。Target apply 失败保留可重试的 desired/applied 状态和安全 reason,不把本地路径或底层错误写入公共 audit。 @@ -1062,7 +1060,7 @@ Receipt 创建仍使用现有 exact-selection 和 evidence rules。授权判定 ### Built-in provider -内置 profile 使用固定角色和 Server-owned Binding Store,支持 point check、batch check、从 exact/scope/server Binding +内置 profile 使用固定角色和 Server-owned Binding Store,支持 point check、batch check、从逻辑 Artifact/scope/server Binding 生成可下推 `AuthorizedResourceFilter`、create、revoke 和 audit。它不需要保存业务 resource inventory,是本地部署和 conformance test 的参考语义;它不提供用户密码、目录或自定义 policy language。 @@ -1078,13 +1076,13 @@ Casbin adapter 可以使用带 domain 的 RBAC: - role assignment 和 policy mutation 通过 Casbin management API 与持久化 adapter 完成。 Casbin domain 是 adapter policy namespace,不把 `scope_id` 变成认证或 tenant 证明。Adapter 仍从 Server 传入的可信 -ResourceRef 建立 domain。生成列表 filter 时,exact object policy 产生 canonical key,scope/server role assignment 产生 +ResourceRef 建立 domain。生成列表 filter 时,逻辑 object policy 产生 canonical key,scope/server role assignment 产生 对应 parent constraint;Casbin adapter 不需要枚举业务 Repository。 ### OpenFGA adapter -OpenFGA 适合表达用户、group、scope 和 exact child resource 的关系。所有 Artifact Family 使用一个 `artifact` object type; -object ID 包含 canonical Family、Revision 和 selector,Server 在 tuple write 前用 Family registry 校验 relation compatibility。 +OpenFGA 适合表达用户、group、scope 和逻辑 child resource 的关系。所有 Artifact Family 使用一个 `artifact` object type; +object ID 包含 canonical scope、Family、Artifact ID 和 selector,不包含 Revision。Server 在 tuple write 前用 Family registry 校验 relation compatibility。 这样新增只读 Family 不需要新增 OpenFGA type: ```text @@ -1118,12 +1116,10 @@ type artifact define handoff_viewer: [user] define handoff_receiver: [user] define prompt_user: [user] - define skill_publisher: [user] - define can_read: viewer or handoff_viewer or handoff_receiver or prompt_user or skill_publisher or can_read from parent + define can_read: viewer or handoff_viewer or handoff_receiver or prompt_user or can_read from parent define can_read_handoff_evidence: handoff_viewer or handoff_receiver or can_read from parent define can_acknowledge_handoff: handoff_receiver or can_contribute from parent define can_use_prompt: prompt_user or can_read from parent - define can_publish_skill: skill_publisher or can_admin from parent ``` Adapter 把 `server.observe` 映射到 `server#can_observe`,把 `server.admin` 映射到 `server#can_admin`。`admin from parent` @@ -1132,8 +1128,8 @@ Adapter 把 `server.observe` 映射到 `server#can_observe`,把 `server.admin` Adapter 使用固定 authorization model ID 执行 Check、ListObjects 和 tuple write。Tuple 只保存 opaque ID,不保存 email 或 Handoff 文本。Model migration 在 deployment configuration 中显式切换,不自动使用“latest model”。 -列表中,exact relation 可以通过 ListObjects 产生 canonical key;scope/server role 直接产生可信 parent constraint,不要求 -为每一个没有 exact Binding 的业务 Artifact 预先写入 object tuple。 +列表中,逻辑 resource relation 可以通过 ListObjects 产生 canonical key;scope/server role 直接产生可信 parent constraint, +不要求为每一个没有逻辑 Binding 的业务 Artifact 预先写入 object tuple。 ### AuthZEN, OPA, and Cerbos adapters @@ -1148,13 +1144,12 @@ AuthZEN adapter 把 `AccessRequest` 映射为 Authorization API 的 subject、ac ## Configuration and compatibility -Server 提供三种显式 mode: +Server 提供两种显式 mode: | Mode | Behavior | | --- | --- | | `disabled` | 保持单用户、单 trust-domain 的现有行为;Access API 不可用,不宣称多用户隔离 | -| `legacy-static-admin` | 现有静态 Bearer 映射为 deployment-local `server.admin` Principal | -| `enforced` | 认证 Provider 和 AuthorizationProvider 都是 required dependency,所有业务 operation 执行 PEP | +| `enforced` | Authentication Provider 和 AccessControlService 是 required dependency,所有业务 operation 执行 PEP | 升级不能因为配置了外部身份但漏配 PDP 而回退到 `disabled`。Mode 必须显式,capabilities 和 readiness 报告当前 mode 与 是否支持 relationship management、batch check 和 `safe_resource_filtering`。 @@ -1209,12 +1204,12 @@ Resource Kind 或可绑定 profile。Provider 不支持 `safe_resource_filtering 1. **Contract and Principal**:OpenAPI Access model、operation metadata、generated `Operation.access`、可信 request Principal 和 stable errors。 2. **Built-in PEP/PDP**:固定角色、Binding Store、`_add_route()` authorization wrapper、point/batch check、audit。 -3. **Handoff exact receiver**:commit 后创建 Binding、exact Continue、citation-manifest resolver、exact acknowledge、 - revoke 和 expiration。 -4. **Artifact Family Access Profiles**:统一 ArtifactResourceRef、Family registry、Memory selector、exact read/use resolver、 +3. **Handoff logical receiver**:commit 后创建 Binding、exact/latest Continue、citation-manifest resolver、exact acknowledge、 + future-Revision visibility、revoke 和 expiration。 +4. **Artifact Family Access Profiles**:统一 ArtifactResourceRef、Family registry、Memory selector、logical read/use resolver、 角色兼容性与非传递 lineage。 5. **Skill publication**:Server-configured host-local target registry、publisher-safe selection、operator status、同一 - exact Skill 上的 read plus publish requirement,以及脱敏失败状态。 + exact 业务发布使用逻辑 Skill 授权,以及脱敏失败状态。 6. **Safe listing and UI**:authorized resource listing、Handoff inbox、“Shared with me”、Dashboard permission projection、 授权后分页。 7. **MCP parity**:Principal 通过 internal bridge 传播、tool discovery UX 和调用时 enforcement。 @@ -1228,45 +1223,45 @@ Resource Kind 或可绑定 profile。Provider 不支持 `safe_resource_filtering RFC 实现完成需要通过以下 observable scenarios: - 无身份访问受保护 operation 返回 401; -- A 有 `scope.delegate` 时只能把所属 scope 中已存在、committed 的 exact Handoff Revision 以 `handoff.viewer` 或 +- A 有 `scope.delegate` 时只能把所属 scope 中至少有一个 committed Revision 的逻辑 Handoff 以 `handoff.viewer` 或 `handoff.receiver` 授予 B;其他 Artifact Family 或 role 返回 422,缺少该 action 时返回 403,且都不写 Binding; -- B 可以读取、Continue 和 acknowledge 被授予的 exact Revision; -- B 请求 latest、相邻 Revision、聚合 Handoff Report、Memory list、Source list 和 Task Outcome write 均被拒绝; +- B 可以读取并 Continue 已授予 Handoff 的历史、当前和未来 Revision,使用 `latest`,并 acknowledge 所选精确 Revision; +- B 读取其他 Handoff、聚合 Handoff Report、Memory list、Source list 和 Task Outcome write 均被拒绝; - B 只能通过被授权 Handoff 的 resolver 读取 manifest citation,不能用任意 citation 调用通用读取接口; - `handoff.viewer` 不能 acknowledge,`handoff.receiver` 可以; - `accepted` Receipt 不产生新的 Binding 或 scope role; -- revoke 或 expiration 后,B 的后续 access 被拒绝,authorized resource list 不再包含该 Revision; +- revoke 或 expiration 后,B 的 access 被拒绝,authorized resource list 不再包含该逻辑 Handoff; - Binding create/revoke 的 CAS、idempotency 和 audit 行为稳定; - 403 不泄漏资源是否存在,list cursor 和 total 只描述授权集合; - PDP unavailable 返回 503,且 application service、Repository 和 mutation 未被调用; - MCP internal bridge 使用原 Principal 并执行与 HTTP 相同的 deny; - Dashboard 隐藏控制失效或被绕过时,API 仍拒绝请求; -- legacy static token 只在显式 mode 中映射为 local admin; +- 显式 `enforced` mode 下,legacy static token 只在没有注入 Authentication Provider 时映射为 local admin; - `server.observer` 可以读取受保护的服务和 publication status,但不能修改 access 或 target configuration; `server.admin` 可以执行两类 operation,且 Built-in、Casbin 和 OpenFGA 的结果一致; - Built-in、Casbin/OpenFGA 和 AuthZEN adapter 对同一 conformance vector 返回相同结果; -- 请求不能提交独立的 content profile;未知/disabled Family、`revision=latest`、缺失或多余 selector,以及 +- 请求不能提交独立的 content profile,也不能在 Access Resource 中提交 Revision;未知/disabled Family、缺失或多余 selector,以及 Family-role mismatch 返回 422 且不写 Binding; - `artifact.viewer` 在 Experience、Skill、Prompt 和 `memory_entry` selector 上始终只映射为 `artifact.read`,不会因 Family 不同隐式增加 use、publish、acknowledge 或 mutation action; -- `artifact.viewer` 可以通过 `family=memory` 和完整 `memory_entry` selector get 被授权的 Memory Entry,但不能 - search/list/current/revise/retire 或读取相邻版本; -- exact Artifact viewer 可以读取 approved Experience/managed Skill Revision,但不能看到 Candidate、future Revision 或 - 解引用 lineage body; +- `artifact.viewer` 可以通过 `family=memory` 和 `entry_id` selector get 被授权 Memory Entry 的历史及未来版本,但不能 + search/list/revise/retire 或读取其他 Entry; +- logical Artifact viewer 可以读取一个 Experience/managed Skill 的 approved Revision,但不能看到 Candidate、其他 Artifact + 或解引用 lineage body; - `artifact.viewer` 只能读取 Prompt,`prompt.user` 可以显式 use;两者都不能改变宿主 instruction priority 或自动进入 普通 recall; -- exact-resource role 即使知道 expected version,也不能 revise、retire、replace 或提交共享原件的下一 Revision; +- logical-resource role 即使知道 expected version,也不能 revise、retire、replace 或提交共享原件的下一 Revision; - acknowledge 创建的 Receipt 和 publish 创建的 target projection 不改变源资源的 identity、content、Revision 或 digest; - fork、import 或 copy 在没有目标 scope 的 `scope.contribute` 时被拒绝;授权后创建新的 identity 或 Candidate,并保持原资源 不变; -- managed Skill publish 只有在同一个 exact Skill 的 `artifact.read` 和 `skill.publish` 均 allow 时执行,任一 - deny/unavailable 都不得解析 `target_id`、检查 host path 或写 projection;授权通过后,unknown 或 disabled target 仍必须 +- managed Skill publish 只有在逻辑 Skill 的 `artifact.read` allow 时执行;deny/unavailable 都不得解析 `target_id`、 + 检查 host path 或写 projection;授权通过后,unknown 或 disabled target 仍必须 拒绝发布; -- publisher target-list 只有在同一个 exact Skill 的两个 requirement 均 allow 后才能读取 registry,并且只返回 enabled +- publisher target-list 只有在逻辑 Skill 的 `artifact.read` allow 后才能读取 registry,并且只返回 enabled target 的 safe identity/capability;详细 status 仍要求 `server.observe` 或 `server.admin`; - 首版拒绝 remote Receiver target,并且不得尝试读取 remote credential 或建立网络连接; -- `skill.publisher` 可以把被授权的 exact Skill 发布到 deployment 中任一 enabled target;首版没有 target Binding 或 - per-target delegation; +- 拥有 `artifact.read` 的 Principal 可以把已授权逻辑 Skill 的所选精确 Revision 发布到 deployment 中任一 enabled target; + 首版没有 target Binding 或 per-target delegation; - `resources/list` 的 total、cursor 和 rows 只描述当前 Principal 对所选 Resource Kind 和 Artifact Family 有权发现的集合; - 不支持 Prompt lifecycle 的部署拒绝 `family=prompt` Binding;没有可用发布 operation 的部署准确报告 `operation_capabilities.skill_publication.enabled=false`; @@ -1281,7 +1276,7 @@ membership,不冻结 private call order。 每个业务请求增加一次授权判定,外部 PDP 还会增加网络依赖和延迟。安全列表要求 Provider 产生有界、可下推的 `AuthorizedResourceFilter`,只有 point-check 的简单 adapter 无法支持全部 Dashboard 列表。 -精确 Handoff 分享必须先 commit,因此不能把临时 Prepared Handoff 直接变成可撤销的跨用户资源。这增加一步持久化, +逻辑 Handoff 分享必须先 commit,因此不能把临时 Prepared Handoff 直接变成可撤销的跨用户资源。这增加一步持久化, 但避免为临时 payload 发明第二套 identity 和 ACL。 判定和关系管理分离使 adapter interface 比单一 `check()` 更复杂;另一方面,假设所有外部 PDP 都允许 PowerContext 写 @@ -1290,11 +1285,11 @@ policy 会制造错误的可移植性承诺。 撤销只能阻止未来访问,无法删除接收方已经阅读、截图或导出的信息。包含高度敏感内容的 Handoff、Memory、Artifact 或 Prompt 仍需要最小化内容、外部数据分类和导出控制。 -Artifact Family Access Profile 增加了 registry、selector、角色兼容矩阵和 conformance vector。Skill publish 还需要在 -同一个 exact Artifact 上判定 `artifact.read` 和 `skill.publish`;外部 PDP 不提供原子 multi-requirement decision 时会增加 -延迟,并留下必须记录 policy revision 的有界 TOCTOU 风险。 +Artifact Family Access Profile 增加了 registry、selector、角色兼容矩阵和 conformance vector。Skill publish 在解析精确 +业务 Revision 前检查逻辑 Artifact 的 `artifact.read`;外部 PDP 会增加延迟,并留下必须记录 policy revision 的有界 +TOCTOU 风险。 -首版不把 target 纳入授权策略。拥有某个 exact Skill 的 `skill.publisher` 可以把它发布到 deployment 中任一 enabled +首版不把 target 纳入授权策略。拥有某个逻辑 Skill 的 `artifact.read` 可以把它发布到 deployment 中任一 enabled target。需要按 target 隔离发布权限的部署必须暂缓该能力、隔离 deployment,或等待独立 RFC 定义通用 `execution_target` Resource;本 RFC 不用一个 Skill 专属资源提前固化这套模型。 @@ -1323,7 +1318,7 @@ AuthZEN 统一。 ## Alternative: only use scope-level roles 只授予 `scope.viewer` 容易实现,但 B 会看到整个 Workstream 的 Memory、Source、历史和 Report。对于临时接力不符合最小 -权限原则。Scope roles 保留给长期协作,exact-resource Binding 负责一次性交接或资产分享。 +权限原则。Scope roles 保留给长期协作,logical-resource Binding 负责一次性交接或资产分享。 ## Alternative: add one share API per domain @@ -1333,15 +1328,15 @@ Family role compatibility 和 resolver;业务 API 仍由各 domain 拥有。 ## Alternative: 每个 Artifact Family 使用一个 Resource Kind -为 `handoff`、`memory_entry`、`experience`、`skill` 和 `prompt` 分别增加 `ResourceRef.type`,会重复 scope parent、exact -Revision、canonical key 和只读分享结构;每新增一个 Family 还必须扩展 OpenAPI discriminator 和外部 PDP object type。 +为 `handoff`、`memory_entry`、`experience`、`skill` 和 `prompt` 分别增加 `ResourceRef.type`,会重复 scope parent、逻辑 +Artifact identity、canonical key 和只读分享结构;每新增一个 Family 还必须扩展 OpenAPI discriminator 和外部 PDP object type。 它也会让 `ResourceRef.type` 与 `ArtifactReference.family` 成为两个可能冲突的内容 discriminator。本 RFC 选择统一 `artifact` Resource Kind,由 Server 从 `ArtifactReference.family` 派生 Access Profile;只有 Memory 等需要更细授权单元的 Family 增加显式 selector。 ## Alternative: automatically recall every shared resource -把所有 exact grant 自动加入 PreparedContext 会混淆可见性与相关性,扩大 token budget,并让不可信 Prompt 或 Skill 在接收方 +把所有逻辑 grant 自动加入 PreparedContext 会混淆可见性与相关性,扩大 token budget,并让不可信 Prompt 或 Skill 在接收方 没有显式选择时影响模型。首版只提供授权发现与显式附加;后续若增加 shared collection 或 subscription,仍必须经过独立的 Context selection policy。 @@ -1366,7 +1361,7 @@ Casbin 适合 embedded RBAC,OpenFGA 适合关系和 group,OPA/Cerbos 适合 ## Alternative: store roles in access token -Token role 简单但对 exact Handoff grant、撤销、large resource set 和 policy update 不友好。Token 可以携带可信 identity +Token role 简单但对逻辑 Handoff grant、撤销、large resource set 和 policy update 不友好。Token 可以携带可信 identity 和 group claims,最终 resource decision 仍由 PDP 完成。 ## Alternative: authorize inside every Runtime method @@ -1408,12 +1403,11 @@ subject、action、resource、context 和 decision contract。本 RFC 对齐其 - Dashboard 如何从部署方的身份目录选择 canonical recipient;目录搜索本身不由本 RFC 的 Access API 提供; - enforced deployment 是否要求 Provider 同时支持 `safe_resource_filtering`,还是允许禁用相关 Dashboard 列表; - `handoff.receiver` 的产品默认过期时间是否由 deployment policy 决定,还是 UI 必须每次显式选择; -- exact receiver 创建 Receipt 后,UI 是否建议管理员另行授予 `scope.contributor`,但不能自动执行该升级; +- Handoff receiver 创建 Receipt 后,UI 是否建议管理员另行授予 `scope.contributor`,但不能自动执行该升级; - Prompt Artifact 的后续 lifecycle 采用固定 Review policy,还是区分个人私有模板与组织 approved template。 以下问题明确推迟:custom role、organization hierarchy、cross-tenant export、anonymous share link、temporary elevation、approval -workflow、通用 Source object-level ACL、动态 Memory collection、Artifact catalog 分享和自动跟随 future Revision。它们需要 -独立威胁模型和 RFC。 +workflow、通用 Source object-level ACL、动态 Memory collection 和 Artifact catalog 分享。它们需要独立威胁模型和 RFC。 # Future possibilities @@ -1432,5 +1426,5 @@ workflow、通用 Source object-level ACL、动态 Memory collection、Artifact - 带显式成员和 Revision manifest 的共享 collection,以及经过 Context policy 的订阅式选择; - 在有明确 revocation-staleness guarantee 后增加 bounded decision cache。 -这些扩展不能改变首版不变量:`scope_id` 不是 ACL,资源内容不授予权限,exact grant 不跟随 future Revision,读取不自动 -进入 Context 或获得执行权,所有 transport 在 Server PEP fail closed。 +这些扩展不能改变首版不变量:`scope_id` 不是 ACL,资源内容不授予权限,逻辑 grant 只跨 Revision 覆盖同一 identity, +读取不自动进入 Context 或获得执行权,所有 transport 在 Server PEP fail closed。 diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index 9d343f66c..1683dc663 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -565,6 +565,7 @@ paths: post: tags: [handoff] summary: Resolve a Handoff as untrusted historical input + description: Select an exact or latest business Revision through one logical Handoff grant and inspect the evidence declared by that Revision's immutable manifest. operationId: continue_handoff x-powercontext-access: resolver: continue_handoff_access @@ -3042,6 +3043,7 @@ components: MemoryEntryAccessSelector: type: object additionalProperties: false + description: Logical Memory entry selector; the Binding covers the entry's existing and future versions. required: [type, entry_id] properties: type: {type: string, enum: [memory_entry]} @@ -3049,6 +3051,7 @@ components: AccessArtifactIdentity: type: object additionalProperties: false + description: Logical Artifact identity; the Binding covers existing and future Revisions of the same Artifact. required: [family, artifact_id] properties: family: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} diff --git a/integrations/langgraph/examples/_local_server.py b/integrations/langgraph/examples/_local_server.py index 1134e5e1f..f2b88ffce 100644 --- a/integrations/langgraph/examples/_local_server.py +++ b/integrations/langgraph/examples/_local_server.py @@ -36,7 +36,7 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import InferenceConfig from powercontext.server.factory import create_server_app -from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings @contextmanager @@ -48,12 +48,10 @@ def local_powercontext_server(*, token: str | None = None) -> Iterator[str]: with TemporaryDirectory() as db_dir: settings = ServerSettings( - auth=AuthenticationConfig( - provider="static-bearer" if token is not None else None, + auth=BearerAuthConfig( token=None if token is None else SecretStr(token), ), access=AccessControlConfig(mode="enforced" if token is not None else "disabled"), - authorization_provider="builtin" if token is not None else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{db_dir}/memory.db"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=False), diff --git a/integrations/openclaw/README.md b/integrations/openclaw/README.md index 9faafa6f5..5bcb3740b 100644 --- a/integrations/openclaw/README.md +++ b/integrations/openclaw/README.md @@ -81,8 +81,6 @@ Start an authenticated Server from a protected environment: ```bash export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_AUTH_PROVIDER=static-bearer -export POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER=builtin export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_LOCAL_TOKEN" powercontext server run ``` diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 9d343f66c..1683dc663 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -565,6 +565,7 @@ paths: post: tags: [handoff] summary: Resolve a Handoff as untrusted historical input + description: Select an exact or latest business Revision through one logical Handoff grant and inspect the evidence declared by that Revision's immutable manifest. operationId: continue_handoff x-powercontext-access: resolver: continue_handoff_access @@ -3042,6 +3043,7 @@ components: MemoryEntryAccessSelector: type: object additionalProperties: false + description: Logical Memory entry selector; the Binding covers the entry's existing and future versions. required: [type, entry_id] properties: type: {type: string, enum: [memory_entry]} @@ -3049,6 +3051,7 @@ components: AccessArtifactIdentity: type: object additionalProperties: false + description: Logical Artifact identity; the Binding covers existing and future Revisions of the same Artifact. required: [family, artifact_id] properties: family: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index 4be6f69be..36df3850e 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -166,7 +166,6 @@ class ApiProtocol: "POWERCONTEXT_SERVER_MCP_ENABLED": "true", "POWERCONTEXT_SERVER_MCP_PATH": "/mcp", "POWERCONTEXT_SERVER_ACCESS_MODE": "disabled", - "POWERCONTEXT_SERVER_ACCESS_STATIC_PRESET": "true", "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "true", "POWERCONTEXT_SERVER_LOGGING_LEVEL": "INFO", "POWERCONTEXT_SERVER_LOGGING_FORMAT": "console", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 01173b263..407050141 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -530,6 +530,11 @@ "post": { "tags": ["handoff"], "summary": "Resolve a Handoff as untrusted historical input", + "description": "Select an exact or latest business " + "Revision through one logical Handoff " + "grant and inspect the evidence " + "declared by that Revision's immutable " + "manifest.", "operationId": "continue_handoff", "requestBody": { "content": { @@ -2778,6 +2783,11 @@ "additionalProperties": False, "type": "object", "required": ["type", "entry_id"], + "description": "Logical Memory entry " + "selector; the Binding " + "covers the entry's " + "existing and future " + "versions.", }, "AccessArtifactIdentity": { "properties": { @@ -2787,6 +2797,10 @@ "additionalProperties": False, "type": "object", "required": ["family", "artifact_id"], + "description": "Logical Artifact identity; " + "the Binding covers existing " + "and future Revisions of the " + "same Artifact.", }, "ArtifactAccessResource": { "properties": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index ffb876238..432dc9734 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -44,13 +44,10 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.experience import Experience from powercontext.builtin.artifacts.handoff import ( - HandoffArtifactCitation, HandoffCitation, HandoffEvidenceUnavailableError, HandoffGenerationUnavailableError, - HandoffMemoryCitation, HandoffScopeMismatchError, - HandoffSourceCitation, InvalidHandoffGenerationError, InvalidHandoffReferenceError, ) @@ -2078,72 +2075,26 @@ async def commit_handoff( async def continue_handoff( request: ContinueHandoffRequest, application: Annotated[ServerApplication, Depends(_require_application)], - http_request: Request, ) -> TransportHandoffResolution: handoff = application.handoff.for_scope(request.scope_id) - evidence_authorizer = _handoff_evidence_authorizer(http_request, request.scope_id) if request.selection is HandoffSelection.LATEST: _require_handoff_selection(request, prepared=False, revision=False) - result = await handoff.continue_latest(evidence_authorizer=evidence_authorizer) + result = await handoff.continue_latest() elif request.selection is HandoffSelection.PREPARED: _require_handoff_selection(request, prepared=True, revision=False) prepared = request.prepared if prepared is None: raise InvalidRuntimeRequestError("handoff-selection") - result = await handoff.continue_from( - mapping.runtime_prepared_handoff(prepared), - evidence_authorizer=evidence_authorizer, - ) + result = await handoff.continue_from(mapping.runtime_prepared_handoff(prepared)) else: _require_handoff_selection(request, prepared=False, revision=True) revision = request.revision if revision is None: raise InvalidRuntimeRequestError("handoff-selection") - result = await handoff.continue_from( - mapping.runtime_artifact_reference(revision), - evidence_authorizer=evidence_authorizer, - ) + result = await handoff.continue_from(mapping.runtime_artifact_reference(revision)) return mapping.handoff_resolution_response(result) -def _handoff_evidence_authorizer( - request: Request, - scope_id: str, -) -> Callable[[HandoffCitation], Awaitable[bool]] | None: - access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) - if access is None: - return None - principal = _require_principal() - context = _access_audit_context(CONTINUE_HANDOFF.operation_id) - - async def authorize(citation: HandoffCitation) -> bool: - if isinstance(citation, HandoffSourceCitation): - action = AccessAction.SCOPE_READ - resource = ResourceRef.scope(scope_id) - elif isinstance(citation, HandoffArtifactCitation): - action = AccessAction.ARTIFACT_READ - resource = ResourceRef.artifact( - scope_id, - family=citation.artifact_ref.family, - artifact_id=citation.artifact_ref.artifact_id, - ) - elif isinstance(citation, HandoffMemoryCitation): - action = AccessAction.ARTIFACT_READ - memory = citation.memory_citation - resource = ResourceRef.artifact( - scope_id, - family=memory.memory_ref.family, - artifact_id=memory.memory_ref.artifact_id, - selector=MemoryEntrySelector(entry_id=memory.entry_id), - ) - else: - return False - decision = await access.check(principal, action, resource, context=context) - return decision.allowed - - return authorize - - async def list_memory_entries( request: ListMemoryEntriesRequest, application: Annotated[ServerApplication, Depends(_require_application)], diff --git a/src/powercontext/server/authz/profiles.py b/src/powercontext/server/authz/profiles.py index 850d9e171..72bc03360 100644 --- a/src/powercontext/server/authz/profiles.py +++ b/src/powercontext/server/authz/profiles.py @@ -41,7 +41,7 @@ class ArtifactFamilyAccessProfile: additional_actions: frozenset[AccessAction] grantable_roles: frozenset[AccessRole] selector: Literal["forbidden", "memory_entry"] - transitivity: Literal["none", "independent_evidence"] = "none" + transitivity: Literal["none", "manifest"] = "none" mutation_semantics: frozenset[AccessAction] = frozenset() @property @@ -66,7 +66,7 @@ def subject_compatibility(self) -> dict[AccessRole, frozenset[str]]: }), grantable_roles=frozenset({AccessRole.HANDOFF_VIEWER, AccessRole.HANDOFF_RECEIVER}), selector="forbidden", - transitivity="independent_evidence", + transitivity="manifest", mutation_semantics=frozenset({AccessAction.ARTIFACT_WRITE}), ), "memory": ArtifactFamilyAccessProfile( diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index 7f8c59c62..f849ac431 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -26,6 +26,7 @@ from powercontext.server.factory import create_server_app from powercontext.server.logging import configure_server_logging from powercontext.server.settings import ( + MissingAuthenticationProviderError, MissingBearerTokenError, ServerSettings, UnauthenticatedNonLoopbackBindError, @@ -84,6 +85,8 @@ def run( hint = "Invalid value for --env-file" if env_file is not None else "Invalid Server configuration" typer.echo(f"{hint}: {error}", err=True) raise typer.Exit(code=2) from error + except MissingAuthenticationProviderError as error: + raise typer.BadParameter(_MISSING_BEARER_CLI_MESSAGE) from error def _run_configured_server(settings: ServerSettings) -> None: diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index cfdd51c97..0a8c21ee9 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -62,12 +62,12 @@ ResourceRef, access_control_for_mode, ) -from powercontext.server.authz.composition import open_builtin_access_control, open_casbin_access_control +from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.context import current_principal, current_request_id from powercontext.server.mcp import mount_mcp from powercontext.server.metrics import CONTENT_TYPE_LATEST, HttpMetricsMiddleware, ServerMetrics from powercontext.server.middleware import AuthenticationMiddleware -from powercontext.server.settings import ServerSettings +from powercontext.server.settings import MissingAuthenticationProviderError, ServerSettings from powercontext.server.tracing import HttpTracingMiddleware, ServerTracing from powercontext.server.web import mount_web_ui @@ -116,10 +116,12 @@ def create_server_app( """Build the Server process and mount MCP when configured.""" resolved = ServerSettings() if settings is None else settings - static_principal, configured_authentication, configured_access_control = _resolve_security_providers( - resolved, - access_control=access_control, - authentication_provider=authentication_provider, + static_principal, configured_authentication, configured_access_control, legacy_static_admin = ( + _resolve_security_providers( + resolved, + access_control=access_control, + authentication_provider=authentication_provider, + ) ) config = BuiltinConfig( runtime=resolved.runtime, @@ -144,27 +146,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: async with AsyncExitStack() as resources: active_access_control = configured_access_control if active_access_control is None and resolved.access.mode == "enforced": - administrators = ( - (static_principal,) - if resolved.auth.provider == "static-bearer" and resolved.access.static_preset - else () - ) - opener = ( - open_casbin_access_control - if resolved.authorization_provider == "casbin" - else open_builtin_access_control - ) active_access_control = await resources.enter_async_context( - opener( + open_builtin_access_control( resolved.database, - bootstrap_administrators=administrators, + bootstrap_administrators=(static_principal,) if legacy_static_admin else (), deployment_id=resolved.access.deployment_id, ) ) scheduled_source_runner, scheduled_experience_runner = _scheduled_access_runners( resolved, active_access_control, - static_principal=static_principal, + legacy_static_principal=static_principal if legacy_static_admin else None, ) runtime = await resources.enter_async_context( open_builtin_runtime( @@ -275,34 +267,32 @@ def _resolve_security_providers( *, access_control: AccessControlService | None, authentication_provider: AuthenticationProvider | None, -) -> tuple[PrincipalRef, AuthenticationProvider | None, AccessControlService | None]: +) -> tuple[PrincipalRef, AuthenticationProvider | None, AccessControlService | None, bool]: static_principal = PrincipalRef( type="service", - id=settings.auth.principal_id, - description=settings.auth.principal_description, + id="server-token", + description="PowerContext static bearer", ) if settings.access.mode == "disabled": if access_control is not None or authentication_provider is not None: raise ValueError("disabled Access Mode cannot load security Providers") # noqa: TRY003 - return static_principal, None, None - if settings.authorization_provider == "external" and access_control is None: - raise ValueError("the external Authorization Provider must be injected") # noqa: TRY003 + return static_principal, None, None, False if authentication_provider is not None: - return static_principal, authentication_provider, access_control - if settings.auth.provider != "static-bearer" or settings.auth.token is None: - raise ValueError("the selected Authentication Provider must be injected") # noqa: TRY003 + return static_principal, authentication_provider, access_control, False + if settings.auth.token is None: + raise MissingAuthenticationProviderError authentication = StaticBearerAuthenticationProvider( settings.auth.token.get_secret_value(), static_principal, ) - return static_principal, authentication, access_control + return static_principal, authentication, access_control, True def _scheduled_access_runners( settings: ServerSettings, access: AccessControlService | None, *, - static_principal: PrincipalRef, + legacy_static_principal: PrincipalRef | None, ) -> tuple[ScheduledSourceRunner | None, ScheduledExperienceRunner | None]: source_scheduled = settings.runtime.schedule_seconds is not None experience_scheduled = settings.runtime.experience_schedule_seconds is not None @@ -310,7 +300,7 @@ def _scheduled_access_runners( return None, None if access is None: raise ValueError("scheduled processing in enforced mode requires an Authorization Provider") # noqa: TRY003 - principal = _scheduled_principal(settings, static_principal=static_principal) + principal = _scheduled_principal(settings, legacy_static_principal=legacy_static_principal) async def process_sources(scope_id: str, runtime: BuiltinRuntime) -> MemoryFlushResult: context = AccessAuditContext(transport="background", operation="process_source_window") @@ -362,15 +352,19 @@ async def incubate_experience(scope_id: str, runtime: BuiltinRuntime) -> Experie ) -def _scheduled_principal(settings: ServerSettings, *, static_principal: PrincipalRef) -> PrincipalRef: +def _scheduled_principal( + settings: ServerSettings, + *, + legacy_static_principal: PrincipalRef | None, +) -> PrincipalRef: if settings.access.background_principal_id is not None: return PrincipalRef( type="service", id=settings.access.background_principal_id, description=settings.access.background_principal_description, ) - if settings.auth.provider == "static-bearer": - return static_principal + if legacy_static_principal is not None: + return legacy_static_principal raise ValueError("scheduled processing in enforced mode requires ACCESS_BACKGROUND_PRINCIPAL_ID") # noqa: TRY003 diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 8cad64ba5..19d11c901 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -39,7 +39,7 @@ from powercontext.transport import is_loopback_host _UNSAFE_BIND_MESSAGE = ( - "A non-loopback bind requires bearer authentication; " + "A non-loopback bind requires authentication; " "set allow_unauthenticated_non_loopback to opt in when TLS is " "terminated upstream or the network is otherwise controlled" ) @@ -63,6 +63,13 @@ class MissingBearerTokenError(ValueError): """ +class MissingAuthenticationProviderError(ValueError): + """Raised when enforced Access has neither an injected identity Provider nor a legacy token.""" + + def __init__(self) -> None: + super().__init__("enforced Access Mode requires an injected Authentication Provider or legacy AUTH_TOKEN") + + def _default_database() -> SQLiteConfig: return SQLiteConfig(url=sqlite_url(default_database_path())) @@ -122,20 +129,16 @@ def validate_path(cls, value: str) -> str: return normalized -class AuthenticationConfig(BaseModel): - """Authentication Provider selection and provider-specific static settings.""" +class BearerAuthConfig(BaseModel): + """Compatibility settings for the pre-Access static bearer authentication.""" - provider: Literal["static-bearer", "oidc", "trusted-header"] | None = None + enabled: bool = False token: SecretStr | None = Field(default=None, repr=False) - principal_id: str = Field(default="server-token", min_length=1, max_length=255) - principal_description: str | None = Field(default="PowerContext static bearer", min_length=1, max_length=255) @model_validator(mode="after") - def validate_provider_settings(self) -> AuthenticationConfig: - if self.provider == "static-bearer" and (self.token is None or not self.token.get_secret_value()): + def require_token_when_enabled(self) -> BearerAuthConfig: + if self.enabled and (self.token is None or not self.token.get_secret_value()): raise MissingBearerTokenError("Bearer token is required when authentication is enabled") # noqa: TRY003 - if self.provider != "static-bearer" and self.token is not None: - raise ValueError("AUTH_TOKEN is only valid for AUTH_PROVIDER=static-bearer") # noqa: TRY003 return self @@ -143,7 +146,6 @@ class AccessControlConfig(BaseModel): """Server security profile and deployment-local authorization identity.""" mode: Literal["disabled", "enforced"] = "disabled" - static_preset: bool = True deployment_id: str = Field(default="powercontext", min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$") background_principal_id: str | None = Field(default=None, min_length=1, max_length=255) background_principal_description: str | None = Field(default=None, min_length=1, max_length=255) @@ -221,9 +223,8 @@ class ServerSettings(BaseSettings): public_url: str | None = None allow_insecure_http: bool = False mcp: McpConfig = Field(default_factory=McpConfig) - auth: AuthenticationConfig = Field(default_factory=AuthenticationConfig) + auth: BearerAuthConfig = Field(default_factory=BearerAuthConfig) access: AccessControlConfig = Field(default_factory=AccessControlConfig) - authorization_provider: Literal["builtin", "casbin", "external"] | None = None allow_unauthenticated_non_loopback: bool = False dashboard: DashboardConfig = Field(default_factory=DashboardConfig) logging: ServerLoggingConfig = Field(default_factory=ServerLoggingConfig) @@ -311,27 +312,15 @@ def default_database_to_sqlite(cls, value: object) -> object: def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: if self.access.background_principal_description is not None and self.access.background_principal_id is None: raise ValueError("ACCESS_BACKGROUND_PRINCIPAL_DESCRIPTION requires BACKGROUND_PRINCIPAL_ID") # noqa: TRY003 - if self.access.mode == "disabled": - if ( - self.auth.provider is not None - or self.auth.token is not None - or self.authorization_provider is not None - or self.access.background_principal_id is not None - ): - raise ValueError("ACCESS_MODE=disabled cannot configure authentication or authorization Providers") # noqa: TRY003 - elif self.auth.provider is None or self.authorization_provider is None: - raise ValueError("ACCESS_MODE=enforced requires authentication and authorization Providers") # noqa: TRY003 - elif ( - (self.runtime.schedule_seconds is not None or self.runtime.experience_schedule_seconds is not None) - and self.auth.provider != "static-bearer" - and self.access.background_principal_id is None - ): - raise ValueError( # noqa: TRY003 - "scheduled processing in a multi-user enforced deployment requires ACCESS_BACKGROUND_PRINCIPAL_ID" - ) + if self.auth.enabled: + self.access.mode = "enforced" + if self.access.mode == "disabled" and self.auth.token is not None: + raise ValueError("AUTH_TOKEN requires ACCESS_MODE=enforced or legacy AUTH_ENABLED=true") # noqa: TRY003 + if self.access.mode == "disabled" and self.access.background_principal_id is not None: + raise ValueError("ACCESS_MODE=disabled cannot configure a background Principal") # noqa: TRY003 if is_unauthenticated_non_loopback_bind( host=self.http.host, - auth_enabled=self.access.mode == "enforced", + auth_enabled=self.access.mode != "disabled", allow_unauthenticated_non_loopback=self.allow_unauthenticated_non_loopback, ): raise UnauthenticatedNonLoopbackBindError(_UNSAFE_BIND_MESSAGE) @@ -340,13 +329,14 @@ def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: __all__ = [ "AccessControlConfig", - "AuthenticationConfig", + "BearerAuthConfig", "DashboardConfig", "DashboardScopeConfig", "HandoffReportConfig", "HttpConfig", "McpConfig", "MetricsConfig", + "MissingAuthenticationProviderError", "MissingBearerTokenError", "ServerLoggingConfig", "ServerSettings", diff --git a/tests/e2e/real_experience_skill/harness.py b/tests/e2e/real_experience_skill/harness.py index c0f0c2f17..d528acbdf 100644 --- a/tests/e2e/real_experience_skill/harness.py +++ b/tests/e2e/real_experience_skill/harness.py @@ -2036,7 +2036,7 @@ def _validate_configured_settings(settings: ServerSettings) -> None: def _configured_access_token(settings: ServerSettings) -> str | None: if settings.access.mode == "disabled": return None - if settings.auth.provider != "static-bearer" or settings.auth.token is None: + if settings.auth.token is None: _fail("configured E2E supports enforced Access Control only with static-bearer authentication") return settings.auth.token.get_secret_value() diff --git a/tests/e2e/real_experience_skill/test_access_control.py b/tests/e2e/real_experience_skill/test_access_control.py index 84e4cdc5a..63dd83e42 100644 --- a/tests/e2e/real_experience_skill/test_access_control.py +++ b/tests/e2e/real_experience_skill/test_access_control.py @@ -57,7 +57,7 @@ pytestmark = pytest.mark.real_e2e -def test_configured_database_persists_exact_skill_grant_and_revocation(pytestconfig: pytest.Config) -> None: +def test_configured_database_persists_logical_skill_grant_and_revocation(pytestconfig: pytest.Config) -> None: if pytestconfig.getoption("real_e2e_mode") not in {"configured", "all"}: pytest.skip("configured Access Control acceptance runs in configured mode") diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py index 14cf5b59a..94699e80a 100644 --- a/tests/e2e/test_access_control_http.py +++ b/tests/e2e/test_access_control_http.py @@ -50,7 +50,7 @@ from powercontext.server.factory import create_server_app from powercontext.server.settings import ( AccessControlConfig, - AuthenticationConfig, + BearerAuthConfig, DashboardConfig, McpConfig, MetricsConfig, @@ -67,9 +67,9 @@ async def generate(self, request: HandoffGenerationRequest, /) -> HandoffDraft: citations = tuple(item.citation for item in request.evidence) return HandoffDraft( objective=request.objective, - state=(HandoffStatement(text="The exact Handoff is ready for its receiver.", citations=citations),), + state=(HandoffStatement(text="The logical Handoff is ready for its receiver.", citations=citations),), disposition="continuable", - next_action=HandoffStatement(text="Acknowledge only this committed Revision.", citations=citations), + next_action=HandoffStatement(text="Inspect the selected Revision and its evidence.", citations=citations), ) @@ -158,6 +158,10 @@ async def scenario() -> None: ) ) assert exact.selected_revision == first_committed.reference + assert exact.content is not None + assert exact.content.state[0].citations + assert exact.evidence_checks + assert all(check.status == "available" for check in exact.evidence_checks) receipt = await receiver.acknowledge_handoff( AcknowledgeHandoffRequest.model_validate({ "scope_id": "access-e2e", @@ -179,6 +183,17 @@ async def scenario() -> None: ContinueHandoffRequest(scope_id="access-e2e", selection=HandoffSelection.LATEST) ) assert latest.selected_revision == committed.reference + assert latest.evidence_checks + assert all(check.status == "available" for check in latest.evidence_checks) + later_exact = await receiver.continue_handoff( + ContinueHandoffRequest( + scope_id="access-e2e", + selection=HandoffSelection.EXACT, + revision=committed.reference, + ) + ) + assert later_exact.selected_revision == committed.reference + assert all(check.status == "available" for check in later_exact.evidence_checks) visible = await receiver.list_access_resources( ListAccessResourcesRequest( action=AccessAction.ARTIFACT_READ, @@ -238,8 +253,7 @@ async def scenario() -> None: mode="enforced", deployment_id="scheduled-access-e2e", ), - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr(token)), - authorization_provider="builtin", + auth=BearerAuthConfig(token=SecretStr(token)), dashboard=DashboardConfig(enabled=False), metrics=MetricsConfig(enabled=False), mcp=McpConfig(enabled=False), @@ -290,11 +304,8 @@ def _app( database=database, access=AccessControlConfig( mode="enforced", - static_preset=False, deployment_id=DEPLOYMENT_ID, ), - auth=AuthenticationConfig(provider="oidc"), - authorization_provider="external", dashboard=DashboardConfig(enabled=False), metrics=MetricsConfig(enabled=False), mcp=McpConfig(enabled=False), diff --git a/tests/e2e/test_claude_code_service_chain.py b/tests/e2e/test_claude_code_service_chain.py index ccc552e56..ca442b2c4 100644 --- a/tests/e2e/test_claude_code_service_chain.py +++ b/tests/e2e/test_claude_code_service_chain.py @@ -37,7 +37,7 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import InferenceConfig from powercontext.server.factory import create_server_app -from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings PROJECT_ROOT = Path(__file__).resolve().parents[2] CLAUDE_PLUGIN = PROJECT_ROOT / "integrations" / "claude-code" / "plugins" / "powercontext" @@ -83,12 +83,10 @@ def test_claude_sessions_and_codex_share_one_project_memory( ) app = create_server_app( settings=ServerSettings( - auth=AuthenticationConfig( - provider="static-bearer" if authentication_enabled else None, + auth=BearerAuthConfig( token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), - authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=True), @@ -155,12 +153,10 @@ def test_claude_plugin_mcp_supports_explicit_memory_and_handoff_workflows( ) -> None: app = create_server_app( settings=ServerSettings( - auth=AuthenticationConfig( - provider="static-bearer" if authentication_enabled else None, + auth=BearerAuthConfig( token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), - authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'mcp.db'}"), mcp=McpConfig(enabled=True), ), diff --git a/tests/e2e/test_codex_service_chain.py b/tests/e2e/test_codex_service_chain.py index 0668de84e..92d8a0571 100644 --- a/tests/e2e/test_codex_service_chain.py +++ b/tests/e2e/test_codex_service_chain.py @@ -42,7 +42,7 @@ SearchMemoryRequest, ) from powercontext.server.factory import create_server_app -from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings PROJECT_ROOT = Path(__file__).resolve().parents[2] CODEX_PLUGIN = PROJECT_ROOT / "integrations" / "codex" / "plugins" / "powercontext" @@ -74,12 +74,10 @@ def test_codex_hook_http_sdk_and_mcp_share_one_composed_context( ) app = create_server_app( settings=ServerSettings( - auth=AuthenticationConfig( - provider="static-bearer" if authentication_enabled else None, + auth=BearerAuthConfig( token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), - authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=True), diff --git a/tests/e2e/test_langgraph_chain.py b/tests/e2e/test_langgraph_chain.py index bd3c26d63..4f9230fcb 100644 --- a/tests/e2e/test_langgraph_chain.py +++ b/tests/e2e/test_langgraph_chain.py @@ -46,7 +46,7 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import InferenceConfig from powercontext.server.factory import create_server_app -from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings pytest.importorskip("powercontext_langgraph") @@ -98,12 +98,10 @@ def _model_node(state: ChainState) -> dict[str, list[BaseMessage]]: def test_langgraph_write_then_recall_over_real_http(tmp_path: Path, authentication_enabled: bool) -> None: app = create_server_app( settings=ServerSettings( - auth=AuthenticationConfig( - provider="static-bearer" if authentication_enabled else None, + auth=BearerAuthConfig( token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), - authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=False), diff --git a/tests/e2e/test_statistics_flow.py b/tests/e2e/test_statistics_flow.py index ce939606c..a6d9eceb5 100644 --- a/tests/e2e/test_statistics_flow.py +++ b/tests/e2e/test_statistics_flow.py @@ -45,7 +45,7 @@ StatsPeriod, ) from powercontext.server.factory import create_server_app -from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings _AUTH_TOKEN = "statistics-e2e-token" # noqa: S105 - non-secret test credential. _OCEANBASE_URL = os.environ.get("POWERCONTEXT_TEST_OCEANBASE_URL") @@ -70,9 +70,8 @@ def _settings(database_kind: str, database: Path) -> ServerSettings: persistence = SQLiteConfig(url=f"sqlite+aiosqlite:///{database}") return ServerSettings( database=persistence, - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr(_AUTH_TOKEN)), + auth=BearerAuthConfig(token=SecretStr(_AUTH_TOKEN)), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=False), ) diff --git a/tests/e2e/test_workbuddy_service_chain.py b/tests/e2e/test_workbuddy_service_chain.py index 74d4b59c7..b5bcf9c2e 100644 --- a/tests/e2e/test_workbuddy_service_chain.py +++ b/tests/e2e/test_workbuddy_service_chain.py @@ -36,7 +36,7 @@ from powercontext.builtin.runtime import InferenceConfig from powercontext.cli.workbuddy import install_workbuddy_plugin from powercontext.server.factory import create_server_app -from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings PROJECT_ROOT = Path(__file__).resolve().parents[2] WORKBUDDY_PLUGIN = PROJECT_ROOT / "integrations" / "workbuddy" / "plugins" / "powercontext" @@ -70,12 +70,10 @@ def test_workbuddy_hook_and_mcp_share_one_service_configuration( ) app = create_server_app( settings=ServerSettings( - auth=AuthenticationConfig( - provider="static-bearer" if authentication_enabled else None, + auth=BearerAuthConfig( token=SecretStr(AUTH_TOKEN) if authentication_enabled else None, ), access=AccessControlConfig(mode="enforced" if authentication_enabled else "disabled"), - authorization_provider="builtin" if authentication_enabled else None, database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), inference=InferenceConfig(generation_model="test"), mcp=McpConfig(enabled=True), diff --git a/tests/test_access_http.py b/tests/test_access_http.py index b51f9f526..0ecbd8ac9 100644 --- a/tests/test_access_http.py +++ b/tests/test_access_http.py @@ -22,6 +22,8 @@ import httpx import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr from starlette.middleware import Middleware from powercontext.artifacts import ArtifactRef @@ -52,7 +54,7 @@ from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository from powercontext.server.factory import create_server_app from powercontext.server.middleware import AuthenticationMiddleware -from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, ServerSettings from powercontext.server.web import mount_web_ui ADMIN = PrincipalRef(type="service", id="admin", description="deployment administrator") @@ -80,25 +82,62 @@ async def readiness(self) -> ProviderReadiness: def test_enforced_mode_cannot_silently_start_without_authentication_or_provider() -> None: - with pytest.raises(ValueError, match="requires authentication and authorization Providers"): - ServerSettings(access=AccessControlConfig(mode="enforced")) + with pytest.raises(ValueError, match="injected Authentication Provider or legacy AUTH_TOKEN"): + create_server_app(settings=ServerSettings(access=AccessControlConfig(mode="enforced"))) - with pytest.raises(ValueError, match="selected Authentication Provider"): - create_server_app( - settings=ServerSettings( - access=AccessControlConfig(mode="enforced"), - auth=AuthenticationConfig(provider="oidc"), - authorization_provider="builtin", - ) - ) - - with pytest.raises(ValueError, match="BACKGROUND_PRINCIPAL_ID"): - ServerSettings( + scheduled = create_server_app( + settings=ServerSettings( access=AccessControlConfig(mode="enforced"), - auth=AuthenticationConfig(provider="oidc"), - authorization_provider="builtin", runtime=RuntimeConfig(schedule_seconds=60), + ), + authentication_provider=_ActingAuthenticationProvider(), + ) + with pytest.raises(ValueError, match="BACKGROUND_PRINCIPAL_ID"), TestClient(scheduled): + pass + + +def test_enforced_mode_uses_injected_authentication_and_builtin_access(tmp_path: Path) -> None: + app = create_server_app( + settings=ServerSettings( + access=AccessControlConfig(mode="enforced"), + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'injected-auth.db'}"), + ), + authentication_provider=_ActingAuthenticationProvider(), + ) + + with TestClient(app) as client: + principal = client.get("/v1/access/me") + protected = client.get("/v1/capabilities") + + assert principal.status_code == 200 + assert principal.json()["principal"]["id"] == "bob" + assert principal.json()["mode"] == "enforced" + assert protected.status_code == 403 + + +def test_injected_authentication_takes_precedence_over_legacy_token(tmp_path: Path) -> None: + app = create_server_app( + settings=ServerSettings( + access=AccessControlConfig(mode="enforced"), + auth=BearerAuthConfig(token=SecretStr("legacy-server-secret")), + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'injected-precedence.db'}"), + ), + authentication_provider=_ActingAuthenticationProvider(), + ) + + with TestClient(app) as client: + principal = client.get( + "/v1/access/me", + headers={"Authorization": "Bearer legacy-server-secret"}, ) + protected = client.get( + "/v1/capabilities", + headers={"Authorization": "Bearer legacy-server-secret"}, + ) + + assert principal.status_code == 200 + assert principal.json()["principal"]["id"] == "bob" + assert protected.status_code == 403 def test_low_level_enforced_app_fails_closed_without_an_authorization_provider() -> None: diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 4f9ddce6b..766ea6062 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -294,7 +294,7 @@ def test_memory_search_declares_the_revision_conflict_response() -> None: assert SEARCH_MEMORY.responses[409] == {"$ref": "#/components/responses/Conflict"} -def test_handoff_access_metadata_preserves_exact_revision_authorization() -> None: +def test_handoff_access_metadata_resolves_business_revision_to_logical_authorization() -> None: assert CONTINUE_HANDOFF.access is not None assert CONTINUE_HANDOFF.access.action is None assert CONTINUE_HANDOFF.access.resolver == "continue_handoff_access" @@ -314,6 +314,10 @@ def test_access_contract_uses_logical_resources_and_generic_skill_read_access() assert set(artifact["properties"]) == {"type", "scope_id", "identity", "selector"} selector = schemas["MemoryEntryAccessSelector"] assert selector["required"] == ["type", "entry_id"] + assert set(selector["properties"]) == {"type", "entry_id"} + identity = schemas["AccessArtifactIdentity"] + assert identity["required"] == ["family", "artifact_id"] + assert set(identity["properties"]) == {"family", "artifact_id"} assert set(schemas["AccessDecision"]["properties"]) == {"allowed", "reason_code"} assert schemas["AccessBinding"]["properties"]["policy_revision"]["maxLength"] == 64 assert schemas["AccessAuditEvent"]["properties"]["policy_revision"]["maxLength"] == 64 diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e6fb19a6..a2feee315 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -739,8 +739,7 @@ def test_server_command_clears_stale_server_values_missing_from_env_file( ) -> None: environment = tmp_path / ".env" environment.write_text("POWERCONTEXT_SERVER_HTTP_HOST=127.0.0.1\n", encoding="utf-8") - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_PROVIDER", "static-bearer") - monkeypatch.delenv("POWERCONTEXT_SERVER_AUTH_TOKEN", raising=False) + monkeypatch.setenv("POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID", "stale-deployment") run_server = Mock() tracing = Mock() monkeypatch.setattr("powercontext.server.cli._run_server", run_server) @@ -754,7 +753,7 @@ def test_server_command_clears_stale_server_values_missing_from_env_file( assert result.exit_code == 0 run_server.assert_called_once() - assert os.environ["POWERCONTEXT_SERVER_AUTH_PROVIDER"] == "static-bearer" + assert os.environ["POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID"] == "stale-deployment" def test_server_command_reports_a_missing_env_file_without_starting( @@ -861,8 +860,6 @@ def test_server_command_reports_a_friendly_error_when_auth_lacks_a_token( monkeypatch.setattr("powercontext.server.cli.configure_server_logging", lambda _config: None) monkeypatch.setattr("powercontext.server.cli.configure_server_tracing", lambda _config: tracing) monkeypatch.setenv("POWERCONTEXT_SERVER_ACCESS_MODE", "enforced") - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_PROVIDER", "static-bearer") - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER", "builtin") monkeypatch.delenv("POWERCONTEXT_SERVER_AUTH_TOKEN", raising=False) result = CliRunner().invoke(create_cli([server_app]), ["server", "run"]) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 7f92fa11d..32762e6f3 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -27,7 +27,7 @@ from powercontext.server.factory import create_server_app from powercontext.server.settings import ( AccessControlConfig, - AuthenticationConfig, + BearerAuthConfig, DashboardConfig, DashboardScopeConfig, McpConfig, @@ -41,8 +41,6 @@ def test_dashboard_is_enabled_by_default_without_authentication_or_scopes(tmp_path, monkeypatch) -> None: for name in ( "POWERCONTEXT_SERVER_ACCESS_MODE", - "POWERCONTEXT_SERVER_AUTH_PROVIDER", - "POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER", "POWERCONTEXT_SERVER_AUTH_TOKEN", "POWERCONTEXT_SERVER_DASHBOARD_ENABLED", "POWERCONTEXT_SERVER_DASHBOARD_SCOPES", @@ -137,9 +135,8 @@ def test_dashboard_is_the_authenticated_server_ui_entry(tmp_path) -> None: app = create_server_app( settings=ServerSettings( public_url="https://powercontext.example.com/base/", - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + auth=BearerAuthConfig(token=SecretStr("dashboard-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[ @@ -219,9 +216,8 @@ def test_skill_library_exposes_external_takeover_machine_through_later_revisions encoding="utf-8", ) settings = ServerSettings( - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + auth=BearerAuthConfig(token=SecretStr("dashboard-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], @@ -316,9 +312,8 @@ def test_review_publishes_an_approved_managed_skill_into_default_project_targets claude_skill_root = workspace / ".claude" / "skills" settings = ServerSettings( workspace=workspace, - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + auth=BearerAuthConfig(token=SecretStr("dashboard-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], @@ -539,9 +534,8 @@ def for_scope(self, scope_id: str): def test_publish_reports_success_when_post_publish_scan_fails(tmp_path, caplog) -> None: codex_skill_root = tmp_path / "repository" / ".agents" / "skills" settings = ServerSettings( - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + auth=BearerAuthConfig(token=SecretStr("dashboard-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], @@ -646,9 +640,8 @@ def for_scope(self, scope_id: str): def test_publish_reports_stale_discovery_when_registry_database_is_unavailable(tmp_path, caplog) -> None: codex_skill_root = tmp_path / "repository" / ".agents" / "skills" settings = ServerSettings( - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + auth=BearerAuthConfig(token=SecretStr("dashboard-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", dashboard=DashboardConfig( enabled=True, scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], @@ -755,9 +748,8 @@ def test_handoff_report_page_is_available_without_the_statistics_dashboard(tmp_p def _handoff_report_settings(database_path: Path, *, enabled: bool) -> ServerSettings: return ServerSettings( - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("dashboard-secret")), + auth=BearerAuthConfig(token=SecretStr("dashboard-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", dashboard=DashboardConfig(enabled=False), database=SQLiteConfig(url=f"sqlite+aiosqlite:///{database_path}"), mcp=McpConfig(enabled=False), diff --git a/tests/test_server.py b/tests/test_server.py index 4a2904bfe..a922e7d49 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -51,7 +51,7 @@ from powercontext.server.factory import create_server_app from powercontext.server.settings import ( AccessControlConfig, - AuthenticationConfig, + BearerAuthConfig, DashboardConfig, DashboardScopeConfig, McpConfig, @@ -375,23 +375,51 @@ def test_server_scheduler_uses_the_powercontext_data_directory(tmp_path, monkeyp def test_settings_load_bearer_authentication_without_exposing_token(monkeypatch) -> None: monkeypatch.setenv("POWERCONTEXT_SERVER_ACCESS_MODE", "enforced") - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_PROVIDER", "static-bearer") - monkeypatch.setenv("POWERCONTEXT_SERVER_AUTHORIZATION_PROVIDER", "builtin") monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_TOKEN", "server-secret") settings = ServerSettings() assert settings.access.mode == "enforced" - assert settings.auth.provider == "static-bearer" - assert settings.authorization_provider == "builtin" assert settings.auth.token is not None assert settings.auth.token.get_secret_value() == "server-secret" assert "server-secret" not in repr(settings) +def test_legacy_auth_token_cannot_silently_enable_access(monkeypatch) -> None: + monkeypatch.delenv("POWERCONTEXT_SERVER_ACCESS_MODE", raising=False) + monkeypatch.delenv("POWERCONTEXT_SERVER_AUTH_ENABLED", raising=False) + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_TOKEN", "orphaned-server-secret") + + with pytest.raises(ValidationError, match="AUTH_TOKEN requires ACCESS_MODE=enforced"): + ServerSettings() + + +def test_legacy_static_bearer_environment_maps_to_server_admin(monkeypatch) -> None: + monkeypatch.delenv("POWERCONTEXT_SERVER_ACCESS_MODE", raising=False) + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_ENABLED", "true") + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_TOKEN", "legacy-server-secret") + + settings = ServerSettings(database=SQLiteConfig(), mcp=McpConfig(enabled=False)) + + assert settings.access.mode == "enforced" + assert "legacy-server-secret" not in repr(settings) + + with TestClient(create_server_app(settings=settings)) as client: + missing = client.get("/v1/capabilities") + principal = client.get( + "/v1/access/me", + headers={"Authorization": "Bearer legacy-server-secret"}, + ) + + assert missing.status_code == 401 + assert principal.status_code == 200 + assert principal.json()["mode"] == "enforced" + assert principal.json()["principal"]["id"] == "server-token" + + def test_enabled_bearer_authentication_requires_a_token() -> None: with pytest.raises(ValueError, match="Bearer token is required"): - AuthenticationConfig(provider="static-bearer") + BearerAuthConfig(enabled=True) def test_liveness_adds_a_server_owned_request_id() -> None: @@ -429,9 +457,8 @@ def test_scalar_reference_embeds_the_canonical_openapi_contract() -> None: def test_server_factory_optionally_requires_bearer_authentication() -> None: app = create_server_app( settings=ServerSettings( - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), + auth=BearerAuthConfig(token=SecretStr("server-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", database=SQLiteConfig(), mcp=McpConfig(enabled=False), ) @@ -466,9 +493,8 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: def test_enforced_mode_fails_closed_if_the_authorization_provider_disappears(tmp_path) -> None: app = create_server_app( settings=ServerSettings( - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), + auth=BearerAuthConfig(token=SecretStr("server-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="external", dashboard=DashboardConfig(scopes=[DashboardScopeConfig(scope_id="scope-a", display_name="Scope A")]), database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), mcp=McpConfig(enabled=False), @@ -504,9 +530,8 @@ def test_enforced_mode_fails_closed_if_the_authorization_provider_disappears(tmp def test_server_factory_maps_static_token_to_bootstrap_principal() -> None: app = create_server_app( settings=ServerSettings( - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), + auth=BearerAuthConfig(token=SecretStr("server-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", database=SQLiteConfig(), mcp=McpConfig(enabled=False), ) diff --git a/tests/test_transport.py b/tests/test_transport.py index 9d015ea1f..540896bd4 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -28,7 +28,7 @@ from powercontext.client import PowerContextClient from powercontext.client.settings import ClientSettings -from powercontext.server.settings import AccessControlConfig, AuthenticationConfig, HttpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, HttpConfig, ServerSettings from powercontext.transport import canonical_loopback_endpoint, is_loopback_host, is_plaintext_non_loopback _ALL_INTERFACES = "0.0.0.0" # noqa: S104 - a non-loopback bind used to exercise the policy. @@ -223,16 +223,15 @@ def test_server_rejects_an_unauthenticated_non_loopback_bind() -> None: with pytest.raises(ValidationError): ServerSettings( http=HttpConfig(host=_ALL_INTERFACES), - auth=AuthenticationConfig(), + auth=BearerAuthConfig(), ) def test_server_allows_a_non_loopback_bind_with_authentication() -> None: settings = ServerSettings( http=HttpConfig(host=_ALL_INTERFACES), - auth=AuthenticationConfig(provider="static-bearer", token=SecretStr("server-secret")), + auth=BearerAuthConfig(token=SecretStr("server-secret")), access=AccessControlConfig(mode="enforced"), - authorization_provider="builtin", ) assert settings.http.host == _ALL_INTERFACES @@ -240,7 +239,7 @@ def test_server_allows_a_non_loopback_bind_with_authentication() -> None: def test_server_allows_a_non_loopback_bind_with_an_explicit_opt_in() -> None: settings = ServerSettings( http=HttpConfig(host=_ALL_INTERFACES), - auth=AuthenticationConfig(), + auth=BearerAuthConfig(), allow_unauthenticated_non_loopback=True, ) assert settings.allow_unauthenticated_non_loopback is True From 58d7ca9783241c822a17b07cdf4ab2183ee726d7 Mon Sep 17 00:00:00 2001 From: Teingi Date: Fri, 4 Sep 2026 12:27:10 +0800 Subject: [PATCH 15/22] fix(access): align publication ownership and RFCs --- .../rfcs/1304_experience_skill_review_page.md | 51 +- docs/en/rfcs/1396_handoff_access_control.md | 534 +++++++++--------- .../rfcs/1304_experience_skill_review_page.md | 47 +- docs/zh/rfcs/1396_handoff_access_control.md | 486 ++++++++-------- src/powercontext/server/app.py | 11 + tests/test_access_http.py | 25 +- 6 files changed, 597 insertions(+), 557 deletions(-) diff --git a/docs/en/rfcs/1304_experience_skill_review_page.md b/docs/en/rfcs/1304_experience_skill_review_page.md index a14ac776a..04e41a3c4 100644 --- a/docs/en/rfcs/1304_experience_skill_review_page.md +++ b/docs/en/rfcs/1304_experience_skill_review_page.md @@ -3,7 +3,9 @@ - RFC PR: [oceanbase/powercontext#1304](https://github.com/oceanbase/powercontext/pull/1304) - Related RFCs: [RFC 0050](0050_artifact_candidate_review_inbox.md), [RFC 0051](0051_experience_skill_artifact_families.md), - [RFC 0072](0072_scoped_statistics_and_usage.md) + [RFC 0072](0072_scoped_statistics_and_usage.md), + [RFC 1345](1345_scope_organization_and_agent_integration.md), and + [RFC 1396](1396_handoff_access_control.md) # Summary @@ -11,11 +13,11 @@ This RFC adds a Server-owned Review page for Experience and PowerContext-managed user-facing projection of the existing Candidate and Review lifecycle. It does not create another review model, change Candidate persistence, or bypass the existing HTTP operations. -PowerContext already exposes a personal Dashboard, configured Dashboard scopes, Bearer authentication, and Review +PowerContext already exposes a personal Dashboard, durable Access-filtered Scope discovery, authentication, and Review operations for listing, reading, revising, approving, and rejecting Candidates. The proposed `/reviews` page combines those capabilities into one scoped Review Inbox. A reviewer can: -1. select one configured scope; +1. select one visible durable Scope; 2. filter current Candidate heads by status and Family; 3. inspect the typed Experience or Skill proposal and its exact evidence references; 4. revise the proposal without changing its evidence; @@ -25,9 +27,10 @@ those capabilities into one scoped Review Inbox. A reviewer can: Pending remains the default view. Approved and rejected Candidates are available as read-only views. Experience and Skill share one page because they share one Candidate lifecycle, while each Family retains its own rendering and edit -form. The first version adds no Candidate generation, evidence-content preview, reviewer identity, RBAC, assignment, -notification, bulk action, or Skill execution capability. Publication is a separate explicit action after approval and -can write only to a host-local Agent target that configuration marks as writable. +form. The page adds no Candidate generation, evidence-content preview, reviewer identity, role editor, assignment, +notification, bulk action, or Skill execution capability. It relies on RFC 1396 for authorization. Publication is a +separate explicit action after approval and can write only to a host-local Agent target that configuration marks as +writable. # Motivation @@ -216,7 +219,7 @@ the reviewer either discards it or manually applies it to the new current propos The first version has these goals: - make the existing Experience and managed Skill Review lifecycle usable from the Server UI; -- keep scope selection explicit and limited to configured Dashboard scopes; +- keep scope selection explicit and limited to durable Scopes visible to the current Principal; - render each Family as a reviewable domain object rather than generic JSON; - preserve exact Candidate-version and target CAS behavior; - keep untrusted content inert and keep approval separate from execution authority; @@ -231,7 +234,7 @@ The following are out of scope: - editing Candidate evidence, target, lineage, or generation reason; - rendering Source content or arbitrary Artifact evidence previews; - automatic publication, arbitrary-path export, Skill execution, runtime hot loading, or rollback; -- reviewer identity, RBAC, SSO, assignment, notifications, service-level targets, and bulk actions; +- reviewer identity, a role editor, SSO, assignment, notifications, service-level targets, and bulk actions; - Candidate retention, reopening, deletion, semantic diff, or version-history browsing; - a generic form renderer for future Artifact Families; and - a new frontend framework or standalone web application. @@ -242,7 +245,7 @@ The design reuses current Server behavior: | Existing surface | Use on the Review page | | --- | --- | -| `GET /dashboard/scopes` | List the scopes deliberately exposed by Server configuration | +| `GET /dashboard/scopes` | List durable Scopes after Access filtering; in `enforced` mode, only Scopes for which the current Principal has `scope.read` are returned | | `POST /v1/artifact-candidates/list` | Page current heads by scope, status, Family, and cursor | | `POST /v1/artifact-candidates/get` | Refresh one current Candidate head | | `POST /v1/artifact-candidates/revise` | Append one complete replacement proposal | @@ -255,7 +258,7 @@ The design reuses current Server behavior: | Dashboard page UI utilities | Reuse locale, theme, status, and stale-request handling patterns | No OpenAPI change, generated client change, database migration, or new public persistence contract is required. Like -`/dashboard/scopes`, the two `/dashboard/skill-projections/*` endpoints are authenticated Server UI supporting surfaces. +`/dashboard/scopes`, the `/dashboard/skill-projections/*` routes are authenticated Server UI supporting surfaces. They operate on explicitly configured roots on the Server host, are not a cross-host PowerContext API, and never accept a caller-provided path. Portable exact reads remain on the public `get_skill` contract, and CLI export remains available. @@ -265,18 +268,18 @@ The Review page is part of the personal Dashboard feature: - route: `GET /reviews`; - availability: mounted only when `DashboardConfig.enabled` is true; -- scopes: the same ordered `DashboardConfig.scopes` used by the statistics Dashboard; -- authentication: the same Server Bearer policy and same-origin request helper; and -- navigation order: Dashboard, Review, Handoff Report when all three are available. +- scopes: the same ordered, Access-filtered durable Scope descriptors used by the statistics Dashboard; +- authentication: the same Server Bearer policy and same-origin request helper; +- navigation order: Dashboard, Review, Handoff Report when all three are available; and - publication targets: only explicit `AgentSkillTarget` entries with `allow_managed_publish=true`; legacy `CodexSkillRoot` entries remain a Codex-only compatibility form, and no target is writable by default. Disabling the Dashboard removes both the Dashboard and Review routes. Handoff Report may remain independently available under its existing configuration. -The Review page does not accept an arbitrary `scope_id` from a query parameter in the first version. It selects the -first configured scope initially and lets the reviewer switch through the configured picker. This avoids presenting -scope-shaped input as authorization and avoids a deep link that may expose an unconfigured scope. +The Review page does not accept an arbitrary `scope_id` from a query parameter. It selects the first visible durable +Scope initially and lets the reviewer switch through the Access-filtered picker. A caller-supplied `scope_id` is never +treated as authorization; each data request is still enforced by the Server PEP. ## Page state and request ordering @@ -284,7 +287,7 @@ The page maintains these client-side values: ```text authentication state -configured scopes +visible durable scopes selected scope selected family filter selected status filter @@ -405,7 +408,7 @@ A publication status request selects an exact approved ArtifactRef: ``` The Server first verifies that the Artifact is the exact `result_artifact` of the identified approved Skill Candidate. -It then returns targets only for configured Dashboard scopes and Agent targets that allow managed publication. Each +It then returns targets only for the selected visible Scope and Agent targets that allow managed publication. Each target carries `target_id`, `agent_kind`, and installation scope, plus a stable package state: `unpublished`, `current`, `update_available`, `conflict`, `drifted`, or `incompatible`. Discovery is reported independently as `available`, `unavailable`, or `not_published`. @@ -426,7 +429,7 @@ The page distinguishes: | State | Behavior | | --- | --- | -| No configured scopes | Explain the Dashboard scope configuration requirement; send no Candidate request | +| No visible Scopes | Explain that no durable Scope is available to the current Principal; send no Candidate request | | Empty filtered page | Explain which scope, status, and Family have no Candidates | | Loading list | Keep filters visible and mark the list busy | | Loading detail | Keep the selected row visible and mark the detail pane busy | @@ -482,7 +485,7 @@ resulting Candidate and Artifact state. | Availability | `/reviews` exists only when the Dashboard is enabled and appears in primary navigation | | Authentication | The existing optional Bearer flow protects page data and handles `401` without another token store | | Scope isolation | Switching scopes clears rows, detail, cursor, conflicts, and drafts before another response renders | -| Default Inbox | The first request lists pending Experience and Skill current heads for the first configured scope | +| Default Inbox | The first request lists pending Experience and Skill current heads for the first visible Scope | | Filtering | Family or status changes restart pagination and never mix rows from different filters | | Pagination | Load more follows `next_cursor`, preserves server order, and deduplicates by Candidate ID | | Experience | The four typed fields, reason, target, and exact evidence references are readable | @@ -516,8 +519,8 @@ approved Skill successor Revision and publication update, optional authenticatio - A unified Inbox needs Family-specific rendering and validation branches even though the lifecycle is shared. - Keeping evidence immutable in the first revision form means some corrections still require CLI, MCP, or a new Candidate. -- The page improves access to governance but does not provide reviewer attribution, authorization separation, or an - organizational audit log. +- The page does not add reviewer attribution or an organization-level audit UI; authorization and audit enforcement + come from the shared Access boundary rather than page-local logic. - Host-local publication affects the Server process host; a remote browser does not publish to its own device. # Rationale and alternatives @@ -545,8 +548,8 @@ without an ergonomic structured inspection flow. query model. This RFC presents that contract without changing it. - RFC 0051 defines Experience and managed Skill proposal shapes, lineage, and the boundary between Skill approval and execution authority. This RFC gives those shapes separate review views. -- RFC 0072 and the existing Dashboard establish configured scope discovery, scoped pending counts, authentication, - localization, theme, and Server-owned static delivery. +- RFC 0072 and the existing Dashboard establish scoped pending counts, localization, theme, and Server-owned static + delivery. RFC 1345 supplies durable Scope discovery, and RFC 1396 supplies Principal-aware filtering and enforcement. - The Handoff Report page demonstrates that a focused workflow can share Server navigation and page utilities without becoming part of the statistics Dashboard itself. diff --git a/docs/en/rfcs/1396_handoff_access_control.md b/docs/en/rfcs/1396_handoff_access_control.md index a034497f9..af976087f 100644 --- a/docs/en/rfcs/1396_handoff_access_control.md +++ b/docs/en/rfcs/1396_handoff_access_control.md @@ -47,9 +47,10 @@ The first version defines three stable Resource Kinds: - `artifact`: one logical Artifact identity or Family-owned logical selector interpreted by an Artifact Family Access Profile. -The `artifact` Resource Kind initially registers Artifact Family Access Profiles for `handoff`, `memory`, -`experience`, `skill`, and `prompt`. `ArtifactReference.family` is the only Profile discriminator. A client does not -submit a second content type that could conflict with it. +The `artifact` Resource Kind registers enabled Artifact Family Access Profiles for `handoff`, `memory`, `experience`, +and `skill`. The `prompt` vocabulary is reserved, but its Profile is disabled until PowerContext implements a Prompt +lifecycle. `ArtifactReference.family` is the only Profile discriminator. A client does not submit a second content +type that could conflict with it. User A can collaborate in two ways: @@ -59,16 +60,16 @@ User A can collaborate in two ways: The second option is the least-privilege path in the first version. B may read existing and future versions of the shared logical resource and perform only the actions defined by its Artifact Family Access Profile. A Handoff receiver may inspect the evidence explicitly cited by the selected Revision through its resolver and leave a Receipt for that -Revision. A logical Memory, Artifact, or Prompt grant does not open the rest of the scope, aggregate search or list -results, another logical resource, or resources referenced by lineage. +Revision. A logical Memory or Artifact grant does not open the rest of the scope, aggregate search or list results, +another logical resource, or resources referenced by lineage. Reading a Skill, publishing it to a target, and allowing a host to load or execute it are separate authorization boundaries. An `accepted` Receipt, Artifact approval, Prompt read, or Skill publication never grants tools, network, filesystem, model Provider, or credential access. PowerContext defines a stable authorization request and decision, built-in roles, an Access API, and an OpenAPI -extension without requiring one policy engine. The first version provides a built-in Role Binding Store. Casbin, -OpenFGA, and Policy Decision Points (PDPs) compatible with the OpenID AuthZEN Authorization API can be integrated -through adapters. +extension without requiring one policy engine. The current implementation provides a built-in Role Binding Store, an +embedded writable Casbin adapter, and a decision-only adapter for Policy Decision Points (PDPs) compatible with the +OpenID AuthZEN Authorization API. OpenFGA, OPA, and Cerbos remain possible future adapters. # Motivation @@ -82,7 +83,6 @@ operation. The Server cannot express that: - a team member may view a Handoff Report but may not approve an Experience or Skill; - B may read the versions of one shared Memory Entry but may not search the scope or read another entry; - B may read Revisions of one approved Experience or managed Skill but may not review a Candidate; -- B may use one logical Prompt but may not silently promote it to a host system or developer instruction; - a publisher may publish a selected managed Skill Revision but cannot thereby modify its source or gain host execution authority; - an active Handoff Binding covers later Revisions, while a revoked receiver may not read any Revision afterward; @@ -137,7 +137,7 @@ Protected Resource │ ├── family=memory │ ├── family=experience │ ├── family=skill -│ └── family=prompt +│ └── family=prompt (reserved, disabled) ``` Each Artifact Family Access Profile must define: @@ -163,12 +163,12 @@ shareable by default. Resource visibility, context selection, and external execution authority are separate planes: ```text -Access Plane: Which logical resource the Principal may read or use across versions +Access Plane: Which logical resource the Principal may read, write, or share across versions Context Plane: Which authorized content enters bounded PreparedContext after explicit selection Execution Plane: Whether a host installs, loads, or executes a Skill or Prompt and which tools it may use ``` -An allow decision does not propagate across planes. A logical Memory, Artifact, or Prompt grant does not place content +An allow decision does not propagate across planes. A logical Memory or Artifact grant does not place content in normal scope recall automatically. A receiver first discovers it in a “Shared with me” view, then explicitly reads it, attaches it to the current task, or forks it into a scope where the receiver may contribute. Shared content remains `untrusted_history` or untrusted instruction; Context builders and hosts still enforce their own budgets, precedence, @@ -206,7 +206,6 @@ An example Binding creation request is: { "subject": { "type": "user", - "issuer": "https://id.example.com/", "id": "00u-bob" }, "resource": { @@ -214,7 +213,7 @@ An example Binding creation request is: "scope_id": "project:payments", "identity": { "family": "handoff", - "artifact_id": "project:payments" + "artifact_id": "handoff" }, "selector": null }, @@ -261,10 +260,10 @@ Other Artifact Families use the same logical-share flow without inheriting Hando logical `entry_id` selector and other Artifacts to `{family, artifact_id}`; Revision fields do not enter the Binding. 2. The Server checks whether A may create the relevant Binding in the resource's scope, then verifies that the resource exists and is in a shareable state. -3. B discovers the logical resource through `access/resources/list` and reads existing or future versions, or - explicitly uses it, as B's own Principal. -4. To modify or maintain the content, B explicitly forks or proposes a Candidate in a scope where B has - `scope.contribute`. The original resource and Binding do not change. +3. B discovers the logical resource through `access/resources/list` and reads existing or future versions as B's own + Principal. +4. To create a derivative, B proposes a new Artifact in a scope where B has `scope.contribute`. The new logical + identity belongs to B; the original resource and Binding do not change. First-version logical grants behave as follows: @@ -272,29 +271,32 @@ First-version logical grants behave as follows: | --- | --- | --- | | `artifact.viewer` on a `family=memory` selector | Exact get of any version of one `entry_id` | Search, list, changes, revise, retire, or another entry | | `artifact.viewer` | Exact get of any approved Revision of one Experience or managed Skill identity | Candidate read/review, publication, another Artifact, or lineage bodies | -| `artifact.viewer` on `family=prompt` | Exact get of any approved Revision of one Prompt identity | Render/use, another Prompt, or automatic injection | -| `prompt.user` | `artifact.viewer` plus explicit render/use | Changing instruction priority, enabling tools, or reading credentials | -Ordinary user input remains Source evidence; the word “prompt” in its content does not make it a Prompt Artifact. A later -Prompt Artifact lifecycle may define reusable parameterized task templates. Internal prompts for Memory extraction, -Experience or Skill generation, and Handoff generation are Server implementation or configuration managed by -`server.admin`; they are not shared through `family=prompt` Artifact Bindings. Content that tells an Agent when to -apply a capability, how to perform it, and how to validate it should be a managed Skill rather than a duplicate Prompt -Artifact. +The reserved `prompt` Profile cannot receive a Binding while `enabled=false`; `prompt.user` is therefore absent from +the usable roles returned for enabled Families. Internal prompts for Memory extraction, Experience or Skill +generation, and Handoff generation are Server implementation or configuration, not shareable Prompt Artifacts. Except for Handoff's manifest-scoped evidence resolver, a logical resource response may return lineage or citation identities defined by its schema, but the grant does not propagate to those referenced resources. A general Source, Memory, or Artifact get still requires an independent decision for the target. A Provider must not create `can_read` inheritance merely because “A references B.” -## Sharing is read-only access to one evolving identity, not collaborative editing +## Viewer Bindings are read-only; ownership governs the evolving identity + +Each enabled logical Artifact has exactly one direct owner in enforced mode. The Server establishes that owner when it +creates the first Handoff or Memory identity, or records the proposer as the proposed owner and establishes ownership +when an Experience or Skill Candidate is approved. Ownership is a Server-managed relation, not a public +`artifact.owner` Binding, and it covers all existing and future Revisions of the same logical identity. + +The owner receives `artifact.read`, `artifact.write`, and `artifact.share`; Handoff owners also receive +`handoff.evidence.inspect`. `artifact.write` is required when a request creates the next Revision, revises or retires +Memory, replaces an existing Experience or Skill target, or changes managed Skill lifecycle state. A scope role may +authorize contribution or review, but it does not silently make its holder the owner of an existing Artifact. -An Artifact Binding grants only read, explicit use, or a controlled publication operation to a Server-configured -target. It does not transfer content authority over the original resource. A later Revision created by an authorized -owner becomes visible through the logical Binding, but the Binding itself cannot authorize the receiver to revise, -retire, replace, commit a later Revision, or overwrite the shared content in place. If the receiver -separately has `scope.contribute` or stronger authority in the original scope, that write authority comes from the -independent scope role, not from the share. +Viewer and receiver Bindings remain read-only with respect to the bound content. A later Revision written by the owner +becomes visible through the logical Binding, but the Binding cannot authorize the receiver to revise, retire, replace, +or commit the next Revision. To create a derivative, a receiver needs `scope.contribute` in the destination scope and +creates a new identity or Candidate whose ownership is independent of the source. State produced by the receiver remains separate from the shared original: @@ -305,7 +307,7 @@ State produced by the receiver remains separate from the shared original: | Publish a managed Skill | Writes projection or state to a Server-configured target and does not modify the source Skill Revision | | Fork, import, or copy | Requires `scope.contribute` on the destination scope; creates a new identity or Candidate with lineage to the original | -Product surfaces should offer actions such as “View,” “Use,” “Acknowledge,” “Request changes,” “Copy to my scope,” or +Product surfaces should offer actions such as “View,” “Acknowledge,” “Request changes,” “Copy to my scope,” or “Publish to configured target.” They should not present a logical share as “Edit shared content.” Ongoing co-maintenance requires a separate scope role. For an Artifact Family with Review, a contributor still creates a Candidate and uses the Review lifecycle to produce a new Revision instead of editing an approved Revision in place. Revocation prevents @@ -314,8 +316,8 @@ or fork that was previously created under independent authority. ## Publishing an Artifact across Scopes -`POST /v1/artifact-publications` copies one exact source Artifact Revision into an independent Artifact owned by a -target Scope. The business request therefore contains an exact `ArtifactAddress`, but the Access Resource remains the +`POST /v1/artifact-publications` copies one exact source Artifact Revision into an independent Artifact in a target +Scope. The business request therefore contains an exact `ArtifactAddress`, but the Access Resource remains the logical `{family, artifact_id}` identity without a Revision. Before loading or copying content, the Server requires: ```text @@ -323,10 +325,13 @@ artifact.share on the logical source Artifact scope.admin on the target Scope ``` -This keeps the authorization durable across source revisions while preserving exact publication provenance. A grant -does not copy content by itself, and a publication does not grant access to host paths, tools, networks, credentials, -or later target mutations. Family-specific publication support remains a Runtime concern; unsupported complete-state -copies fail after authorization without weakening the Access model. +This keeps the authorization durable across source revisions while preserving exact publication provenance. After the +copy succeeds, the Server establishes the publishing Principal as the direct owner of the new target identity before +returning success. The target does not inherit the source Binding or owner. Repeating the same publication repairs a +missing target-owner relation idempotently; a conflicting owner fails closed. A grant does not copy content by itself, +and a publication does not grant access to host paths, tools, networks, or credentials. Family-specific publication +support remains a Runtime concern; unsupported complete-state copies fail after authorization without weakening the +Access model. ## B takes over the Workstream @@ -350,19 +355,20 @@ or Receipt cannot enlarge those permissions. A stable team can receive scope roles instead of a new Binding for each Revision: -- `scope.viewer` reads Handoffs, Memory, approved Artifacts, Prompts, Sources, and read-only projections in the current - scope and may explicitly use approved Prompts; -- `scope.contributor` writes work evidence, Memory contributions, Handoffs, and Outcomes and proposes Artifact or Prompt +- `scope.viewer` reads Handoffs, Memory, approved Artifacts, Sources, and read-only projections in the current scope; +- `scope.contributor` writes work evidence, Memory contributions, Handoffs, and Outcomes and proposes Artifact Candidates in addition to viewer access; - `scope.reviewer` reviews Artifact Candidates in addition to viewer access; - `scope.delegator` shares logical Handoffs with receivers in addition to viewer access; -- `scope.admin` administers all roles and policies for the scope. +- `scope.admin` administers roles and policies for the scope and can authorize Artifact sharing, but is not itself a + content-reader or content-writer role. -`scope.delegate` continues to authorize only viewer or receiver Bindings for `family=handoff` Artifacts in this RFC. In -the first version, only `scope.admin` may create logical Bindings for other Artifact Families. An existing Handoff -delegator does not silently gain a wider sharing boundary. A later resource-specific delegation action is an explicit -wire-contract change. `server.admin` manages publication targets through deployment configuration; targets do not -receive Access Bindings. +`scope.delegate` authorizes only viewer or receiver Bindings for `family=handoff` Artifacts. The Artifact's direct +owner may also create or revoke its resource Binding through `artifact.share`. For other enabled Families, the owner, +`scope.admin`, or `server.admin` may administer resource Bindings. An existing Handoff delegator does not silently +gain a wider sharing boundary. `server.admin` administers server and scope policy but does not implicitly gain +`server.observe`, `scope.read`, or `artifact.write`; the legacy static Principal receives separate observer and +per-scope working roles for compatibility. These fixed roles are wire-contract vocabulary. An external PDP does not have to persist the same role names. It may map organization roles, teams, or relationships to these actions. @@ -407,7 +413,7 @@ This RFC aims to: - establish a Principal from a credential without allowing the request to override it; - support scope-level RBAC and logical Handoff receiver Bindings; - define stable Resource Kinds and an Artifact Family Access Profile contract, with logical authorization for Handoff, - Memory, Experience, Skill, and Prompt resources; + Memory, Experience, and Skill resources while reserving disabled Prompt vocabulary; - resolve evidence cited by a selected Revision of an authorized Handoff safely without opening the complete scope; - separate resource reads, context selection, Skill publication, and host execution authority; - provide a replaceable decision interface and an optional relationship mutation interface; @@ -428,7 +434,7 @@ This RFC does not define: - dynamic subscription sharing for Memory collections or Artifact catalogs whose membership changes over time; - the Prompt Artifact content schema, variable language, Review lifecycle, or host instruction-precedence policy; - per-target publication delegation or a general `execution_target` Resource; -- remote managed Skill projection or a Receiver distribution contract; or +- the remote managed Skill Receiver distribution contract, which is defined by its own lifecycle RFC; or - cross-host locators, automatic installation, or package distribution for External Skills. ## Trust model and invariants @@ -439,7 +445,7 @@ An implementation must preserve these invariants: 2. A Principal comes only from authentication middleware or trusted internal bridge context. 3. A `receiver`, `subject`, `actor`, role string, or Handoff prose in a request body cannot replace the current Principal. -4. Handoff, Memory, Artifact, and Prompt content is `untrusted_history` or untrusted instruction and cannot grant an +4. Handoff, Memory, and Artifact content is `untrusted_history` or untrusted instruction and cannot grant an action. 5. `is_internal_bridge()` may skip repeated transport authentication but never authorization. 6. Every protected operation receives a decision before it accesses a Repository or application service. @@ -453,17 +459,17 @@ An implementation must preserve these invariants: select a positive integer Revision or version, but those fields never enter the Access Resource or Binding. The Server derives the Access Profile only from `identity.family`; it rejects an independent content profile, an unknown Family, or a selector mismatch. -11. Reading Memory, Artifact, or Prompt content does not grant its lineage or citation targets and does not place it in +11. Reading Memory or Artifact content does not grant its lineage or citation targets and does not place it in PreparedContext automatically. 12. A logical-resource Binding does not grant revise, retire, replace, commit-next-Revision, or any other mutation of shared content. Receipts, feedback, projections, and forks are separate resources or operations that require independent authorization and do not modify the original resource identity, content, or Revision. -13. `prompt.use` does not change host instruction precedence. Skill publication does not authorize host loading, - execution, tools, networks, filesystems, or secrets. -14. Skill publication requires `artifact.read` on the logical `family=skill` Artifact before - resolving `target_id` or performing any host or filesystem inspection. `target_id` is not an authorization - resource, and the first version resolves only configured host-local targets. -15. Public errors, logs, metrics, and traces do not contain credentials, Handoff, Memory, Artifact, or Prompt content, +13. Every enabled logical Artifact has one immutable direct owner relation. Public Bindings cannot create, replace, or + transfer `artifact.owner`; a missing owner fails closed before Artifact authorization. +14. Host-local Skill projection requires `server.observe` and `artifact.read` before resolving `target_id` or + inspecting the filesystem. Remote target administration requires `scope.admin`, and remote publication also + requires `artifact.read`. These operations never grant host execution, tools, networks, filesystems, or secrets. +15. Public errors, logs, metrics, and traces do not contain credentials, Handoff, Memory, or Artifact content, Source bodies, target locators, or raw PDP responses. ## Principal model @@ -473,7 +479,6 @@ An implementation must preserve these invariants: ```json { "type": "user", - "issuer": "https://id.example.com/", "id": "00u-bob" } ``` @@ -482,13 +487,15 @@ The fields mean: | Field | Semantics | | --- | --- | -| `type` | `user`, `service`, or a later registered Principal type | -| `issuer` | The trusted issuer that established the identity; local credentials use a deployment-specific issuer | -| `id` | A stable opaque subject within that issuer, not a display name or email address | +| `type` | `user` or `service` | +| `id` | A deployment-wide stable opaque subject, not a display name or email address | +| `description` | Optional display metadata excluded from identity equality and policy keys | -Agent names, hosts, session IDs, and model names are provenance, not Principals by default. When an enterprise token -proves an on-behalf-of actor, an authentication adapter may add that actor to trusted request context; a PDP may then -constrain both subject and actor. A client cannot assert that actor in a JSON body. +Issuer namespacing, when needed, is normalized by the Authentication Provider into the deployment-wide opaque `id`; +`issuer` is not a public `PrincipalRef` field. Agent names, hosts, session IDs, and model names are provenance, not +Principals by default. When an enterprise token proves an on-behalf-of actor, an authentication adapter may add that +actor to trusted request context; a PDP may then constrain both subject and actor. A client cannot assert that actor in +a JSON body. The existing Handoff Receipt `receiver` remains record content. The Server separately records the authenticated Principal that produced the Receipt. If they differ, the Server rejects `accepted` or explicitly records the mismatch @@ -543,8 +550,8 @@ Revision remain in business citations, not in Access Resources: `ArtifactResourceRef.identity.family` is the only Artifact Family Access Profile discriminator. A request contains no separate `profile` field. The Server derives the Profile from the validated logical identity, avoiding conflicts such as `profile=prompt` with `family=skill`. Each Family declares its selector required, forbidden, or one -specific discriminated-union variant. The first version requires a `memory_entry` selector for `memory` and forbids a -selector for `handoff`, `experience`, `skill`, and `prompt`. +specific discriminated-union variant. The current implementation requires a `memory_entry` selector for `memory` and +forbids a selector for `handoff`, `experience`, `skill`, and the disabled `prompt` Profile. The Family registry is a fixed Server-owned contract, not an administrator-editable policy DSL. Every registration contains at least: @@ -555,21 +562,27 @@ contains at least: | `share_unit` | `artifact` or one explicit Family-owned logical selector type | | `shareable_states` | Lifecycle states in which a Binding may be created | | `base_action` | `artifact.read` in the first version | -| `additional_actions` | Family-specific use, acknowledge, or publish actions | +| `additional_actions` | Family-specific read-side or acknowledgement actions | | `grantable_roles` | Fixed logical-resource roles compatible with the Family | +| `mutation_semantics` | Owner-only mutations represented by `artifact.write` | | `parent_implications` | Child actions implied by scope roles in one direction | | `transitivity` | Whether lineage, citations, or other related resources need separate decisions; the default is none | | `resolver` | How to resolve a selected business version after logical authorization and which safe identity to return | -The first-version registry is: +The current registry is: -| Artifact Family | Share unit | Shareable state | Actions | Grantable resource roles | -| --- | --- | --- | --- | --- | -| `handoff` | logical Artifact | at least one committed Revision | `artifact.read`, `handoff.evidence.inspect`, `handoff.acknowledge` | `handoff.viewer`, `handoff.receiver` | -| `memory` | logical `memory_entry` selector | entry exists | `artifact.read` | `artifact.viewer` | -| `experience` | logical Artifact | at least one approved Revision | `artifact.read` | `artifact.viewer` | -| `skill` | logical Artifact | at least one approved Revision | `artifact.read` | `artifact.viewer` | -| `prompt` | logical Artifact | at least one approved Revision | `artifact.read`, `prompt.use` | `artifact.viewer`, `prompt.user` | +| Artifact Family | Enabled | Share unit | Shareable state | Family actions | Grantable resource roles | +| --- | --- | --- | --- | --- | --- | +| `handoff` | yes | logical Artifact | at least one committed Revision | `artifact.read`, `handoff.evidence.inspect`, `handoff.acknowledge` | `handoff.viewer`, `handoff.receiver` | +| `memory` | yes | logical `memory_entry` selector | active or retired entry exists | `artifact.read` | `artifact.viewer` | +| `experience` | yes | logical Artifact | at least one approved Revision | `artifact.read` | `artifact.viewer` | +| `skill` | yes | logical Artifact | at least one approved Revision | `artifact.read` | `artifact.viewer` | +| `prompt` | no | logical Artifact | reserved | reserved `artifact.read`, `prompt.use` vocabulary | none | + +Every enabled row also accepts `artifact.write` for its direct owner and `artifact.share` for owner- or +administrator-controlled sharing. Those actions do not become viewer actions and are not grantable as separate +resource Bindings. `artifact.owner` is exposed by role discovery as a one-per-resource, system-managed role, while +owner relations are created only by Server business flows. A Prepared Handoff has no persistent identity and cannot receive an Access Binding. A least-privilege cross-user transfer must be committed first. A pending or rejected Candidate likewise cannot receive an Artifact Binding. Even a @@ -587,6 +600,19 @@ An adapter maps a structured ResourceRef to an external PDP object ID. The mappi must not write email addresses, tokens, resource content, publication target locators, or other PII into Casbin policy, OpenFGA tuples, or audit keys. +### Artifact ownership + +The Server stores ownership separately from ordinary `AccessBinding` rows. `ArtifactOwnerRelation` contains the +logical resource, one `PrincipalRef`, trusted creation time, policy revision, and an idempotency key. It deliberately +contains no Artifact Revision. The owner relation is immutable; creating it again is idempotent only for the same +owner and key, and a different owner returns a conflict. Ownership transfer is not part of this RFC. + +In enforced mode, Artifact authorization fails closed with `artifact_owner_pending` until this relation exists. New +Memory entries and first Handoff commits are owned by the creating Principal. A new Experience or Skill Candidate +records a Server-side proposed-owner attestation; approval establishes that Principal as owner. A Candidate targeting +an existing identity must retain its existing owner. Cross-Scope publication establishes the publisher as owner of +the new target identity. + ## Action vocabulary First-version actions are stable lowercase dotted strings: @@ -596,27 +622,29 @@ First-version actions are stable lowercase dotted strings: | `server.observe` | server | Read service-level operations and observability data | | `server.admin` | server | Administer deployment access and publication-target configuration | | `scope.read` | scope | Read general resources, approved content, and projections in a Workstream | -| `scope.contribute` | scope | Write Sources, Memory contributions, Handoffs/Outcomes, and propose Artifact/Prompt Candidates | +| `scope.contribute` | scope | Create Sources, new Memory/Handoff content, Outcomes, and Artifact Candidates | | `scope.review` | scope | Review Artifact Candidates in the scope | | `scope.delegate` | scope | Create viewer or receiver Bindings for logical Handoffs | | `scope.admin` | scope | Administer roles, Bindings, and policy for the scope | | `artifact.read` | logical artifact | Read selected existing and future versions of the identity or selector defined by its Family Profile | +| `artifact.write` | logical artifact | Mutate the owner-controlled logical identity through its Family lifecycle | +| `artifact.share` | logical artifact | Administer viewer/receiver Bindings or publish an exact Revision from the logical source identity | | `handoff.evidence.inspect` | `family=handoff` artifact | Resolve a selected Revision's citation manifest through the Handoff resolver | | `handoff.acknowledge` | `family=handoff` artifact | Create a Handoff Receipt for a selected Revision | -| `prompt.use` | `family=prompt` artifact | Explicitly render or attach an authorized Prompt without deciding host instruction precedence | +| `prompt.use` | `family=prompt` artifact | Reserved; unusable while the Prompt Profile is disabled | -`artifact.read` has one meaning across every Family: read versions of only the logical identity or selector named by -the Binding. It does not include Handoff evidence, Prompt use, lineage bodies, or any mutation. Managed Skill -publication is a controlled projection of a selected readable Revision to a Server-configured target. A Family adds a -semantic action only for an operation with a genuinely different security effect. +`artifact.read` has one meaning across every enabled Family: read versions of only the logical identity or selector +named by the Binding. It does not include Handoff evidence, lineage bodies, write, or share. A Family adds a semantic +action only for an operation with a genuinely different security effect. Business operations check actions rather than role names. External role and relationship models can therefore evolve without changing application code. -Policy may make `scope.read` imply `artifact.read` for every registered Family, `handoff.evidence.inspect` for Handoffs, -and `prompt.use` for Prompts under the scope. `scope.contribute` may imply acknowledge, prepare, commit, Memory -contribution, Artifact or Prompt Candidate proposal, and Outcome writes. The reverse implication never holds: a resource -viewer or user role does not gain `scope.read` or `scope.contribute`. +The built-in parent implications are deliberately narrow. `scope.viewer`, `scope.reviewer`, and `scope.delegator` +imply `artifact.read` and Handoff evidence inspection for children. `scope.contributor` additionally implies Handoff +acknowledgement. `scope.admin` and `server.admin` imply `artifact.share`, while `server.admin` also implies +`scope.admin`. Administration never implicitly grants content read or write. The reverse implication never holds: a +resource viewer or owner does not gain a scope role. ## Built-in roles @@ -625,19 +653,24 @@ viewer or user role does not gain `scope.read` or `scope.contribute`. | `handoff.viewer` | `artifact.read`, `handoff.evidence.inspect` on one logical `family=handoff` Artifact | | `handoff.receiver` | Viewer actions plus `handoff.acknowledge` on one logical Handoff | | `artifact.viewer` | `artifact.read` on one compatible logical Artifact or selector | -| `prompt.user` | `artifact.read`, `prompt.use` on one logical `family=prompt` Artifact | +| `prompt.user` | Reserved role; not usable while `family=prompt` is disabled | +| `artifact.owner` | `artifact.read`, `artifact.write`, `artifact.share`, and Handoff evidence inspection on one logical Artifact; system-managed | | `scope.viewer` | `scope.read` | | `scope.contributor` | `scope.read`, `scope.contribute` | | `scope.reviewer` | `scope.read`, `scope.review` | | `scope.delegator` | `scope.read`, `scope.delegate` | -| `scope.admin` | Every scope and child Artifact Family action, including delegation and Binding administration | +| `scope.admin` | `scope.admin`; implies only `artifact.share` on child Artifacts | | `server.observer` | `server.observe` | -| `server.admin` | Every server, scope, and Artifact Family action | +| `server.admin` | `server.admin`; implies `scope.admin` and `artifact.share`, but no read or write action | + +`handoff.receiver` and `artifact.owner` have `one_per_resource` cardinality; all other roles are +`many_per_resource`. Owner is system-managed. Receiver and owner subjects must be a user or service. Other public role +schemas also admit a group subject, but the built-in and Casbin compositions currently report `group_subjects=false` +and reject group Binding creation until a trusted group resolver is configured. -Every resource role is read-only with respect to its bound content. `handoff.receiver` adds only the creation of a -separate Receipt. Publishing a readable Skill writes only a projection to a Server-configured target. Neither operation -may modify the source Handoff or Skill Revision. Mutation of the original resource requires an independent scope role -and the relevant domain lifecycle. +Every publicly grantable resource role is read-only with respect to its bound content. `handoff.receiver` adds only the +creation of a separate Receipt. Mutation of the original resource requires the system-managed owner relation and the +relevant domain lifecycle. The first version does not allow the public API to create roles or change role-to-action mappings. Fixed roles give OpenAPI, the Dashboard, and adapter conformance tests stable semantics. An enterprise PDP may map custom organization @@ -648,20 +681,19 @@ logical Handoff in that scope. Creating a scope role requires `scope.admin`. Cre `server.admin` and permission from deployment policy. A Principal cannot grant itself authority beyond the caller's administration boundary. -In the first version, only `scope.admin` may create `artifact.viewer` or `prompt.user` Bindings in -an administered scope. `artifact.viewer` may bind only to a logical Artifact or selector declared compatible by the -Family registry. `prompt.user` may bind only to an approved `family=prompt` Artifact. A role and Artifact Family Access -Profile or Resource Kind mismatch returns 422; insufficient -authority returns 403. The -Server must not forward an incompatible role string unchanged to an external RelationshipWriter. +An Artifact owner or `scope.admin` may create compatible viewer Bindings; `server.admin` inherits that administration +boundary. `artifact.viewer` may bind only to a logical Artifact or selector declared compatible by an enabled Family +Profile. Public `artifact.owner` Bindings and all Bindings for disabled `family=prompt` are rejected. A role and +Artifact Family Access Profile or Resource Kind mismatch returns 422; insufficient authority returns 403. The Server +must not forward an incompatible role string unchanged to an external RelationshipWriter. | Resource or Artifact Family Profile | Grantable resource roles | Binding administrator | | --- | --- | --- | -| `artifact` with `family=handoff` | `handoff.viewer`, `handoff.receiver` | `scope.delegate`, `scope.admin`, or `server.admin` | -| `artifact` with `family=memory` and a `memory_entry` selector | `artifact.viewer` | `scope.admin` or `server.admin` | -| `artifact` with `family=experience` | `artifact.viewer` | `scope.admin` or `server.admin` | -| `artifact` with `family=skill` | `artifact.viewer` | `scope.admin` or `server.admin` | -| `artifact` with `family=prompt` | `artifact.viewer`, `prompt.user` | `scope.admin` or `server.admin` | +| `artifact` with `family=handoff` | `handoff.viewer`, `handoff.receiver` | owner, `scope.delegate`, `scope.admin`, or `server.admin` | +| `artifact` with `family=memory` and a `memory_entry` selector | `artifact.viewer` | owner, `scope.admin`, or `server.admin` | +| `artifact` with `family=experience` | `artifact.viewer` | owner, `scope.admin`, or `server.admin` | +| `artifact` with `family=skill` | `artifact.viewer` | owner, `scope.admin`, or `server.admin` | +| disabled `family=prompt` | none | none | ## Authorization request and decision @@ -691,7 +723,6 @@ A normalized request is: { "subject": { "type": "user", - "issuer": "https://id.example.com/", "id": "00u-bob" }, "action": {"name": "artifact.read"}, @@ -700,13 +731,14 @@ A normalized request is: "scope_id": "project:payments", "identity": { "family": "handoff", - "artifact_id": "project:payments" + "artifact_id": "handoff" }, "selector": null }, "context": { "request_id": "pc-01K...", - "transport": "mcp" + "transport": "mcp", + "operation": "continue_handoff" } } ``` @@ -716,7 +748,7 @@ A normalized request is: ```json { "allowed": true, - "reason_code": "role_binding", + "reason_code": "role-binding", "policy_revision": "42" } ``` @@ -733,27 +765,34 @@ the `all` combination. The PEP uses one `check_batch`, or semantically equivalen application service, target adapter, or filesystem unless every decision allows access. This is not a client-authored Boolean policy DSL. -For example, managed Skill publication resolves to: +For example, cross-Scope Artifact publication resolves to two ordered requirements: ```json { - "combination": "all", + "match": "all", "requirements": [ { - "action": {"name": "artifact.read"}, + "action": "artifact.share", "resource": { "type": "artifact", "scope_id": "project:payments", "identity": {"family": "skill", "artifact_id": "retry-runbook"}, "selector": null } + }, + { + "action": "scope.admin", + "resource": { + "type": "scope", + "scope_id": "team:runbooks" + } } ] } ``` -The business request's Revision and `target_id` do not enter the Access Resource. The Server resolves those business -parameters only after the decision allows access. +The source Revision remains in the business request and publication provenance, not in the Access Resource. Host-local +and remote Skill projection likewise keep `target_id` as an operation parameter rather than an Access Resource. Alternatives such as “scope role or resource role” do not require an `any` expression. The PEP requests the child-resource action. A Provider uses a trusted parent relationship to decide whether a scope role implies that action, while a logical @@ -794,10 +833,11 @@ class RelationshipWriter(Protocol): ) -> AccessBinding: ... ``` -The built-in Provider and Casbin or OpenFGA adapters may implement both `AuthorizationProvider` and -`RelationshipWriter`. An OPA, Cerbos, or generic AuthZEN adapter may provide decisions only. Its PowerContext Binding -mutation endpoint then returns `relationship_management_unavailable`, and administrators configure relationships in -the external system. The Server must not report a successful grant and then write only a local shadow record. +The built-in Provider and included Casbin adapter implement both `AuthorizationProvider` and `RelationshipWriter` over +the canonical relational Access repository. The included AuthZEN adapter is decision-only. With that adapter, +PowerContext Binding mutation returns `relationship_management_unavailable`, and administrators configure +relationships in the external system. Future OpenFGA, OPA, or Cerbos adapters must declare the capabilities they +actually implement. The Server must not report a successful grant and then write only a local shadow record. ## Access Binding model @@ -822,6 +862,9 @@ A role, subject, or resource change revokes the old Binding and creates a new on idempotency key, and payload returns the original Binding. The same key with a different payload returns 409. Expiration does not delete a record; the decision treats it as denied. +Artifact ownership is not an `AccessBinding`. It is stored in the separate one-per-resource owner relation described +above, has no expiration, and cannot be created or transferred through `/v1/access/bindings/*`. + The built-in Binding Repository belongs to a Server access-control component. It is not added to the Runtime `context`, `source`, `memory`, `artifact`, `handoff`, or `work` application object. It may share a deployment database with the Server, but it owns an independent schema, migrations, and API. @@ -836,11 +879,11 @@ The OpenAPI source of truth adds these operations: | `POST /v1/access/check` | Check one compound `all` or `any` requirement for the current Principal | Current Principal only | | `POST /v1/access/resources/list` | List resource identities available to the current Principal | Current Principal only | | `POST /v1/access/roles/list` | Return fixed roles and action vocabulary | Authenticated Principal | -| `POST /v1/access/bindings/list` | List Bindings the caller may administer | `scope.delegate`, `scope.admin`, or `server.admin` | +| `POST /v1/access/bindings/list` | List Bindings the caller may administer | owner `artifact.share`, `scope.delegate`, `scope.admin`, or `server.admin`, according to resource | | `POST /v1/access/bindings/create` | Create a Family-compatible logical-resource or administrative Binding | Resource-specific administration action | | `POST /v1/access/bindings/revoke` | Revoke a Binding using CAS | Same administration boundary | | `POST /v1/access/bindings/replace` | Atomically revoke an immutable Binding and create its successor | Same administration boundary | -| `POST /v1/access/audit/list` | Query security audit events | `scope.admin` or `server.admin` | +| `POST /v1/access/audit/list` | Query server- or scope-bounded security audit events | `scope.admin` or `server.admin` | `check` and `resources/list` do not accept a client-selected subject. They evaluate only the current authenticated Principal, preventing ordinary users from using the API as a personnel permission oracle. @@ -853,10 +896,10 @@ to confirm that the resource exists, belongs to the declared parent, and is in a and an invisible resource both return 403 to an unauthorized caller. A 404 or Family-specific conflict is available only after the administration decision allows access. -The Access API does not create, modify, fork, render, or publish business resources. Memory, Artifact, Prompt, and -managed Skill publication operations retain their own contracts. Publisher-safe target selection belongs to the Skill -publication contract; target configuration and operator status are Server operations. None enters the Access API or -creates a target Binding. A Binding expresses only who may perform which action on an existing resource. +The Access API does not create, modify, fork, or publish business resources. Memory, Artifact, cross-Scope +publication, and managed Skill projection operations retain their own contracts. Target configuration and operator +status are Server or scope operations. None enters the Access API or creates a target Binding. A Binding expresses +only who may perform which action on an existing resource. The public `check` operation may return HTTP 200 with `allowed=false`. The same denial on a business operation returns 403 and does not call the application service. The Access API supports explanation and UI preflight; it never replaces @@ -869,14 +912,15 @@ The first-version Handoff mappings are: | Operation | Required authorization | | --- | --- | | `prepare_handoff`, `finalize_handoff`, `handoff_current_work` | `scope.contribute` on request `scope_id` | -| `commit_handoff` | `scope.contribute` on request `scope_id` | +| first `commit_handoff` | `scope.contribute` on request `scope_id`; success establishes the caller as owner | +| later `commit_handoff` with `base` | `scope.contribute` on request `scope_id` and `artifact.write` on the logical Handoff | | `continue_handoff(selection=latest)` | `artifact.read` and `handoff.evidence.inspect` on the logical `family=handoff` Artifact, directly or through parent `scope.read` | | `continue_handoff(selection=exact)` | `artifact.read` and `handoff.evidence.inspect` on the logical `family=handoff` Artifact, directly or through parent `scope.read` | | `continue_handoff(selection=prepared)` | `scope.read` on request `scope_id` | | `acknowledge_handoff` with an exact Receipt | `scope.contribute` or `handoff.acknowledge` on the logical Handoff selected by the exact Revision | | `record_task_outcome` | `scope.contribute` on request `scope_id` | -| Aggregate Handoff Report queries | Scope-level read; a logical Handoff grant is insufficient | -| Handoff Report administration | `scope.admin` or an appropriate server administration action | +| Handoff Report with exact Scope selection | `scope.read` for every selected Scope; a logical Handoff grant is insufficient | +| Handoff Report with a non-exact selection | `server.observe` | When a receiver calls Continue, the Server builds the logical Handoff ArtifactResourceRef before reading a Revision. For `selection=exact`, it derives the logical identity from the request's exact `ArtifactReference`; for @@ -895,34 +939,33 @@ client-selected bypass path: | --- | --- | | Memory search/list/changes | `scope.read` on request `scope_id`; a logical Memory Entry grant is insufficient | | Exact Memory get | `artifact.read` on the logical `family=memory` Artifact plus `memory_entry.entry_id`, directly or through parent `scope.read` | -| Memory flush/remember/revise/retire | `scope.contribute`; a logical viewer grant is insufficient | +| Create a Memory entry | `scope.contribute`; success establishes the caller as owner | +| Flush Memory | `scope.contribute` plus `artifact.write` on every existing entry that may be changed; new entries become caller-owned | +| Revise or retire one Memory entry | `artifact.write` on the logical `memory_entry` selector | | Approved Experience/managed Skill exact get | `artifact.read` on the logical Artifact identity derived from the exact request, directly or through parent `scope.read` | -| Experience/Skill propose or generate | `scope.contribute` | +| Experience/Skill propose or generate a new identity | `scope.contribute`; the Server attests the caller as proposed owner | +| Experience/Skill proposal targeting an existing identity | `scope.contribute` plus `artifact.write` on that identity | | Candidate list/get | `scope.read`; a logical Artifact grant does not expose Candidates | | Candidate revise/approve/reject | `scope.review` | -| Approved Prompt exact get | `artifact.read` on a logical `family=prompt` Artifact, directly or through parent `scope.read` | -| Approved Prompt render/use | `prompt.use`, directly or through parent `scope.read` | -| Prompt propose/revise | Candidate operation defined by the Prompt lifecycle plus `scope.contribute` | -| List enabled publication targets for an exact managed Skill | `artifact.read` on the logical `family=skill` Artifact | -| Publish managed Skill | `artifact.read` on the logical `family=skill` Artifact | +| Managed Skill lifecycle mutation | `artifact.write` on the logical Skill | +| Host-local Skill projection status/publish/unpublish | `server.observe` and `artifact.read` on the logical Skill | +| Remote Skill target administration | `scope.admin` | +| Publish a Skill Revision to a remote target | `scope.admin` and `artifact.read` on the logical Skill | +| Cross-Scope Artifact publication | `artifact.share` on the logical source and `scope.admin` on the target Scope | An exact-get resolver derives the complete logical identity from a validated business request and discards Revision -fields for authorization. A bare Memory `entry_id`, Artifact `artifact_id`, or Prompt name without its scope and Family +fields for authorization. A bare Memory `entry_id` or Artifact `artifact_id` without its scope and Family is not an authorization key. Search, aggregate projections, and the Candidate Inbox remain collection operations; a logical grant cannot enter them. -The Prompt Family Access Profile specifies authorization vocabulary and resolver behavior only. A deployment reports -that Family as enabled only after it registers an immutable approved `family=prompt` Artifact lifecycle and exposes -exact get and use operations consistent with this section. A version without Prompt domain operations may implement -other Families, but it must reject `family=prompt` Bindings and must not claim `prompt.user` is usable in `roles/list`. +The Prompt Family Access Profile reserves authorization vocabulary only. The current deployment reports +`prompt.enabled=false`, rejects `family=prompt` Bindings, and omits `prompt.user` from roles usable by enabled Families. -`target_id` is a Server-configured publication operation parameter, not an authorization key or Resource. Only -`server.admin` may configure, modify, or remove a target; `server.observe` or `server.admin` protects detailed target -status. An operator status response contains only target ID, Agent kind, capabilities, desired and applied exact -Revisions, a stable state, and a safe reason code. It does not expose host paths, Agent homes, credentials, or raw OS -errors. For publication and publisher target-list requests, the Server must allow `artifact.read` on the logical Skill -before resolving `target_id` or reading the target registry. A standalone operator status request first checks the -server-level action. +`target_id` is an operation parameter, not an authorization key or Resource. Host-local target inspection requires +`server.observe` plus logical Skill read. The remote distribution lifecycle uses scope-owned targets: their +administration requires `scope.admin`, and setting desired publication additionally requires logical Skill read. +Target credentials protect Receiver-only reconcile, download, and receipt operations outside user-Principal Access. +Public status never exposes host paths, Agent homes, credentials, or raw OS errors. ## OpenAPI access metadata @@ -1055,7 +1098,7 @@ Audit does not contain: - Bearer tokens, cookies, client secrets, or PDP credentials; - Handoff objectives, state, or next action; -- Source, Memory, Artifact, Prompt, PreparedContext, or citation bodies; +- Source, Memory, Artifact, PreparedContext, or citation bodies; - publication-target locators, host paths, credential references, or raw Receiver or OS errors; - arbitrary exception fields, configured PDP URLs, or raw provider responses; - email addresses, display names, or unnecessary directory attributes. @@ -1080,9 +1123,9 @@ relationship lookup first or declares self-service mutation unsupported. Every Artifact Family follows the same “persist or approve first, bind second” sharing rule. A failed Binding creation does not roll back or recreate a business Revision; the client retries only the same idempotent Binding mutation. -Skill publication is a projection operation protected by the logical Skill read decision. It creates no content Revision and -creates no target Binding or change to target authorization state. A failed target apply retains retryable -desired/applied state and a safe reason without placing local paths or underlying errors in public audit. +Skill projection is protected by logical Skill read plus the applicable server or scope administration boundary. It +creates no source content Revision and no Access Binding. A failed target apply retains retryable desired/applied state +and a safe reason without placing local paths or underlying errors in public audit. Receipt creation retains the existing exact-selection and evidence rules. The decision occurs before the Receipt transaction. If authority is revoked concurrently immediately after the check, a colocated Provider and Binding Store @@ -1100,9 +1143,9 @@ tests and does not provide passwords, a directory, or a custom policy language. ### Casbin adapter -A Casbin adapter can use RBAC with domains: +The included Casbin adapter uses the canonical Access relationships with Casbin enforcement semantics: -- subject maps to an issuer-scoped opaque ID; +- subject maps to a deployment-wide opaque Principal or group ID; - domain maps a server resource to the deployment access namespace and a scope or Artifact resource to its canonical scope resource namespace; - object maps to a canonical server key, scope key, or Artifact key containing Family and selector; @@ -1114,67 +1157,20 @@ adapter derives the domain from a trusted ResourceRef supplied by the Server. Fo produces canonical keys while scope or server role assignments produce parent constraints; the Casbin adapter does not enumerate the business Repository. -### OpenFGA adapter - -OpenFGA naturally represents relationships among users, groups, scopes, and logical child resources. Every Artifact -Family uses one `artifact` object type. The object ID contains the canonical scope, Family, Artifact ID, and selector; -it contains no Revision. The Server -validates relation compatibility through the Family registry before a tuple write. A new read-only Family therefore -does not require a new OpenFGA type: - -```text -type user - -type server - relations - define observer: [user] - define admin: [user] - define can_observe: observer or admin - define can_admin: admin - -type scope - relations - define parent: [server] - define viewer: [user] - define contributor: [user] - define reviewer: [user] - define delegator: [user] - define admin: [user] - define can_read: viewer or contributor or reviewer or delegator or admin or admin from parent - define can_contribute: contributor or admin or admin from parent - define can_review: reviewer or admin or admin from parent - define can_delegate: delegator or admin or admin from parent - define can_admin: admin or admin from parent - -type artifact - relations - define parent: [scope] - define viewer: [user] - define handoff_viewer: [user] - define handoff_receiver: [user] - define prompt_user: [user] - define can_read: viewer or handoff_viewer or handoff_receiver or prompt_user or can_read from parent - define can_read_handoff_evidence: handoff_viewer or handoff_receiver or can_read from parent - define can_acknowledge_handoff: handoff_receiver or can_contribute from parent - define can_use_prompt: prompt_user or can_read from parent -``` - -The adapter maps `server.observe` to `server#can_observe` and `server.admin` to `server#can_admin`. `admin from parent` -continues to make deployment `server.admin` imply scope administration and child Artifact Family actions in one -direction. `server.observer` gains none of those permissions. +### Future OpenFGA adapter -The adapter uses an explicit authorization model ID for Check, ListObjects, and tuple writes. Tuples contain only -opaque IDs, never email addresses or Handoff content. Model migration switches the configured model ID explicitly; it -does not use an implicit latest model. -For lists, logical resource relations may produce canonical keys through ListObjects, while scope or server roles produce trusted -parent constraints directly. The adapter does not require an object tuple for every business Artifact that has no -logical Binding. +No OpenFGA adapter is included in the current implementation. A future adapter may map the same canonical server, +scope, Artifact, owner, viewer, and receiver relationships to tuples, but it must preserve the exact role table above: +administration must not imply content read or write, Artifact object IDs must omit Revision, and safe listing must not +enumerate the business repository before authorization. It must also expose an explicit authorization model ID and +declare relationship, group, and resource-filter capabilities accurately. -### AuthZEN, OPA, and Cerbos adapters +### AuthZEN adapter and future OPA or Cerbos adapters -An AuthZEN adapter maps `AccessRequest` to the Authorization API subject, action, resource, and context and maps the -decision back to `AccessDecision`. An OPA adapter can submit the same structure as its input document. A Cerbos adapter -can map it to principal, resource, and actions. +The included AuthZEN adapter maps point and batch `AccessRequest` values to the Authorization API subject, action, +resource, and context and maps only a bounded decision plus optional policy revision back to `AccessDecision`. It is +decision-only: safe resource filtering and relationship management are unavailable. OPA and Cerbos are possible +future adapters, not current deployment options. Decision interoperability does not imply policy administration interoperability. If an organization manages policy through GitOps, IAM, or a separate administration plane, PowerContext consumes decisions and safe resource filters but @@ -1184,7 +1180,7 @@ from PDP search or trusted relationship data also reports `safe_resource_filteri ## Configuration and compatibility -The Server provides two explicit modes: +`POWERCONTEXT_SERVER_ACCESS_MODE` is the only supported Access switch and accepts two values: | Mode | Behavior | | --- | --- | @@ -1195,18 +1191,23 @@ An upgrade cannot fall back to `disabled` because external identity is configure explicit. Capabilities and readiness report the current mode and whether relationship management, batch checks, and `safe_resource_filtering` are available. +`POWERCONTEXT_SERVER_AUTH_TOKEN` is compatibility authentication only. In `enforced` mode, when no Authentication +Provider is injected, it authenticates the fixed `service/server-token` Principal and the built-in Access service +bootstraps that Principal with separate `server.observer`, `server.admin`, and per-scope working roles. It cannot model +multiple users. The legacy pair `POWERCONTEXT_SERVER_AUTH_ENABLED=true` plus +`POWERCONTEXT_SERVER_AUTH_TOKEN=...` maps to `ACCESS_MODE=enforced`. A token without enforced mode is rejected, and an +enforced deployment without either an injected Authentication Provider or this compatibility token fails startup. + `disabled` is suitable only for a local environment whose caller already trusts the whole process and catalog. Documentation cannot describe it as a secure multi-user configuration. Remote, multi-user, or shared-Dashboard deployments use `enforced`. -`access/me` and readiness also report enabled Resource Kinds and an `artifact_families` capability map. Each Family -entry contains at least `enabled`, `share_unit`, available actions, and grantable roles. For example, a deployment -without the Prompt lifecycle reports `prompt.enabled=false`. `operation_capabilities.skill_publication` separately -reports whether host-local managed Skill publication and publisher-safe target selection are available. It is true -only when the Skill Family, both domain operations, and at least one enabled host-local target are available; it is -not a Resource Kind or bindable profile. When a Provider lacks `safe_resource_filtering`, multi-requirement checks, or -relationship mutation, the relevant capability is false. The Server must not accept a Binding it cannot subsequently -enforce or revoke. +`access/me` reports the Principal, mode, Resource Kinds, Provider capabilities, and an `artifact_families` capability +list. Each Family entry contains `enabled`, `share_unit`, action vocabulary, and grantable roles. Disabled Prompt still +reports its reserved actions but has no grantable role. Readiness separately reports stable Access mode, provider +state, Resource Kinds, and Family enabled/disabled state. When a Provider lacks safe filtering, multi-requirement +checks, relationship mutation, groups, or multiple Principals, the corresponding capability is false. The Server must +not accept a Binding it cannot subsequently enforce or revoke. ```json { @@ -1214,7 +1215,10 @@ enforce or revoke. "provider_capabilities": { "safe_resource_filtering": true, "multi_requirement_check": true, - "relationship_management": true + "relationship_management": true, + "group_subjects": false, + "multi_principal": false, + "max_direct_resource_keys": 10000 }, "artifact_families": [ { @@ -1227,14 +1231,11 @@ enforce or revoke. { "family": "prompt", "enabled": false, - "share_unit": "revision", - "actions": [], + "share_unit": "artifact", + "actions": ["artifact.read", "prompt.use"], "grantable_roles": [] } - ], - "operation_capabilities": { - "skill_publication": {"enabled": true} - } + ] } ``` @@ -1242,9 +1243,9 @@ Adding authorization metadata to an existing OpenAPI operation does not change i but it adds a 403 response and changes unauthorized behavior. The generated Client maps 401, 403, and 503 to stable, distinct exceptions; it does not treat 403 as an empty result. -## Implementation slices +## Implementation status -Implementation proceeds in independently verifiable slices: +The current implementation delivers these independently verifiable slices: 1. **Contract and Principal**: OpenAPI Access models, operation metadata, generated `Operation.access`, trusted request Principal, and stable errors. @@ -1252,16 +1253,16 @@ Implementation proceeds in independently verifiable slices: audit. 3. **Logical Handoff receiver**: post-commit Binding creation, exact/latest Continue, citation-manifest resolver, exact acknowledge, future-Revision visibility, revocation, and expiration. -4. **Artifact Family Access Profiles**: unified ArtifactResourceRef, Family registry, Memory selector, logical read/use - resolvers, role compatibility, and non-transitive lineage. -5. **Skill publication**: a Server-configured host-local target registry, publisher-safe selection, operator status, - logical Skill authorization for exact business publication, and redacted failure state. +4. **Artifact Family Access Profiles and ownership**: unified ArtifactResourceRef, Family registry, Memory selector, + system-managed logical ownership, read/write/share resolvers, role compatibility, and non-transitive lineage. +5. **Publication and distribution**: cross-Scope publication, host-local Skill projection, and remote Skill + distribution with their distinct logical Artifact and administrative requirements. 6. **Safe listing and UI**: authorized resource listing, Handoff inbox, “Shared with me,” Dashboard permission projection, and authorization-aware pagination. 7. **MCP parity**: Principal propagation through the internal bridge, tool-discovery UX, and invocation-time enforcement. -8. **External adapters**: implement Casbin or OpenFGA first, then validate an AuthZEN-compatible PDP with the same - conformance suite. +8. **Provider adapters**: built-in and embedded Casbin relationship-capable profiles plus a decision-only AuthZEN + adapter. OpenFGA, OPA, and Cerbos remain future work. 9. **Migration**: legacy static admin, configuration validation, Family capabilities, readiness, and operator documentation. @@ -1290,40 +1291,39 @@ The implementation of this RFC is complete only when these observable scenarios - the MCP internal bridge uses the original Principal and returns the same denial as HTTP; - the API denies a request even when Dashboard controls are bypassed or fail to hide it; - in explicit `enforced` mode, a legacy static token becomes local admin only when no Authentication Provider is injected; -- `server.observer` can read protected service and publication status but cannot modify access or target configuration; - `server.admin` can perform both classes of operation, with equivalent Built-in, Casbin, and OpenFGA results; -- built-in, Casbin/OpenFGA, and AuthZEN adapters return equivalent decisions for the same conformance vectors; +- `server.observer` can read protected service state but cannot modify access or target configuration; `server.admin` + can administer those resources but does not implicitly receive content read or write; +- built-in and Casbin providers return equivalent decisions for the same canonical relationships; the AuthZEN adapter + maps point and batch decisions and fails closed on malformed or unavailable responses; - a request cannot submit an independent content profile or Revision in an Access Resource; an unknown or disabled Family, a missing or extra selector, or a Family-role mismatch returns 422 and writes no Binding; -- `artifact.viewer` always maps only to `artifact.read` for Experience, Skill, Prompt, and a `memory_entry` selector; +- `artifact.viewer` always maps only to `artifact.read` for Experience, Skill, and a `memory_entry` selector; the Family never adds use, publish, acknowledge, or mutation implicitly; - `artifact.viewer` can get historical and future versions of an authorized Memory Entry through `family=memory` and an `entry_id` selector, but cannot search, list, revise, retire, or read another entry; - a logical Artifact viewer can read approved Revisions of one Experience or managed Skill but cannot see Candidates, another Artifact, or dereference lineage bodies; -- `artifact.viewer` may only read a Prompt while `prompt.user` may use it explicitly; neither role changes host - instruction precedence or places the Prompt in normal recall automatically; +- `family=prompt` is reported disabled, rejects Bindings, and does not expose `prompt.user` as usable for an enabled + Family; - a logical-resource role cannot revise, retire, replace, or commit a later Revision of the shared original, even when the request supplies the expected version; +- an enabled Artifact without an owner relation fails closed; first creation or approval establishes exactly one + immutable owner, and public Binding APIs cannot assign or transfer `artifact.owner`; +- an Artifact owner can read, write, and share its logical identity across Revisions without receiving a separate + viewer Binding; scope and server administration do not implicitly grant owner write access; - a Receipt created by acknowledgement and a target projection created by publication do not change the source identity, content, Revision, or digest; - a fork, import, or copy is denied without `scope.contribute` on the destination scope; when allowed, it creates a new identity or Candidate and leaves the original unchanged; -- managed Skill publication runs only when `artifact.read` allows access on the logical Skill; any denial or - unavailable decision prevents `target_id` resolution, host-path inspection, and projection - writes; after authorization, an unknown or disabled target still rejects publication; -- the publisher target list reads the registry only after `artifact.read` on the logical Skill allows access and - returns only safe identities and capabilities for enabled targets; detailed status still requires `server.observe` - or `server.admin`; -- the first version rejects a remote Receiver target without reading remote credentials or opening a network - connection; -- a Principal with `artifact.read` may publish a selected exact Revision of its authorized logical Skill to any enabled - target in the deployment; the first version has no target Binding or per-target delegation; +- host-local managed Skill projection requires both `server.observe` and logical Skill `artifact.read` before target + resolution or filesystem inspection; remote target administration requires `scope.admin`, while publishing a + Revision also requires logical Skill read; +- cross-Scope publication requires logical source `artifact.share` plus target `scope.admin`, preserves the exact + source Revision as provenance, and establishes the publisher as owner of the new target identity; - `resources/list` totals, cursors, and rows describe only the selected Resource Kind and Artifact Family resources discoverable by the current Principal; -- a deployment without a Prompt lifecycle rejects `family=prompt` Bindings; one without an available publication - operation reports `operation_capabilities.skill_publication.enabled=false`; and -- Access Audit contains no token, Handoff, Memory, Artifact, or Prompt content, Source body, target locator, or raw PDP +- a deployment without a Prompt lifecycle rejects `family=prompt` Bindings and reports `enabled=false`; and +- Access Audit contains no token, Handoff, Memory, or Artifact content, Source body, target locator, or raw PDP error. Cross-component acceptance scenarios belong in `tests/e2e/` and assert through the public HTTP and MCP contracts. @@ -1343,17 +1343,16 @@ Separating decisions from relationship management makes the adapter surface more Assuming every external PDP lets PowerContext write policy would, however, make a false portability promise. Revocation blocks future access but cannot erase information a receiver has already read, captured, or exported. -Handoffs, Memory, Artifacts, or Prompts containing highly sensitive material still need content minimization, external +Handoffs, Memory, or Artifacts containing highly sensitive material still need content minimization, external data classification, and export controls. -Artifact Family Access Profiles add a registry, selectors, a role compatibility matrix, and conformance vectors. Skill -publication checks `artifact.read` on the logical Artifact before resolving the exact business Revision. A remote PDP without an atomic -multi-requirement decision adds latency and a bounded TOCTOU risk whose policy revision must be recorded. +Artifact Family Access Profiles add a registry, selectors, ownership, a role compatibility matrix, and conformance +vectors. Multi-requirement publication and projection checks add decision work; a remote PDP without an atomic batch +decision adds latency and a bounded TOCTOU risk whose policy revision must be recorded. -The first version does not place targets in authorization policy. A Principal with `artifact.read` on a logical Skill -may publish it to any enabled target in the deployment. A deployment that needs target-specific isolation must defer -the capability, isolate deployments, or wait for a separate RFC to define a generic `execution_target` Resource. This -RFC does not prematurely encode that model as a Skill-specific resource. +The Access model does not make `target_id` a Resource. Host-local targets use the server-observer boundary; remote +targets use their scope-administration boundary. A deployment that needs grants for individual targets must isolate +them by Scope or wait for a separate RFC to define a generic `execution_target` Resource. The Prompt Family Access Profile defines only an authorization boundary. It cannot replace the Prompt Artifact lifecycle or host instruction-precedence contract. A deployment reports that Family unavailable until those business @@ -1366,7 +1365,7 @@ the PowerContext public API does not immediately provide a custom role editor. ## Chosen: independent Server PEP plus replaceable PDP -This design keeps Handoff, Memory, Artifact, Prompt, and Runtime models independent of the identity system +This design keeps Handoff, Memory, Artifact, and Runtime models independent of the identity system while giving HTTP, MCP, and the Dashboard one enforcement path. Stable action vocabulary maps across Casbin, OpenFGA, OPA, Cerbos, and enterprise IAM more reliably than stable external role names. @@ -1468,18 +1467,18 @@ authorization. [OPA](https://www.openpolicyagent.org/docs/integration) provides [Cerbos CheckResources](https://docs.cerbos.dev/cerbos/latest/api/index.html) provides batch decisions over principals, resources, and actions. These systems are adapter targets; they do not change the PowerContext Handoff lifecycle. -# Unresolved questions +# Open questions -The RFC must resolve these choices before merge, but they do not change the core security boundary: +These product choices remain outside the implemented security boundary: -- whether the first external conformance adapter is Casbin or OpenFGA; -- whether the built-in Provider ships with the default Server extra or a separate optional extra; - how the Dashboard selects a canonical recipient from the deployment identity directory; the Access API in this RFC does not provide directory search; -- whether an enforced deployment requires `safe_resource_filtering` or may disable the corresponding Dashboard lists; +- which external identity source supplies trusted group membership; the built-in Provider currently reports + `group_subjects=false`; - whether deployment policy sets a default expiration for `handoff.receiver` or the UI requires an explicit choice; - whether the UI suggests a separate `scope.contributor` grant after a Handoff receiver creates a Receipt, without ever performing that upgrade automatically; +- whether a future governed workflow permits Artifact ownership transfer; and - whether the later Prompt Artifact lifecycle uses one fixed Review policy or distinguishes private personal templates from organization-approved templates. @@ -1502,7 +1501,6 @@ The subject/action/resource contract can later support: `artifact.read` action; - a generic `execution_target` Resource Kind and per-target grants shared by Skill, Prompt, or other execution content, defined in a separate RFC; -- remote managed Skill targets after a separate Receiver distribution contract and trust-boundary review; - shared collections with explicit membership and Revision manifests, plus subscription selection through Context policy; - a bounded decision cache after a clear revocation-staleness guarantee exists. diff --git a/docs/zh/rfcs/1304_experience_skill_review_page.md b/docs/zh/rfcs/1304_experience_skill_review_page.md index 436abeee5..a8ce3496b 100644 --- a/docs/zh/rfcs/1304_experience_skill_review_page.md +++ b/docs/zh/rfcs/1304_experience_skill_review_page.md @@ -3,7 +3,9 @@ - RFC PR: [oceanbase/powercontext#1304](https://github.com/oceanbase/powercontext/pull/1304) - Related RFCs: [RFC 0050](0050_artifact_candidate_review_inbox.md)、 [RFC 0051](0051_experience_skill_artifact_families.md)、 - [RFC 0072](0072_scoped_statistics_and_usage.md) + [RFC 0072](0072_scoped_statistics_and_usage.md)、 + [RFC 1345](1345_scope_organization_and_agent_integration.md) 和 + [RFC 1396](1396_handoff_access_control.md) # Summary @@ -11,10 +13,10 @@ Candidate/Review 生命周期的用户界面投影,不会创建另一套审核模型、改变 Candidate 持久化,也不会绕过现有 HTTP operation。 -PowerContext 已经提供个人 Dashboard、配置好的 Dashboard scope、Bearer authentication,以及列出、读取、修改、批准和 +PowerContext 已经提供个人 Dashboard、经过 Access 过滤的持久 Scope discovery、authentication,以及列出、读取、修改、批准和 拒绝 Candidate 的 Review operation。提议的 `/reviews` 页面把这些能力组合成一个 scoped Review Inbox。审核者可以: -1. 选择一个已配置的 scope; +1. 选择一个当前可见的持久 Scope; 2. 按状态和 Family 筛选当前 Candidate head; 3. 查看类型化 Experience 或 Skill proposal 及其精确证据引用; 4. 在不改变证据的情况下修改 proposal; @@ -23,9 +25,9 @@ PowerContext 已经提供个人 Dashboard、配置好的 Dashboard scope、Beare 7. 当另一位审核者先修改 Candidate 时,显式处理并发冲突。 页面默认显示 pending。approved 和 rejected Candidate 作为只读视图提供。Experience 和 Skill 共用一个页面,因为它们 -共享同一 Candidate 生命周期;每个 Family 仍保留自己的展示方式和编辑表单。首版不增加 Candidate generation、证据内容 -预览、审核者身份、RBAC、任务分派、通知、批量操作或 Skill 执行能力。发布是 approval 之后的独立显式操作,只能写入 -配置中明确允许的 host-local Agent target。 +共享同一 Candidate 生命周期;每个 Family 仍保留自己的展示方式和编辑表单。页面不增加 Candidate generation、证据内容 +预览、审核者身份、role editor、任务分派、通知、批量操作或 Skill 执行能力,而是复用 RFC 1396 的授权。发布是 +approval 之后的独立显式操作,只能写入配置中明确允许的 host-local Agent target。 # Motivation @@ -197,7 +199,7 @@ Reject 需要提供非空且不超过 2,000 个字符的原因。成功后不会 首版目标如下: - 让现有 Experience 和 managed Skill Review 生命周期可以从 Server UI 使用; -- 明确选择 scope,并将选择范围限制为配置好的 Dashboard scope; +- 明确选择 scope,并将范围限制为当前 Principal 可见的持久 Scope; - 将每个 Family 展示为可审核的 domain object,而不是通用 JSON; - 保留精确 Candidate-version 和 target CAS 行为; - 让不受信任内容保持 inert,并把批准与执行权限分离; @@ -212,7 +214,7 @@ Reject 需要提供非空且不超过 2,000 个字符的原因。成功后不会 - 编辑 Candidate evidence、target、lineage 或 generation reason; - 渲染 Source 内容或任意 Artifact evidence preview; - 自动发布、任意路径导出、Skill 执行、运行时热加载或回滚; -- reviewer identity、RBAC、SSO、分派、通知、服务级目标和批量操作; +- reviewer identity、role editor、SSO、分派、通知、服务级目标和批量操作; - Candidate retention、reopen、delete、semantic diff 或 version-history 浏览; - 面向未来 Artifact Family 的 generic form renderer; - 新的前端框架或独立 Web application。 @@ -223,7 +225,7 @@ Reject 需要提供非空且不超过 2,000 个字符的原因。成功后不会 | 现有 surface | Review 页面用途 | | --- | --- | -| `GET /dashboard/scopes` | 列出 Server 配置显式暴露的 scope | +| `GET /dashboard/scopes` | 列出经过 Access 过滤的持久 Scope;`enforced` mode 下只返回当前 Principal 拥有 `scope.read` 的 Scope | | `POST /v1/artifact-candidates/list` | 按 scope、状态、Family 和 cursor 分页读取当前 head | | `POST /v1/artifact-candidates/get` | 刷新一个当前 Candidate head | | `POST /v1/artifact-candidates/revise` | 追加一个完整 replacement proposal | @@ -235,8 +237,8 @@ Reject 需要提供非空且不超过 2,000 个字符的原因。成功后不会 | Dashboard authentication utilities | 将现有 Bearer token 附加到 same-origin request | | Dashboard page UI utilities | 复用 locale、theme、status 和 stale-request handling 模式 | -本 RFC 不需要 OpenAPI 变更、generated client 变更、数据库 migration 或新的公开 persistence contract。两个 -`/dashboard/skill-projections/*` endpoint 与 `/dashboard/scopes` 一样,是 authenticated Server UI supporting surface:它们 +本 RFC 不需要 OpenAPI 变更、generated client 变更、数据库 migration 或新的公开 persistence contract。 +`/dashboard/skill-projections/*` route 与 `/dashboard/scopes` 一样,是 authenticated Server UI supporting surface:它们 操作 Server host 上明确配置的本地 root,不是跨 host 的 PowerContext API,也不接受调用方提供的路径。可移植的 exact-read 仍由公开 `get_skill` contract 提供,CLI export 保持可用。 @@ -246,16 +248,16 @@ Review 页面属于个人 Dashboard feature: - route:`GET /reviews`; - availability:仅在 `DashboardConfig.enabled` 为 true 时 mount; -- scopes:使用 statistics Dashboard 的同一组有序 `DashboardConfig.scopes`; +- scopes:使用 statistics Dashboard 的同一组经过 Access 过滤的有序持久 Scope descriptor; - authentication:使用相同 Server Bearer policy 和 same-origin request helper; -- navigation order:三者都可用时依次为 Dashboard、Review、Handoff Report。 +- navigation order:三者都可用时依次为 Dashboard、Review、Handoff Report; - publication targets:只包含 `allow_managed_publish=true` 的显式 `AgentSkillTarget`;旧的 `CodexSkillRoot` 继续作为 Codex-only 兼容格式,默认没有可写目标。 禁用 Dashboard 会同时移除 Dashboard 和 Review route。Handoff Report 仍可按其现有配置独立使用。 -首版 Review 页面不接受 query parameter 中任意传入的 `scope_id`。它默认选择第一个已配置 scope,并允许审核者通过已配置的 -picker 切换。这可以避免把形似 scope 的输入表现成权限,也避免 deep link 暴露未配置的 scope。 +Review 页面不接受 query parameter 中任意传入的 `scope_id`。它默认选择第一个可见持久 Scope,并允许审核者通过经过 +Access 过滤的 picker 切换。Caller 提供的 `scope_id` 永远不视为授权;每个数据 request 仍由 Server PEP enforce。 ## Page state and request ordering @@ -263,7 +265,7 @@ picker 切换。这可以避免把形似 scope 的输入表现成权限,也避 ```text authentication state -configured scopes +visible durable scopes selected scope selected family filter selected status filter @@ -379,7 +381,7 @@ publication status request 使用精确 approved ArtifactRef: } ``` -Server 首先验证该 Artifact 是指定 approved Skill Candidate 的精确 `result_artifact`,随后只对配置好的 Dashboard scope 和允许 +Server 首先验证该 Artifact 是指定 approved Skill Candidate 的精确 `result_artifact`,随后只对当前选择的可见 Scope 和允许 managed publish 的 Agent target 返回目标。每个目标携带 `target_id`、`agent_kind` 和 installation scope,并返回稳定 state:`unpublished`、 `current`、`update_available`、`conflict`、`drifted` 或 `incompatible`,并独立返回 discovery 的 `available`、 `unavailable` 或 `not_published`。 @@ -399,7 +401,7 @@ references 或 assets,因此检测到额外 package 文件也视为 drift, | 状态 | 行为 | | --- | --- | -| 没有配置 scope | 说明 Dashboard scope 配置要求,不发送 Candidate request | +| 没有可见 Scope | 说明当前 Principal 没有可用持久 Scope,不发送 Candidate request | | filtered page 为空 | 说明哪个 scope、status 和 Family 没有 Candidate | | 正在加载 list | 保持 filter 可见,并将 list 标记为 busy | | 正在加载 detail | 保持 selected row 可见,并将 detail pane 标记为 busy | @@ -452,7 +454,7 @@ Candidate、通过页面加载、执行决策,并验证最终 Candidate 与 Ar | Availability | `/reviews` 仅在 Dashboard 启用时存在,并出现在 primary navigation | | Authentication | 现有 optional Bearer flow 保护页面数据并处理 `401`,不增加 token store | | Scope isolation | 切换 scope 时在其他响应渲染前清除 rows、detail、cursor、conflicts 和 drafts | -| Default Inbox | 首个请求列出第一个配置 scope 下 pending Experience 和 Skill current head | +| Default Inbox | 首个请求列出第一个可见 Scope 下 pending Experience 和 Skill current head | | Filtering | Family 或 status 变化会重置 pagination,不混合不同 filter 的 row | | Pagination | Load more 沿用 `next_cursor`,保留 Server order,并按 Candidate ID 去重 | | Experience | 四个类型化字段、reason、target 和精确 evidence reference 可读 | @@ -485,7 +487,8 @@ publication update、optional authentication、两种 locale 和窄屏 viewport - 只有精确 reference 而没有 Source-body preview,限制了审核者在单个页面中查看证据的深度。 - 统一 Inbox 虽共享生命周期,仍需要 Family-specific rendering 和 validation branch。 - 首版 revision form 不允许修改 evidence,因此部分修正仍需要 CLI、MCP 或新的 Candidate。 -- 页面改善了治理入口,但不提供 reviewer attribution、authorization separation 或组织级 audit log。 +- 页面不增加 reviewer attribution 或组织级 audit UI;authorization 与 audit enforcement 来自共享 Access boundary,而非 + page-local logic。 - host-local publish 只对 Server 进程所在主机有效;远程浏览器操作的是 Server host,不是浏览器所在设备。 # Rationale and alternatives @@ -512,8 +515,8 @@ Experience 和 Skill 更可能堆积,或在没有符合 domain 的结构化查 本 RFC 展示该 contract,不改变它。 - RFC 0051 定义 Experience 和 managed Skill proposal shape、lineage,以及 Skill approval 与 execution authority 的边界。 本 RFC 为这些 shape 提供独立 review view。 -- RFC 0072 和现有 Dashboard 建立 configured scope discovery、scoped pending count、authentication、localization、theme 和 - Server-owned static delivery。 +- RFC 0072 和现有 Dashboard 建立 scoped pending count、localization、theme 和 Server-owned static delivery;RFC 1345 + 提供持久 Scope discovery,RFC 1396 提供 Principal-aware filtering 与 enforcement。 - Handoff Report 页面证明:focused workflow 可以共享 Server navigation 和 page utility,而无需成为 statistics Dashboard 的一部分。 diff --git a/docs/zh/rfcs/1396_handoff_access_control.md b/docs/zh/rfcs/1396_handoff_access_control.md index f23d2ef78..aeb452af1 100644 --- a/docs/zh/rfcs/1396_handoff_access_control.md +++ b/docs/zh/rfcs/1396_handoff_access_control.md @@ -54,15 +54,16 @@ PowerContext Server 策略执行点(PEP) │ ├── family=memory │ ├── family=experience │ ├── family=skill -│ └── family=prompt +│ └── family=prompt(保留但禁用) ``` - `server`:当前 PowerContext deployment; - `scope`:一个精确 Workstream scope; - `artifact`:一个由 Artifact Family Access Profile 解释的逻辑 Artifact identity 或 Family-owned 逻辑 selector。 -`artifact` Resource Kind 首版注册 `handoff`、`memory`、`experience`、`skill` 和 `prompt` 五个 Artifact Family Access -Profile。`ArtifactReference.family` 是唯一的 Profile discriminator;客户端不再提交第二个可能与它冲突的内容类型。 +`artifact` Resource Kind 当前启用 `handoff`、`memory`、`experience` 和 `skill` 四个 Artifact Family Access Profile。 +`prompt` vocabulary 已保留,但在 PowerContext 实现 Prompt lifecycle 前保持禁用。`ArtifactReference.family` 是唯一的 +Profile discriminator;客户端不再提交第二个可能与它冲突的内容类型。 用户 A 可以选择两种协作方式: @@ -71,13 +72,13 @@ Profile。`ArtifactReference.family` 是唯一的 Profile discriminator;客户 第二种方式是首版的最小权限路径。B 可以读取被分享逻辑资源的已有及未来版本,并只能执行对应 Artifact Family Access Profile 明确授予的 action。Handoff receiver 可以通过 Handoff resolver 检查所选 Revision 明确引用的 evidence,并对该 -Revision 留下 Receipt;逻辑 Memory、Artifact 或 Prompt grant 不自动开放同一 scope、聚合搜索/列表、其他逻辑资源或 +Revision 留下 Receipt;逻辑 Memory 或 Artifact grant 不自动开放同一 scope、聚合搜索/列表、其他逻辑资源或 lineage 中引用的资源。Skill 的读取、发布到一个 target,以及宿主最终加载或执行是彼此独立的授权边界。`accepted` Receipt、Artifact approval、Prompt read 或 Skill publication 都不会授予工具、网络、文件系统、模型 Provider 或凭据权限。 PowerContext 定义稳定的授权 request/decision、内置角色、Access API 和 OpenAPI extension,但不绑定一个策略引擎。 -首版提供内置 Role Binding Store;Casbin、OpenFGA 和兼容 OpenID AuthZEN Authorization API 的 Policy Decision -Point(PDP)可以通过 adapter 接入。 +当前实现提供内置 Role Binding Store、可写的 embedded Casbin adapter,以及兼容 OpenID AuthZEN Authorization API 的 +decision-only PDP adapter。OpenFGA、OPA 和 Cerbos 仍是未来可接入的 adapter。 # Motivation @@ -90,7 +91,6 @@ Memory Entry Version、approved Experience/managed Skill Revision 和 host-local - 团队成员可以查看 Handoff Report,但不能审批 Experience 或 Skill; - B 可以读取一条被分享 Memory Entry 的各版本,但不能搜索整个 scope 或读取其他 Entry; - B 可以读取一个 approved Experience 或 managed Skill 的各 Revision,但不能评审 Candidate; -- B 可以使用一个逻辑 Prompt,但不能把它静默提升为宿主的 system/developer instruction; - 发布者可以发布选定的 managed Skill Revision,但不能借此修改源资源或获得宿主执行权限; - 有效 Handoff Binding 覆盖后续 Revision,而被撤销的接收方之后不能读取任何 Revision; - HTTP、MCP 和 Dashboard 对同一个 Principal 得到相同判定。 @@ -138,7 +138,7 @@ Protected Resource │ ├── family=memory │ ├── family=experience │ ├── family=skill -│ └── family=prompt +│ └── family=prompt(保留但禁用) ``` 每一种 Artifact Family Access Profile 必须固定回答以下问题: @@ -162,12 +162,12 @@ vocabulary、Server-owned resolver、Provider conformance vector 和生成的 tr 资源可读、进入上下文和获得外部执行能力是三个不同平面: ```text -Access Plane: Principal 可以跨版本读取或使用哪个逻辑 resource +Access Plane: Principal 可以跨版本读取、写入或分享哪个逻辑 resource Context Plane: 哪些已授权内容经显式选择进入有界 PreparedContext Execution Plane: 宿主是否安装、加载或执行 Skill/Prompt,以及能使用哪些工具和凭据 ``` -一个 allow decision 不能跨平面传播。逻辑 Memory、Artifact 或 Prompt grant 不会让内容自动进入普通 scope recall;接收方 +一个 allow decision 不能跨平面传播。逻辑 Memory 或 Artifact grant 不会让内容自动进入普通 scope recall;接收方 先在 “Shared with me” 视图发现资源,再显式读取、附加到当前任务或 fork 到自己可贡献的 scope。共享内容继续视为 `untrusted_history` 或不可信 instruction,Context builder 和宿主仍执行各自的预算、优先级、approval 与 sandbox policy。 @@ -203,7 +203,6 @@ Execution Plane: 宿主是否安装、加载或执行 Skill/Prompt,以及能 { "subject": { "type": "user", - "issuer": "https://id.example.com/", "id": "00u-bob" }, "resource": { @@ -211,7 +210,7 @@ Execution Plane: 宿主是否安装、加载或执行 Skill/Prompt,以及能 "scope_id": "project:payments", "identity": { "family": "handoff", - "artifact_id": "project:payments" + "artifact_id": "handoff" }, "selector": null }, @@ -255,9 +254,9 @@ Evidence 的最小权限不是逐条复制 Source 或 Memory,也不是让外 1. A 选择一个已持久化版本来标识可授权资源;Server 把 Memory 归一化为逻辑 `entry_id` selector,把其他 Artifact 归一化为 `{family, artifact_id}`,Revision 不进入 Binding。 2. Server 先检查 A 是否可以在该资源所属 scope 创建对应 Binding,再验证资源存在且处于可分享状态。 -3. B 通过 `access/resources/list` 发现逻辑 resource,并使用自己的 Principal 读取它的已有或未来版本,或显式使用它。 -4. B 若要修改或长期维护内容,需要在自己拥有 `scope.contribute` 的 scope 中显式 fork 或提出新 Candidate;原资源和 - Binding 不被修改。 +3. B 通过 `access/resources/list` 发现逻辑 resource,并使用自己的 Principal 读取它的已有或未来版本。 +4. B 若要创建派生内容,需要在自己拥有 `scope.contribute` 的 scope 中提出新的 Artifact;新逻辑 identity 归 B 所有, + 原资源和 Binding 不被修改。 首版逻辑 grant 的行为如下: @@ -265,24 +264,30 @@ Evidence 的最小权限不是逐条复制 Source 或 Memory,也不是让外 | --- | --- | --- | | `artifact.viewer` on `family=memory` selector | exact get 同一 `entry_id` 的任一版本 | search、list、changes、revise、retire、其他 entry | | `artifact.viewer` | exact get 同一 Experience 或 managed Skill identity 的任一 approved Revision | Candidate read/review、publication、其他 Artifact、lineage body | -| `artifact.viewer` on `family=prompt` | exact get 同一 Prompt identity 的任一 approved Revision | render/use、其他 Prompt、自动注入 | -| `prompt.user` | `artifact.viewer` 加显式 render/use | 改变 instruction priority、自动启用工具或读取凭据 | -普通用户输入仍是 Source evidence,不因包含文字 “prompt” 就成为 Prompt Artifact。可复用、参数化的任务模板可以由后续 -Prompt Artifact lifecycle 定义;Memory extraction、Experience/Skill generation 和 Handoff generation 使用的内部 prompt -属于 Server implementation/configuration,由 `server.admin` 管理,不通过 `family=prompt` Artifact Binding 分享。如果一个 -内容描述 Agent 何时使用、如何执行和如何验证一项能力,它应建模为 managed Skill,而不是重复创建 Prompt Artifact。 +保留的 `prompt` Profile 在 `enabled=false` 时不能创建 Binding,`prompt.user` 也不会作为 enabled Family 的可用角色返回。 +Memory extraction、Experience/Skill generation 和 Handoff generation 使用的内部 prompt 属于 Server +implementation/configuration,不是可分享的 Prompt Artifact。 除 Handoff 的 manifest 范围 evidence resolver 外,逻辑资源响应可以返回 schema 已定义的 lineage/citation identity,但 grant 不向引用目标传递。调用通用 Source、Memory 或 Artifact get operation 仍需对目标资源独立判定;Provider 不得因为 “A references B” 自动创建 `can_read` 继承。 -## 分享是对一个演进 identity 的只读访问,不是共同编辑 +## Viewer Binding 只读,owner 管理演进 identity -Artifact Binding 只授予读取、显式使用或向 Server-configured target 执行受控发布 operation 的权限,不转移原资源的 -content authority。授权 owner 创建的后续 Revision 会通过逻辑 Binding 对接收方可见,但 Binding 本身不能授权接收方 -revise、retire、replace、提交下一 Revision,或原地覆盖共享内容。即使接收方另外拥有原 scope -的 `scope.contribute` 或更高权限,其写入能力也来自该独立的 scope role,而不是这次分享。 +在 enforced mode 下,每个 enabled 逻辑 Artifact 只有一个 direct owner。Server 在首次创建 Handoff 或 Memory identity +时建立 owner;Experience/Skill Candidate 创建时记录 proposer 为 proposed owner,批准后再正式建立 ownership。 +Ownership 是 Server-managed relation,不是公共 `artifact.owner` Binding,并跨所有已有及未来 Revision 覆盖同一逻辑 +identity。 + +Owner 拥有 `artifact.read`、`artifact.write` 和 `artifact.share`;Handoff owner 还拥有 +`handoff.evidence.inspect`。创建下一 Revision、revise/retire Memory、替换已有 Experience/Skill target 或修改 managed +Skill lifecycle 时必须检查 `artifact.write`。Scope role 可以授权 contribution 或 review,但不会让持有者自动成为已有 +Artifact 的 owner。 + +Viewer/receiver Binding 对被绑定内容保持只读。Owner 创建的后续 Revision 会通过逻辑 Binding 对接收方可见,但 Binding +不能授权接收方 revise、retire、replace 或提交下一 Revision。接收方若要创建派生内容,需要对目标 scope 拥有 +`scope.contribute`,并创建 ownership 与源资源相互独立的新 identity 或 Candidate。 接收方产生的状态必须与共享原件分离: @@ -293,14 +298,14 @@ revise、retire、replace、提交下一 Revision,或原地覆盖共享内容 | 发布 managed Skill | 写入 Server 配置目标的 projection/state,不修改源 Skill Revision | | fork、import 或 copy | 必须对目标 scope 拥有 `scope.contribute`;创建新的 identity 或 Candidate,并保留到原资源的 lineage | -产品界面应使用“查看”“使用”“确认接收”“请求变更”“复制到我的 scope”或“发布到配置目标”等动作,不应把逻辑 share +产品界面应使用“查看”“确认接收”“请求变更”“复制到我的 scope”或“发布到配置目标”等动作,不应把逻辑 share 呈现为“编辑共享内容”。持续共同维护需要单独授予 scope role;对于需要 Review 的 Artifact Family,贡献者仍通过 Candidate 和 Review lifecycle 产生新 Revision,而不是原地改写 approved Revision。撤销分享会阻止后续访问,但不能删除接收方已经 看到的内容,也不能自动撤销此前经独立授权创建的 Receipt、projection 或 fork。 ## 跨 Scope 发布 Artifact -`POST /v1/artifact-publications` 会把一个精确的 source Artifact Revision 复制成由目标 Scope 独立拥有的新 Artifact。 +`POST /v1/artifact-publications` 会把一个精确的 source Artifact Revision 复制成目标 Scope 中的独立新 Artifact。 因此业务请求包含精确 `ArtifactAddress`,但 Access Resource 仍是没有 Revision 的逻辑 `{family, artifact_id}` identity。 Server 在读取或复制内容之前必须同时检查: @@ -309,9 +314,11 @@ source 逻辑 Artifact 上的 artifact.share 目标 Scope 上的 scope.admin ``` -这样授权可以覆盖 source 的历史和后续 Revision,同时每次 publication 仍保留精确 provenance。Binding 本身不会复制 -内容,publication 也不会授予 host path、工具、网络、credential 或后续 target mutation 权限。具体 Artifact Family -能否完整复制仍由 Runtime 决定;不支持的 complete-state copy 会在授权后失败,但不会放宽 Access 模型。 +这样授权可以覆盖 source 的历史和后续 Revision,同时每次 publication 仍保留精确 provenance。复制成功后,Server 在 +返回成功前把执行 publication 的 Principal 建立为新 target identity 的 direct owner;target 不继承 source 的 Binding +或 owner。相同 publication 重试会幂等修复缺失的 target owner relation,冲突 owner 则 fail closed。Binding 本身不会 +复制内容,publication 也不会授予 host path、工具、网络或 credential。具体 Artifact Family 能否完整复制仍由 Runtime +决定;不支持的 complete-state copy 会在授权后失败,但不会放宽 Access 模型。 ## B 真正接手 Workstream @@ -333,18 +340,18 @@ PowerContext 权限只控制 PowerContext 资源和 operation。修改 Git 仓 对固定团队,可以把用户或外部 group 绑定为 scope role,而不是为每个 Revision 创建 Binding: -- `scope.viewer`:读取当前 scope 的 Handoff、Memory、approved Artifact、Prompt、Source 和只读投影,并显式使用 approved - Prompt; -- `scope.contributor`:在 viewer 基础上写入工作 evidence、Memory contribution、Handoff 和 Outcome,并提出 Artifact/Prompt +- `scope.viewer`:读取当前 scope 的 Handoff、Memory、approved Artifact、Source 和只读投影; +- `scope.contributor`:在 viewer 基础上写入工作 evidence、Memory contribution、Handoff 和 Outcome,并提出 Artifact Candidate; - `scope.reviewer`:在 viewer 基础上评审 Artifact Candidate; - `scope.delegator`:在 viewer 基础上把逻辑 Handoff 分享给接收方; -- `scope.admin`:管理该 scope 的全部角色和策略。 +- `scope.admin`:管理该 scope 的角色和策略,并可授权 Artifact 分享,但它本身不是内容 read/write role。 -`scope.delegate` 在本 RFC 中继续只允许为 `family=handoff` Artifact 创建 viewer/receiver Binding。首版其他 Artifact -Family 的逻辑 Binding 只能由 `scope.admin` 创建,不能因为已有 Handoff delegator 就静默扩大分享边界。后续可以增加 -资源级 delegation action,但必须作为显式 wire-contract 变更。发布 target 由 `server.admin` 通过 deployment configuration -管理,不创建 Access Binding。 +`scope.delegate` 只允许为 `family=handoff` Artifact 创建 viewer/receiver Binding。Artifact direct owner 也可通过 +`artifact.share` 创建或撤销其 resource Binding。其他 enabled Family 的 resource Binding 可由 owner、`scope.admin` 或 +`server.admin` 管理;已有 Handoff delegator 不会因此获得更宽的分享边界。`server.admin` 管理 server/scope policy,但 +不会隐式获得 `server.observe`、`scope.read` 或 `artifact.write`;legacy static Principal 为兼容性会另外得到 observer 和 +per-scope working role。 固定角色是 wire-contract vocabulary,不要求外部 PDP 使用相同内部存储。外部系统可以把企业角色、团队或关系映射为 这些 action。 @@ -385,8 +392,8 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 - 在 HTTP、MCP 和 Dashboard 前建立同一个 Server PEP; - 从认证凭据建立不可由请求覆盖的 Principal; - 支持 scope 级 RBAC 和逻辑 Handoff receiver Binding; -- 定义稳定 Resource Kind 和 Artifact Family Access Profile contract,并规范 Handoff、Memory、Experience、Skill 和 Prompt - 的逻辑资源授权; +- 定义稳定 Resource Kind 和 Artifact Family Access Profile contract,规范 Handoff、Memory、Experience 和 Skill 的逻辑 + 资源授权,并保留 disabled Prompt vocabulary; - 允许安全解引用已授权 Handoff 所选 Revision 引用的 evidence,而不开放整个 scope; - 区分资源读取、上下文选择、Skill 发布与宿主执行权限; - 提供可替换的判定接口和可选的关系写入接口; @@ -407,7 +414,7 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 - 成员会动态变化的 Memory collection 或 Artifact catalog 订阅分享; - Prompt Artifact 的内容 schema、变量语言、Review lifecycle 或宿主 instruction-priority policy; - per-target publication delegation 或通用 `execution_target` Resource; -- remote managed Skill projection 或 Receiver distribution contract; +- 由独立 lifecycle RFC 定义的 remote managed Skill Receiver distribution contract; - External Skill 的跨主机 locator、自动安装或 package distribution contract。 ## Trust model and invariants @@ -417,7 +424,7 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 1. `scope_id` 是业务分区值,不是授权证明。 2. Principal 只来自认证 middleware 或可信 internal bridge context。 3. 请求 body 中的 `receiver`、`subject`、`actor`、role text 或 Handoff 自然语言不能替换当前 Principal。 -4. Handoff、Memory、Artifact 和 Prompt 内容是 `untrusted_history` 或不可信 instruction,不能授予 action。 +4. Handoff、Memory 和 Artifact 内容是 `untrusted_history` 或不可信 instruction,不能授予 action。 5. `is_internal_bridge()` 只能跳过重复 transport authentication,不能跳过 authorization。 6. 每个受保护的 operation 在访问 Repository 或 application service 前完成判定。 7. 逻辑 Handoff grant 允许对同一 Artifact 的已有和未来 Revision 使用 exact/latest selection,但不开放其他 Handoff 或 @@ -428,15 +435,16 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 Artifact grant 只含 `{family, artifact_id}`。业务请求可以选择正整数 Revision 或 version,但这些字段永远不进入 Access Resource 或 Binding。Server 只从 `identity.family` 派生 Access Profile;独立 content profile、未知 Family 或 selector mismatch 必须拒绝。 -11. 读取 Memory、Artifact 或 Prompt 不自动授予其 lineage/citation target,也不自动进入 PreparedContext。 +11. 读取 Memory 或 Artifact 不自动授予其 lineage/citation target,也不自动进入 PreparedContext。 12. Logical-resource Binding 本身不授予 revise、retire、replace、提交下一 Revision 或其他修改共享内容的 operation; Receipt、feedback、projection 和 fork 是独立资源或 operation,必须分别授权,并且不能修改原资源的 identity、content 或 Revision。 -13. `prompt.use` 不改变宿主 instruction priority;Skill publication 不授予宿主加载、执行、工具、网络、文件系统或 secret - 权限。 -14. Skill publish 必须允许逻辑 `family=skill` Artifact 的 `artifact.read`,且授权发生在解析 - `target_id` 或任何 host/filesystem inspection 前;`target_id` 不是授权资源,首版只解析已配置的 host-local target。 -15. Public error、log、metric 和 trace 不包含 credential、Handoff/Memory/Artifact/Prompt 正文、Source body、target locator +13. 每个 enabled 逻辑 Artifact 只有一条 immutable direct owner relation。公共 Binding 不能创建、替换或转移 + `artifact.owner`;owner 缺失时,Artifact authorization 必须 fail closed。 +14. Host-local Skill projection 必须在解析 `target_id` 或检查文件系统前同时通过 `server.observe` 和 `artifact.read`;remote + target 管理要求 `scope.admin`,remote publication 还要求 `artifact.read`。这些 operation 都不授予宿主执行、工具、 + 网络、文件系统或 secret 权限。 +15. Public error、log、metric 和 trace 不包含 credential、Handoff/Memory/Artifact 正文、Source body、target locator 或 PDP 原始响应。 ## Principal model @@ -446,7 +454,6 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 ```json { "type": "user", - "issuer": "https://id.example.com/", "id": "00u-bob" } ``` @@ -455,13 +462,14 @@ A、相应 grant administrator 或 scope admin 可以撤销其管理边界内的 | Field | Semantics | | --- | --- | -| `type` | `user`、`service` 或后续注册的 Principal type | -| `issuer` | 建立该 identity 的可信 issuer;本地凭据使用 deployment-specific issuer | -| `id` | issuer 内稳定 opaque subject,不使用显示名或 email | +| `type` | `user` 或 `service` | +| `id` | deployment 范围内稳定的 opaque subject,不使用显示名或 email | +| `description` | 可选显示信息,不参与 identity equality 或 policy key | -Agent 名称、host、session ID 和模型名称属于 provenance,不默认成为 Principal。若企业 token 明确证明 on-behalf-of actor, -认证 adapter 可以在可信 request context 中附加 `actor`;PDP 可以同时约束 subject 和 actor。客户端不能通过 JSON body -声明该 actor。 +需要 issuer namespace 时,由 Authentication Provider 把它归一化进 deployment-wide opaque `id`;`issuer` 不是公共 +`PrincipalRef` 字段。Agent 名称、host、session ID 和模型名称属于 provenance,不默认成为 Principal。若企业 token 明确 +证明 on-behalf-of actor,认证 adapter 可以在可信 request context 中附加 `actor`;PDP 可以同时约束 subject 和 actor。 +客户端不能通过 JSON body 声明该 actor。 现有 Handoff Receipt 的 `receiver` 字段继续作为记录内容。Server 另外记录产生 Receipt 的 authenticated Principal, 两者不一致时拒绝 `accepted` 或在非 accepted Receipt 中明确标记 mismatch;绝不能把自由文本 `receiver` 当作 Principal。 @@ -512,8 +520,8 @@ citation 中,不进入 Access Resource: `ArtifactResourceRef.identity.family` 是唯一的 Artifact Family Access Profile discriminator。请求不包含独立 `profile` 字段;Server 从已验证的逻辑 identity 派生 Profile,避免 `profile=prompt` 与 `family=skill` 等不一致组合。 -每个 Family 声明 selector 为 required、forbidden 或某个固定 discriminated union variant。首版 `memory` 要求 -`memory_entry` selector,`handoff`、`experience`、`skill` 和 `prompt` 禁止 selector。 +每个 Family 声明 selector 为 required、forbidden 或某个固定 discriminated union variant。当前 `memory` 要求 +`memory_entry` selector;`handoff`、`experience`、`skill` 和 disabled `prompt` Profile 禁止 selector。 Family registry 是 Server-owned 固定 contract,不是管理员可编辑的 policy DSL。每个注册项至少包含: @@ -523,21 +531,26 @@ Family registry 是 Server-owned 固定 contract,不是管理员可编辑的 p | `share_unit` | `artifact` 或一个明确的 Family-owned 逻辑 selector type | | `shareable_states` | 允许创建 Binding 的 lifecycle state | | `base_action` | 首版统一为 `artifact.read` | -| `additional_actions` | Family 特有的 use、acknowledge 或 publish action | +| `additional_actions` | Family 特有的读取侧或 acknowledge action | | `grantable_roles` | 与该 Family 兼容的固定逻辑资源 roles | +| `mutation_semantics` | 由 `artifact.write` 表达的 owner-only mutation | | `parent_implications` | scope role 可以单向蕴含哪些 child action | | `transitivity` | lineage、citation 或其他关联资源是否需要独立判定;未声明时为 none | | `resolver` | 逻辑授权后如何解析所选业务版本,以及返回什么安全 identity | -首版 registry 为: +当前 registry 为: + +| Artifact Family | Enabled | Share unit | Shareable state | Family actions | Grantable resource roles | +| --- | --- | --- | --- | --- | --- | +| `handoff` | yes | 逻辑 Artifact | 至少一个 committed Revision | `artifact.read`, `handoff.evidence.inspect`, `handoff.acknowledge` | `handoff.viewer`, `handoff.receiver` | +| `memory` | yes | 逻辑 `memory_entry` selector | active 或 retired Entry 存在 | `artifact.read` | `artifact.viewer` | +| `experience` | yes | 逻辑 Artifact | 至少一个 approved Revision | `artifact.read` | `artifact.viewer` | +| `skill` | yes | 逻辑 Artifact | 至少一个 approved Revision | `artifact.read` | `artifact.viewer` | +| `prompt` | no | 逻辑 Artifact | reserved | 保留 `artifact.read`, `prompt.use` vocabulary | none | -| Artifact Family | Share unit | Shareable state | Actions | Grantable resource roles | -| --- | --- | --- | --- | --- | -| `handoff` | 逻辑 Artifact | 至少一个 committed Revision | `artifact.read`, `handoff.evidence.inspect`, `handoff.acknowledge` | `handoff.viewer`, `handoff.receiver` | -| `memory` | 逻辑 `memory_entry` selector | Entry 存在 | `artifact.read` | `artifact.viewer` | -| `experience` | 逻辑 Artifact | 至少一个 approved Revision | `artifact.read` | `artifact.viewer` | -| `skill` | 逻辑 Artifact | 至少一个 approved Revision | `artifact.read` | `artifact.viewer` | -| `prompt` | 逻辑 Artifact | 至少一个 approved Revision | `artifact.read`, `prompt.use` | `artifact.viewer`, `prompt.user` | +每个 enabled row 还允许 direct owner 执行 `artifact.write`,并允许 owner 或 administrator 通过 +`artifact.share` 管理分享。两者都不会变成 viewer action,也不能作为独立 resource Binding 授予。角色发现会把 +`artifact.owner` 报告为 one-per-resource、system-managed role;owner relation 只能由 Server 业务流程建立。 Prepared Handoff 没有持久化 identity,不能创建 Access Binding。跨用户最小权限分享必须先 commit;pending/rejected Candidate 同样不能创建 Artifact Binding。普通新 Family 即使只复用 `artifact.read`,也必须先显式注册为 shareable; @@ -551,6 +564,17 @@ Candidate 同样不能创建 Artifact Binding。普通新 Family 即使只复用 Adapter 负责把结构化 ResourceRef 映射成外部 PDP object ID。映射必须 canonical、可逆或稳定,并避免把 email、token、 资源正文、发布 target locator 或其他 PII 写入 Casbin policy、OpenFGA tuple 或 audit key。 +### Artifact ownership + +Server 把 ownership 与普通 `AccessBinding` 分开保存。`ArtifactOwnerRelation` 包含逻辑 resource、唯一 +`PrincipalRef`、可信创建时间、policy revision 和 idempotency key,刻意不包含 Artifact Revision。Owner relation +不可变;只有相同 owner 和 key 的重复建立才是幂等操作,不同 owner 返回 conflict。本 RFC 不定义 ownership transfer。 + +在 enforced mode 下,owner relation 建立前的 Artifact authorization 以 `artifact_owner_pending` fail closed。新 Memory +Entry 和首次 Handoff commit 由创建者拥有。新 Experience/Skill Candidate 记录 Server-side proposed-owner attestation, +批准后再建立 ownership;以已有 identity 为 target 的 Candidate 必须保留原 owner。跨 Scope publication 的新 target +identity 归 publisher 所有。 + ## Action vocabulary 首版 action 是稳定、小写、点分隔的字符串: @@ -560,25 +584,27 @@ Adapter 负责把结构化 ResourceRef 映射成外部 PDP object ID。映射必 | `server.observe` | server | 读取服务级运行状态和观测数据 | | `server.admin` | server | 管理 deployment access configuration 和 publication target configuration | | `scope.read` | scope | 读取该 Workstream 的通用只读资源、approved content 和投影 | -| `scope.contribute` | scope | 写入 Source、Memory contribution、Handoff/Outcome,并提出 Artifact/Prompt Candidate | +| `scope.contribute` | scope | 创建 Source、新 Memory/Handoff 内容、Outcome 和 Artifact Candidate | | `scope.review` | scope | 评审该 scope 的 Artifact Candidate | | `scope.delegate` | scope | 为逻辑 Handoff 创建 viewer 或 receiver Binding | | `scope.admin` | scope | 管理该 scope 的角色、Binding 和 policy | | `artifact.read` | logical artifact | 读取 Family Profile 定义的 identity 或 selector 的已有和未来版本 | +| `artifact.write` | logical artifact | 通过 Family lifecycle 修改由 owner 控制的逻辑 identity | +| `artifact.share` | logical artifact | 管理 viewer/receiver Binding,或从逻辑 source identity 发布一个精确 Revision | | `handoff.evidence.inspect` | `family=handoff` artifact | 通过 Handoff resolver 解引用所选 Revision 的 citation manifest | | `handoff.acknowledge` | `family=handoff` artifact | 对所选 Revision 创建 Handoff Receipt | -| `prompt.use` | `family=prompt` artifact | 显式 render 或附加一个已授权 Prompt;不决定宿主 instruction priority | +| `prompt.use` | `family=prompt` artifact | 保留 action;Prompt Profile 禁用时不可使用 | -`artifact.read` 的含义在所有 Family 中保持固定:只读取 Binding 标识的逻辑 identity 或 selector 的各版本。它不自动 -包含 Handoff evidence、Prompt use、lineage body 或任何 mutation。Managed Skill publication 是把所选可读 Revision -投影到 Server-configured target 的受控 operation。只有确实具有不同安全效果的 Family operation 才新增 semantic action。 +`artifact.read` 的含义在所有 enabled Family 中保持固定:只读取 Binding 标识的逻辑 identity 或 selector 的各版本。它 +不自动包含 Handoff evidence、lineage body、write 或 share。只有确实具有不同安全效果的 Family operation 才新增 +semantic action。 业务 operation 检查 action,不检查 role name。这样可以调整外部角色或关系模型,而不改 application code。 -`scope.read` 可以通过策略蕴含 scope 下所有已注册 Family 的 `artifact.read`、Handoff 的 `handoff.evidence.inspect` 和 -Prompt 的 `prompt.use`;`scope.contribute` 可以蕴含 acknowledge、prepare、commit、Memory contribution、Artifact/Prompt -Candidate proposal 和 Outcome 写入。反向蕴含不成立:任何 resource viewer/user role 都不能得到 `scope.read` 或 -`scope.contribute`。 +内置 parent implication 刻意保持收敛:`scope.viewer`、`scope.reviewer` 和 `scope.delegator` 对 child 蕴含 +`artifact.read` 与 Handoff evidence inspect;`scope.contributor` 还蕴含 Handoff acknowledge。`scope.admin` 和 +`server.admin` 蕴含 `artifact.share`,`server.admin` 还蕴含 `scope.admin`。管理权限不会隐式授予内容 read/write;反向 +蕴含也不成立,resource viewer 或 owner 不会获得 scope role。 ## Built-in roles @@ -587,18 +613,23 @@ Candidate proposal 和 Outcome 写入。反向蕴含不成立:任何 resource | `handoff.viewer` | `artifact.read`, `handoff.evidence.inspect` on one logical `family=handoff` Artifact | | `handoff.receiver` | viewer actions plus `handoff.acknowledge` on one logical Handoff | | `artifact.viewer` | `artifact.read` on one compatible logical Artifact or selector | -| `prompt.user` | `artifact.read`, `prompt.use` on one logical `family=prompt` Artifact | +| `prompt.user` | reserved role;`family=prompt` disabled 时不可使用 | +| `artifact.owner` | 对一个逻辑 Artifact 执行 `artifact.read`、`artifact.write`、`artifact.share` 和 Handoff evidence inspect;system-managed | | `scope.viewer` | `scope.read` | | `scope.contributor` | `scope.read`, `scope.contribute` | | `scope.reviewer` | `scope.read`, `scope.review` | | `scope.delegator` | `scope.read`, `scope.delegate` | -| `scope.admin` | all scope and child Artifact Family actions, including delegation and Binding administration | +| `scope.admin` | `scope.admin`;只对 child Artifact 蕴含 `artifact.share` | | `server.observer` | `server.observe` | -| `server.admin` | all server, scope, and Artifact Family actions | +| `server.admin` | `server.admin`;蕴含 `scope.admin` 和 `artifact.share`,但不蕴含 read/write | + +`handoff.receiver` 和 `artifact.owner` 的 cardinality 是 `one_per_resource`,其他 role 均为 +`many_per_resource`。Owner 为 system-managed;receiver/owner subject 必须是 user 或 service。其他公共 role schema 也 +允许 group subject,但 built-in/Casbin composition 当前报告 `group_subjects=false`,在配置可信 group resolver 前拒绝创建 +group Binding。 -所有 resource role 对其绑定内容都是只读的。`handoff.receiver` 只额外允许创建独立 Receipt;发布可读 Skill 只向 Server -配置的 target 写 projection。两种 operation 都不能修改源 Handoff 或 Skill Revision。原资源的 mutation 必须由独立的 -scope role 和对应领域 lifecycle 授权。 +所有可通过公共 API 授予的 resource role 对其绑定内容都是只读的;`handoff.receiver` 只额外允许创建独立 Receipt。 +修改原资源必须同时满足 system-managed owner relation 和对应领域 lifecycle。 首版不允许通过公共 API 创建新 role 或修改 role-to-action mapping。固定角色让 OpenAPI、Dashboard 和 adapter conformance test 拥有稳定语义;企业 PDP 可以在外部把自定义组织角色映射为这些 action。 @@ -607,19 +638,18 @@ conformance test 拥有稳定语义;企业 PDP 可以在外部把自定义组 逻辑 Handoff。创建 scope role 需要 `scope.admin`;创建 `server.admin` 需要现有 `server.admin` 和 deployment policy 允许。任何 Principal 都不能授予自己高于调用方管理边界的权限。 -首版只有 `scope.admin` 可以在所管理的 scope 中创建 `artifact.viewer` 或 `prompt.user` Binding。 -`artifact.viewer` 只能绑定到 Family registry 声明兼容的逻辑 Artifact 或 selector;`prompt.user` 只能绑定 approved -`family=prompt` Artifact。Role 与 Artifact Family Access Profile 或 Resource Kind -不匹配时返回 422, -授权不足时返回 403;Server 不能把不匹配的 role text 原样交给外部 RelationshipWriter。 +Artifact owner 或 `scope.admin` 可以创建兼容的 viewer Binding;`server.admin` 继承该管理边界。`artifact.viewer` 只能 +绑定到 enabled Family Profile 声明兼容的逻辑 Artifact 或 selector。公共 `artifact.owner` Binding 和 disabled +`family=prompt` 的所有 Binding 都必须拒绝。Role 与 Artifact Family Access Profile 或 Resource Kind 不匹配时返回 +422,授权不足时返回 403;Server 不能把不匹配的 role text 原样交给外部 RelationshipWriter。 | Resource or Artifact Family Profile | Grantable resource roles | Binding administrator | | --- | --- | --- | -| `artifact` with `family=handoff` | `handoff.viewer`, `handoff.receiver` | `scope.delegate`, `scope.admin`, or `server.admin` | -| `artifact` with `family=memory` and `memory_entry` selector | `artifact.viewer` | `scope.admin` or `server.admin` | -| `artifact` with `family=experience` | `artifact.viewer` | `scope.admin` or `server.admin` | -| `artifact` with `family=skill` | `artifact.viewer` | `scope.admin` or `server.admin` | -| `artifact` with `family=prompt` | `artifact.viewer`, `prompt.user` | `scope.admin` or `server.admin` | +| `artifact` with `family=handoff` | `handoff.viewer`, `handoff.receiver` | owner、`scope.delegate`、`scope.admin` 或 `server.admin` | +| `artifact` with `family=memory` and `memory_entry` selector | `artifact.viewer` | owner、`scope.admin` 或 `server.admin` | +| `artifact` with `family=experience` | `artifact.viewer` | owner、`scope.admin` 或 `server.admin` | +| `artifact` with `family=skill` | `artifact.viewer` | owner、`scope.admin` 或 `server.admin` | +| disabled `family=prompt` | none | none | ## Authorization request and decision @@ -649,7 +679,6 @@ class AuthorizationProvider(Protocol): { "subject": { "type": "user", - "issuer": "https://id.example.com/", "id": "00u-bob" }, "action": {"name": "artifact.read"}, @@ -658,13 +687,14 @@ class AuthorizationProvider(Protocol): "scope_id": "project:payments", "identity": { "family": "handoff", - "artifact_id": "project:payments" + "artifact_id": "handoff" }, "selector": null }, "context": { "request_id": "pc-01K...", - "transport": "mcp" + "transport": "mcp", + "operation": "continue_handoff" } } ``` @@ -674,7 +704,7 @@ class AuthorizationProvider(Protocol): ```json { "allowed": true, - "reason_code": "role_binding", + "reason_code": "role-binding", "policy_revision": "42" } ``` @@ -688,26 +718,34 @@ class AuthorizationProvider(Protocol): `check_batch` 或语义等价的 point checks,并且只有全部 decision 都为 allow 才能调用 Repository、application service、 target adapter 或 filesystem。它不提供 client-authored Boolean policy DSL。 -例如 managed Skill 发布解析为: +例如跨 Scope Artifact publication 解析为两个有序 requirement: ```json { - "combination": "all", + "match": "all", "requirements": [ { - "action": {"name": "artifact.read"}, + "action": "artifact.share", "resource": { "type": "artifact", "scope_id": "project:payments", "identity": {"family": "skill", "artifact_id": "retry-runbook"}, "selector": null } + }, + { + "action": "scope.admin", + "resource": { + "type": "scope", + "scope_id": "team:runbooks" + } } ] } ``` -业务请求中的 Revision 和 `target_id` 不进入 Access Resource。只有 decision allow 后,Server 才解析这些业务参数。 +Source Revision 保留在业务 request 和 publication provenance 中,不进入 Access Resource。Host-local/remote Skill +projection 同样把 `target_id` 保留为 operation parameter,而不是 Access Resource。 “scope role 或 resource role” 这类替代关系不需要 `any` 表达式。PEP 请求 child-resource action,Provider 根据可信 parent relation 判断 scope role 是否蕴含该 action;逻辑 Binding 则直接作用于 child resource。这样不同 Provider 不必实现任意 @@ -745,9 +783,10 @@ class RelationshipWriter(Protocol): ) -> AccessBinding: ... ``` -内置 Provider、Casbin adapter 和 OpenFGA adapter 可以同时提供 `AuthorizationProvider` 与 `RelationshipWriter`。 -OPA、Cerbos 或通用 AuthZEN adapter 可以只提供 decision;此时 PowerContext 的 Binding mutation endpoint 明确返回 -`relationship_management_unavailable`,管理员通过外部系统配置关系。Server 不能声称 grant 成功后再只写本地影子记录。 +内置 Provider 和已包含的 Casbin adapter 都基于 canonical relational Access repository,同时实现 +`AuthorizationProvider` 与 `RelationshipWriter`。已包含的 AuthZEN adapter 只提供 decision;此时 PowerContext 的 +Binding mutation endpoint 明确返回 `relationship_management_unavailable`,管理员通过外部系统配置关系。未来的 +OpenFGA、OPA 或 Cerbos adapter 必须如实声明所实现 capability。Server 不能声称 grant 成功后再只写本地影子记录。 ## Access Binding model @@ -771,6 +810,9 @@ OPA、Cerbos 或通用 AuthZEN adapter 可以只提供 decision;此时 PowerCo Role、subject 或 resource 变化必须 revoke old + create new。相同 grantor、idempotency key 和相同 payload 的重试返回 原 Binding;同 key 不同 payload 返回 409。过期不删除记录,判定时视为 deny。 +Artifact ownership 不是 `AccessBinding`。它保存在单独的 one-per-resource owner relation 中,没有 expiration,也不能 +通过 `/v1/access/bindings/*` 创建或转移。 + 内置 Binding Repository 属于 Server access-control component,不加入 Runtime 的 `context`、`source`、`memory`、 `artifact`、`handoff` 或 `work` application object。它可以与 Server 使用相同数据库部署,但拥有独立 schema、 migration 和 API。 @@ -785,11 +827,11 @@ OpenAPI source of truth 增加以下 operation: | `POST /v1/access/check` | 检查当前 Principal 的一个 `all` 或 `any` 复合权限要求 | current Principal only | | `POST /v1/access/resources/list` | 列出当前 Principal 可访问的资源 identity | current Principal only | | `POST /v1/access/roles/list` | 返回固定角色及 action vocabulary | authenticated Principal | -| `POST /v1/access/bindings/list` | 列出调用方可管理的 Binding | `scope.delegate`, `scope.admin`, or `server.admin` | +| `POST /v1/access/bindings/list` | 列出调用方可管理的 Binding | 按 resource 检查 owner `artifact.share`、`scope.delegate`、`scope.admin` 或 `server.admin` | | `POST /v1/access/bindings/create` | 创建 Family-compatible logical-resource 或管理级 Binding | resource-specific administration action | | `POST /v1/access/bindings/revoke` | CAS revoke 一个 Binding | same administration boundary | | `POST /v1/access/bindings/replace` | 原子撤销不可变 Binding 并创建其后继 Binding | same administration boundary | -| `POST /v1/access/audit/list` | 查询安全审计事件 | `scope.admin` or `server.admin` | +| `POST /v1/access/audit/list` | 查询 server/scope 边界内的安全审计事件 | `scope.admin` or `server.admin` | `check` 和 `resources/list` 不接受 client-specified subject,只检查当前 authenticated Principal,防止普通 用户把 API 当作人员权限枚举器。管理员代查其他 Principal、subject search 和 directory integration 留给后续 RFC。 @@ -799,10 +841,9 @@ OpenAPI source of truth 增加以下 operation: administration check,最后才读取 Repository,确认 Artifact 存在、属于声明的 parent 且处于可授权状态。 不存在与不可见的资源对未授权调用方返回相同 403;只有管理判定通过后才能返回 404 或 family-specific conflict。 -Access API 不负责创建、修改、fork、render 或发布业务资源。Memory、Artifact、Prompt 和 managed Skill publication 的 -业务 operation 继续使用各自 contract;Binding 只表达谁能对已存在资源执行哪些 action。Publisher-safe target selection -属于 Skill publication contract;target configuration 和 operator status 属于 Server operation。三者都不进入 Access API, -也不创建 target Binding。 +Access API 不负责创建、修改、fork 或发布业务资源。Memory、Artifact、跨 Scope publication 和 managed Skill projection +继续使用各自 contract;target configuration 与 operator status 属于 Server 或 scope operation。它们都不进入 Access +API,也不创建 target Binding。Binding 只表达谁能对已存在资源执行哪些 action。 公共 `check` 可以用 HTTP 200 返回 `allowed=false`。业务 operation 的相同拒绝返回 403,并且不调用 application service。Access API 只用于解释和 UI preflight,不能替代业务请求时的实时 enforcement。 @@ -814,14 +855,15 @@ service。Access API 只用于解释和 UI preflight,不能替代业务请求 | Operation | Required authorization | | --- | --- | | `prepare_handoff`, `finalize_handoff`, `handoff_current_work` | `scope.contribute` on request `scope_id` | -| `commit_handoff` | `scope.contribute` on request `scope_id` | +| first `commit_handoff` | `scope.contribute` on request `scope_id`;成功后建立 caller 为 owner | +| later `commit_handoff` with `base` | `scope.contribute` on request `scope_id` and `artifact.write` on logical Handoff | | `continue_handoff(selection=latest)` | `artifact.read` and `handoff.evidence.inspect` on logical `family=handoff` Artifact, directly or through parent `scope.read` | | `continue_handoff(selection=exact)` | `artifact.read` and `handoff.evidence.inspect` on logical `family=handoff` Artifact, directly or through parent `scope.read` | | `continue_handoff(selection=prepared)` | `scope.read` on request `scope_id` | | `acknowledge_handoff` with exact receipt | `scope.contribute` or `handoff.acknowledge` on the logical Handoff selected by the exact Revision | | `record_task_outcome` | `scope.contribute` on request `scope_id` | -| aggregated Handoff Report queries | scope-level read; logical Handoff grant is insufficient | -| Handoff Report administration | `scope.admin` or appropriate server administration action | +| Handoff Report with exact Scope selection | 每个 selected Scope 上的 `scope.read`;logical Handoff grant 不足 | +| Handoff Report with non-exact selection | `server.observe` | receiver 调用 Continue 时,Server 在读取 Revision 前先建立逻辑 Handoff ArtifactResourceRef。`selection=exact` 从请求的 精确 `ArtifactReference` 派生逻辑 identity;`selection=latest` 使用该 scope 注册的逻辑 Handoff identity。授权通过后才 @@ -838,31 +880,31 @@ Family operation 映射如下。表中的 “scope or logical resource” 由 Pr | --- | --- | | Memory search/list/changes | `scope.read` on request `scope_id`;logical Memory Entry grant 不足 | | exact Memory get | `artifact.read` on logical `family=memory` Artifact plus `memory_entry.entry_id`, directly or through parent `scope.read` | -| Memory flush/remember/revise/retire | `scope.contribute`; logical viewer grant 不足 | +| create Memory Entry | `scope.contribute`;成功后建立 caller 为 owner | +| flush Memory | `scope.contribute` plus `artifact.write` on every existing entry that may change;新 Entry 归 caller 所有 | +| revise/retire one Memory Entry | `artifact.write` on logical `memory_entry` selector | | approved Experience/managed Skill exact get | `artifact.read` on the logical Artifact identity derived from the exact request, directly or through parent `scope.read` | -| Experience/Skill propose or generate | `scope.contribute` | +| Experience/Skill propose/generate new identity | `scope.contribute`;Server 记录 caller 为 proposed owner | +| Experience/Skill proposal targeting existing identity | `scope.contribute` plus `artifact.write` on that identity | | Candidate list/get | `scope.read`; logical Artifact grant 不暴露 Candidate | | Candidate revise/approve/reject | `scope.review` | -| approved Prompt exact get | `artifact.read` on logical `family=prompt` Artifact, directly or through parent `scope.read` | -| approved Prompt render/use | `prompt.use`, directly or through parent `scope.read` | -| Prompt propose/revise | Prompt lifecycle 定义的 Candidate operation plus `scope.contribute` | -| list enabled publication targets for an exact managed Skill | `artifact.read` on the logical `family=skill` Artifact | -| publish managed Skill | `artifact.read` on the logical `family=skill` Artifact | +| managed Skill lifecycle mutation | `artifact.write` on logical Skill | +| host-local Skill projection status/publish/unpublish | `server.observe` and `artifact.read` on logical Skill | +| remote Skill target administration | `scope.admin` | +| publish Skill Revision to remote target | `scope.admin` and `artifact.read` on logical Skill | +| cross-Scope Artifact publication | `artifact.share` on logical source and `scope.admin` on target Scope | Exact get resolver 必须从已验证业务 request 派生完整逻辑 identity,并在授权时丢弃 Revision 字段。缺少 scope 和 Family -的 Memory `entry_id`、Artifact `artifact_id` 或 Prompt name 不能单独作为授权 key。Search、aggregated projection 和 +的 Memory `entry_id` 或 Artifact `artifact_id` 不能单独作为授权 key。Search、aggregated projection 和 Candidate Inbox 仍是 collection operation,不能通过一个逻辑 grant 进入。 -Prompt Family Access Profile 只规范 authorization vocabulary 和 resolver contract。部署只有在注册 `family=prompt` 的 -immutable approved Artifact lifecycle,并提供与本节一致的 exact get/use operation 后,才能报告该 Family enabled。 -不支持 Prompt domain operation 的版本仍可实现其他 Family,但不能接受 `family=prompt` Binding 或在 `roles/list` 中声称 -`prompt.user` 可用。 +Prompt Family Access Profile 只保留 authorization vocabulary。当前部署报告 `prompt.enabled=false`,拒绝 +`family=prompt` Binding,也不会把 `prompt.user` 作为 enabled Family 的可用 role 返回。 -`target_id` 是 Server 配置的发布 operation parameter,不是授权 key 或 Resource。只有 `server.admin` 可以配置、修改或 -移除 target;详细 target status 由 `server.observe` 或 `server.admin` 保护。Operator status response 只能返回 target ID、 -Agent kind、capability、desired/applied exact Revision、稳定 state 和安全 reason code,不能返回 host path、Agent home、 -credential 或原始 OS error。在发布和 publisher target-list 请求中,Server 必须先允许逻辑 Skill 的 `artifact.read`,再 -解析 `target_id` 或读取 target registry;独立的 operator status 请求则先判定 server-level action。 +`target_id` 是 operation parameter,不是授权 key 或 Resource。Host-local target inspection 要求 `server.observe` 加逻辑 +Skill read。Remote distribution lifecycle 使用 scope-owned target:管理它们要求 `scope.admin`,设置 desired publication +还要求逻辑 Skill read。Receiver-only reconcile、download 和 receipt operation 使用独立 Target credential,不走用户 +Principal Access。Public status 不返回 host path、Agent home、credential 或原始 OS error。 ## OpenAPI access metadata @@ -984,7 +1026,7 @@ Audit 不包含: - Bearer token、cookie、client secret 或 PDP credential; - Handoff objective/state/next action; -- Source、Memory、Artifact、Prompt、PreparedContext 或 citation body; +- Source、Memory、Artifact、PreparedContext 或 citation body; - publication target locator、host path、credential reference 或原始 Receiver/OS error; - 任意 exception fields、configured PDP URL 或 provider 原始 response; - email、display name 或不必要的目录属性。 @@ -1006,9 +1048,9 @@ Binding 已成功而客户端丢失响应时,同一 idempotency key 返回原 保证时,adapter 必须先执行安全的 canonical relationship lookup,或声明不支持 self-service mutation。 所有 Artifact Family 分享遵循相同的 “persist/approve first, bind second” 原则。Binding create 失败不回滚或重建业务 -Revision;客户端只重试同一个 idempotent Binding mutation。Skill publish 则是一次受逻辑 Skill read decision 保护的 projection -operation,不创建内容 Revision,也不创建 target Binding 或改变 target authorization state。Target apply 失败保留可重试的 -desired/applied 状态和安全 reason,不把本地路径或底层错误写入公共 audit。 +Revision;客户端只重试同一个 idempotent Binding mutation。Skill projection 由逻辑 Skill read 加适用的 server/scope +管理边界保护,不创建 source content Revision 或 Access Binding。Target apply 失败保留可重试的 desired/applied 状态和 +安全 reason,不把本地路径或底层错误写入公共 audit。 Receipt 创建仍使用现有 exact-selection 和 evidence rules。授权判定发生在 Receipt transaction 前;授权在判定后立即 被并发撤销时,Provider 和 Binding Store 应在同一 deployment 中使用 policy revision 或 transaction fence 防止明显 @@ -1024,9 +1066,9 @@ conformance test 的参考语义;它不提供用户密码、目录或自定义 ### Casbin adapter -Casbin adapter 可以使用带 domain 的 RBAC: +已包含的 Casbin adapter 使用 canonical Access relationship 和 Casbin enforcement semantics: -- subject 映射为 issuer-scoped opaque ID; +- subject 映射为 deployment-wide opaque Principal 或 group ID; - domain 对 server resource 映射为 deployment access namespace,对 scope/artifact resource 映射为 canonical scope resource namespace; - object 映射为 canonical server key、scope key 或包含 Family/selector 的 canonical Artifact key; @@ -1037,63 +1079,18 @@ Casbin domain 是 adapter policy namespace,不把 `scope_id` 变成认证或 t ResourceRef 建立 domain。生成列表 filter 时,逻辑 object policy 产生 canonical key,scope/server role assignment 产生 对应 parent constraint;Casbin adapter 不需要枚举业务 Repository。 -### OpenFGA adapter - -OpenFGA 适合表达用户、group、scope 和逻辑 child resource 的关系。所有 Artifact Family 使用一个 `artifact` object type; -object ID 包含 canonical scope、Family、Artifact ID 和 selector,不包含 Revision。Server 在 tuple write 前用 Family registry 校验 relation compatibility。 -这样新增只读 Family 不需要新增 OpenFGA type: - -```text -type user - -type server - relations - define observer: [user] - define admin: [user] - define can_observe: observer or admin - define can_admin: admin - -type scope - relations - define parent: [server] - define viewer: [user] - define contributor: [user] - define reviewer: [user] - define delegator: [user] - define admin: [user] - define can_read: viewer or contributor or reviewer or delegator or admin or admin from parent - define can_contribute: contributor or admin or admin from parent - define can_review: reviewer or admin or admin from parent - define can_delegate: delegator or admin or admin from parent - define can_admin: admin or admin from parent - -type artifact - relations - define parent: [scope] - define viewer: [user] - define handoff_viewer: [user] - define handoff_receiver: [user] - define prompt_user: [user] - define can_read: viewer or handoff_viewer or handoff_receiver or prompt_user or can_read from parent - define can_read_handoff_evidence: handoff_viewer or handoff_receiver or can_read from parent - define can_acknowledge_handoff: handoff_receiver or can_contribute from parent - define can_use_prompt: prompt_user or can_read from parent -``` - -Adapter 把 `server.observe` 映射到 `server#can_observe`,把 `server.admin` 映射到 `server#can_admin`。`admin from parent` -继续使 deployment `server.admin` 单向蕴含 scope administration 和 child Artifact Family action;`server.observer` 不获得 -这些权限。 +### Future OpenFGA adapter -Adapter 使用固定 authorization model ID 执行 Check、ListObjects 和 tuple write。Tuple 只保存 opaque ID,不保存 email -或 Handoff 文本。Model migration 在 deployment configuration 中显式切换,不自动使用“latest model”。 -列表中,逻辑 resource relation 可以通过 ListObjects 产生 canonical key;scope/server role 直接产生可信 parent constraint, -不要求为每一个没有逻辑 Binding 的业务 Artifact 预先写入 object tuple。 +当前实现不包含 OpenFGA adapter。未来可以把相同 canonical server、scope、Artifact、owner、viewer 和 receiver relation +映射为 tuple,但必须保持上面的精确 role table:管理权限不得蕴含内容 read/write,Artifact object ID 不包含 Revision, +安全列表也不能在授权前枚举业务 Repository。Adapter 还必须显式使用 authorization model ID,并如实声明 relationship、 +group 和 resource-filter capability。 -### AuthZEN, OPA, and Cerbos adapters +### AuthZEN adapter 和 future OPA/Cerbos adapter -AuthZEN adapter 把 `AccessRequest` 映射为 Authorization API 的 subject、action、resource、context,把 decision 映射回 -`AccessDecision`。OPA adapter 可以把相同结构作为 input document;Cerbos adapter 可以映射为 principal、resource -和 actions。 +已包含的 AuthZEN adapter 把 point/batch `AccessRequest` 映射为 Authorization API 的 subject、action、resource、context, +只把有界 decision 和可选 policy revision 映射回 `AccessDecision`。它只提供 decision,不支持安全 resource filtering +或 relationship management。OPA/Cerbos 是未来 adapter,不是当前 deployment option。 这些 adapter 的 decision interoperability 不代表 policy administration interoperability。若组织在 GitOps、IAM 或 独立管理面维护 policy,PowerContext 只消费判定和安全 resource filter,不写 policy。部署必须明确 @@ -1102,7 +1099,7 @@ AuthZEN adapter 把 `AccessRequest` 映射为 Authorization API 的 subject、ac ## Configuration and compatibility -Server 提供两种显式 mode: +`POWERCONTEXT_SERVER_ACCESS_MODE` 是唯一正式 Access 开关,支持两种值: | Mode | Behavior | | --- | --- | @@ -1112,15 +1109,21 @@ Server 提供两种显式 mode: 升级不能因为配置了外部身份但漏配 PDP 而回退到 `disabled`。Mode 必须显式,capabilities 和 readiness 报告当前 mode 与 是否支持 relationship management、batch check 和 `safe_resource_filtering`。 +`POWERCONTEXT_SERVER_AUTH_TOKEN` 只用于兼容认证。在 `enforced` mode 且没有注入 Authentication Provider 时,它认证固定 +`service/server-token` Principal;内置 Access service 为该 Principal 初始化相互独立的 `server.observer`、 +`server.admin` 和 per-scope working role。它无法区分多个用户。Legacy +`POWERCONTEXT_SERVER_AUTH_ENABLED=true` 加 `POWERCONTEXT_SERVER_AUTH_TOKEN=...` 会映射到 +`ACCESS_MODE=enforced`。未启用 enforced mode 的 token 会被拒绝;enforced deployment 若既没有 injected +Authentication Provider,也没有兼容 token,则启动失败。 + `disabled` 只适用于调用方已经信任整个进程和 catalog 的本地场景。文档不能把它描述为多用户安全配置。远程、多用户或 共享 Dashboard 部署应使用 `enforced`。 -`access/me` 和 readiness 还必须报告启用的 Resource Kind,以及 `artifact_families` capability map。每个 Family 条目至少 -包含 `enabled`、`share_unit`、可用 action 和 grantable role;例如未实现 Prompt lifecycle 时 `prompt.enabled=false`。 -`operation_capabilities.skill_publication` 单独报告 host-local managed Skill 发布及其 publisher-safe target selection 是否 -可用;只有 Skill Family、两个 domain operation 和至少一个 enabled host-local target 都可用时才能为 true。它不是 -Resource Kind 或可绑定 profile。Provider 不支持 `safe_resource_filtering`、多 requirement check 或 relationship mutation -时,相应 capability 必须为 false;Server 不能接受随后无法 enforce 或撤销的 Binding。 +`access/me` 报告 Principal、mode、Resource Kind、Provider capability 和 `artifact_families` capability list。每个 Family +条目包含 `enabled`、`share_unit`、action vocabulary 和 grantable role。Disabled Prompt 仍报告保留 action,但没有 +grantable role。Readiness 另行报告稳定 Access mode、provider state、Resource Kind 和 Family enabled/disabled state。 +Provider 不支持安全过滤、多 requirement check、relationship mutation、group 或 multi-principal 时,对应 capability 必须 +为 false;Server 不能接受随后无法 enforce 或撤销的 Binding。 ```json { @@ -1128,7 +1131,10 @@ Resource Kind 或可绑定 profile。Provider 不支持 `safe_resource_filtering "provider_capabilities": { "safe_resource_filtering": true, "multi_requirement_check": true, - "relationship_management": true + "relationship_management": true, + "group_subjects": false, + "multi_principal": false, + "max_direct_resource_keys": 10000 }, "artifact_families": [ { @@ -1141,37 +1147,35 @@ Resource Kind 或可绑定 profile。Provider 不支持 `safe_resource_filtering { "family": "prompt", "enabled": false, - "share_unit": "revision", - "actions": [], + "share_unit": "artifact", + "actions": ["artifact.read", "prompt.use"], "grantable_roles": [] } - ], - "operation_capabilities": { - "skill_publication": {"enabled": true} - } + ] } ``` 现有 OpenAPI operation 首次增加 authorization metadata 不改变 request/response domain schema,但会增加 403 response 并改变未授权行为。Generated Client 把 401、403 和 503 映射为稳定、不同的 exception;不能把 403 当作空结果。 -## Implementation slices +## Implementation status -实现按以下可独立验证的 slice 推进: +当前实现交付以下可独立验证的 slice: 1. **Contract and Principal**:OpenAPI Access model、operation metadata、generated `Operation.access`、可信 request Principal 和 stable errors。 2. **Built-in PEP/PDP**:固定角色、Binding Store、`_add_route()` authorization wrapper、point/batch check、audit。 3. **Handoff logical receiver**:commit 后创建 Binding、exact/latest Continue、citation-manifest resolver、exact acknowledge、 future-Revision visibility、revoke 和 expiration。 -4. **Artifact Family Access Profiles**:统一 ArtifactResourceRef、Family registry、Memory selector、logical read/use resolver、 - 角色兼容性与非传递 lineage。 -5. **Skill publication**:Server-configured host-local target registry、publisher-safe selection、operator status、同一 - exact 业务发布使用逻辑 Skill 授权,以及脱敏失败状态。 +4. **Artifact Family Access Profiles and ownership**:统一 ArtifactResourceRef、Family registry、Memory selector、 + system-managed logical ownership、read/write/share resolver、角色兼容性与非传递 lineage。 +5. **Publication and distribution**:跨 Scope publication、host-local Skill projection 和 remote Skill distribution,分别 + 使用对应逻辑 Artifact 与管理权限。 6. **Safe listing and UI**:authorized resource listing、Handoff inbox、“Shared with me”、Dashboard permission projection、 授权后分页。 7. **MCP parity**:Principal 通过 internal bridge 传播、tool discovery UX 和调用时 enforcement。 -8. **External adapters**:先完成 Casbin 或 OpenFGA 之一,再用同一 conformance suite 验证 AuthZEN-compatible PDP。 +8. **Provider adapters**:内置及 embedded Casbin relationship-capable profile,加 decision-only AuthZEN adapter;OpenFGA、 + OPA 和 Cerbos 留给后续。 9. **Migration**:legacy static admin、configuration validation、Family capability、readiness、operator documentation。 每个 slice 都保持 Server 可运行,不能先发布只隐藏 Dashboard 按钮或只保护 HTTP、不保护 MCP 的中间状态。 @@ -1195,35 +1199,35 @@ RFC 实现完成需要通过以下 observable scenarios: - MCP internal bridge 使用原 Principal 并执行与 HTTP 相同的 deny; - Dashboard 隐藏控制失效或被绕过时,API 仍拒绝请求; - 显式 `enforced` mode 下,legacy static token 只在没有注入 Authentication Provider 时映射为 local admin; -- `server.observer` 可以读取受保护的服务和 publication status,但不能修改 access 或 target configuration; - `server.admin` 可以执行两类 operation,且 Built-in、Casbin 和 OpenFGA 的结果一致; -- Built-in、Casbin/OpenFGA 和 AuthZEN adapter 对同一 conformance vector 返回相同结果; +- `server.observer` 可以读取受保护的服务状态,但不能修改 access 或 target configuration;`server.admin` 可以管理这些 + resource,但不会隐式获得 content read/write; +- Built-in 和 Casbin provider 对同一 canonical relationship 返回相同 decision;AuthZEN adapter 正确映射 point/batch + decision,并对 malformed/unavailable response fail closed; - 请求不能提交独立的 content profile,也不能在 Access Resource 中提交 Revision;未知/disabled Family、缺失或多余 selector,以及 Family-role mismatch 返回 422 且不写 Binding; -- `artifact.viewer` 在 Experience、Skill、Prompt 和 `memory_entry` selector 上始终只映射为 `artifact.read`,不会因 Family +- `artifact.viewer` 在 Experience、Skill 和 `memory_entry` selector 上始终只映射为 `artifact.read`,不会因 Family 不同隐式增加 use、publish、acknowledge 或 mutation action; - `artifact.viewer` 可以通过 `family=memory` 和 `entry_id` selector get 被授权 Memory Entry 的历史及未来版本,但不能 search/list/revise/retire 或读取其他 Entry; - logical Artifact viewer 可以读取一个 Experience/managed Skill 的 approved Revision,但不能看到 Candidate、其他 Artifact 或解引用 lineage body; -- `artifact.viewer` 只能读取 Prompt,`prompt.user` 可以显式 use;两者都不能改变宿主 instruction priority 或自动进入 - 普通 recall; +- `family=prompt` 报告 disabled、拒绝 Binding,且不把 `prompt.user` 作为 enabled Family 的可用 role; - logical-resource role 即使知道 expected version,也不能 revise、retire、replace 或提交共享原件的下一 Revision; +- enabled Artifact 缺少 owner relation 时 fail closed;首次创建或批准建立唯一 immutable owner,公共 Binding API 不能 + 分配或转移 `artifact.owner`; +- Artifact owner 无需单独 viewer Binding 即可跨 Revision read/write/share 其逻辑 identity;scope/server administration + 不会隐式获得 owner write; - acknowledge 创建的 Receipt 和 publish 创建的 target projection 不改变源资源的 identity、content、Revision 或 digest; - fork、import 或 copy 在没有目标 scope 的 `scope.contribute` 时被拒绝;授权后创建新的 identity 或 Candidate,并保持原资源 不变; -- managed Skill publish 只有在逻辑 Skill 的 `artifact.read` allow 时执行;deny/unavailable 都不得解析 `target_id`、 - 检查 host path 或写 projection;授权通过后,unknown 或 disabled target 仍必须 - 拒绝发布; -- publisher target-list 只有在逻辑 Skill 的 `artifact.read` allow 后才能读取 registry,并且只返回 enabled - target 的 safe identity/capability;详细 status 仍要求 `server.observe` 或 `server.admin`; -- 首版拒绝 remote Receiver target,并且不得尝试读取 remote credential 或建立网络连接; -- 拥有 `artifact.read` 的 Principal 可以把已授权逻辑 Skill 的所选精确 Revision 发布到 deployment 中任一 enabled target; - 首版没有 target Binding 或 per-target delegation; +- host-local managed Skill projection 必须同时拥有 `server.observe` 和逻辑 Skill `artifact.read`,并在 target 解析或 + filesystem inspection 前完成判定;remote target administration 要求 `scope.admin`,发布 Revision 还要求逻辑 Skill + read; +- 跨 Scope publication 要求逻辑 source `artifact.share` 加 target `scope.admin`,保留精确 source Revision provenance, + 并把 publisher 建立为新 target identity 的 owner; - `resources/list` 的 total、cursor 和 rows 只描述当前 Principal 对所选 Resource Kind 和 Artifact Family 有权发现的集合; -- 不支持 Prompt lifecycle 的部署拒绝 `family=prompt` Binding;没有可用发布 operation 的部署准确报告 - `operation_capabilities.skill_publication.enabled=false`; -- Access Audit 不包含 token、Handoff/Memory/Artifact/Prompt 正文、Source body、target locator 或 PDP 原始错误。 +- 不支持 Prompt lifecycle 的部署拒绝 `family=prompt` Binding 并报告 `enabled=false`; +- Access Audit 不包含 token、Handoff/Memory/Artifact 正文、Source body、target locator 或 PDP 原始错误。 Cross-component acceptance scenarios 放在 `tests/e2e/`,并通过公开 HTTP/MCP contract 断言行为。Focused tests 覆盖 Family registry、selector/canonical key、resource resolver、role mapping、Binding CAS、provider failure 和 citation @@ -1240,16 +1244,16 @@ membership,不冻结 private call order。 判定和关系管理分离使 adapter interface 比单一 `check()` 更复杂;另一方面,假设所有外部 PDP 都允许 PowerContext 写 policy 会制造错误的可移植性承诺。 -撤销只能阻止未来访问,无法删除接收方已经阅读、截图或导出的信息。包含高度敏感内容的 Handoff、Memory、Artifact 或 -Prompt 仍需要最小化内容、外部数据分类和导出控制。 +撤销只能阻止未来访问,无法删除接收方已经阅读、截图或导出的信息。包含高度敏感内容的 Handoff、Memory 或 Artifact +仍需要最小化内容、外部数据分类和导出控制。 -Artifact Family Access Profile 增加了 registry、selector、角色兼容矩阵和 conformance vector。Skill publish 在解析精确 -业务 Revision 前检查逻辑 Artifact 的 `artifact.read`;外部 PDP 会增加延迟,并留下必须记录 policy revision 的有界 -TOCTOU 风险。 +Artifact Family Access Profile 增加了 registry、selector、ownership、角色兼容矩阵和 conformance vector。多 requirement +publication/projection 会增加判定工作;不能原子处理 batch 的外部 PDP 会增加延迟,并留下必须记录 policy revision 的 +有界 TOCTOU 风险。 -首版不把 target 纳入授权策略。拥有某个逻辑 Skill 的 `artifact.read` 可以把它发布到 deployment 中任一 enabled -target。需要按 target 隔离发布权限的部署必须暂缓该能力、隔离 deployment,或等待独立 RFC 定义通用 -`execution_target` Resource;本 RFC 不用一个 Skill 专属资源提前固化这套模型。 +Access 模型不把 `target_id` 建模为 Resource。Host-local target 使用 server-observer boundary;remote target 使用所属 +scope-administration boundary。需要单 target grant 的部署必须按 Scope 隔离,或等待独立 RFC 定义通用 +`execution_target` Resource。 Prompt Family Access Profile 只定义授权边界,不能代替 Prompt Artifact lifecycle 和宿主 instruction-priority contract。 部署在这些业务能力完成前必须报告该 Family 不可用,因此 RFC 可以先落地其他 Family,但产品不会同时获得全部用户体验。 @@ -1261,7 +1265,7 @@ role editor。 ## Chosen: independent Server PEP plus replaceable PDP -该设计保持 Handoff、Memory、Artifact、Prompt 和 Runtime model 与身份系统解耦,同时让 HTTP、MCP 和 +该设计保持 Handoff、Memory、Artifact 和 Runtime model 与身份系统解耦,同时让 HTTP、MCP 和 Dashboard 共用 enforcement。稳定 action vocabulary 比稳定外部 role name 更容易跨 Casbin、OpenFGA、OPA、Cerbos 和 企业 IAM 映射。 @@ -1352,16 +1356,15 @@ subject、action、resource、context 和 decision contract。本 RFC 对齐其 [Cerbos CheckResources](https://docs.cerbos.dev/cerbos/latest/api/index.html) 提供 principal、resource 和 action 的批量判定。 这些系统是 adapter 目标,不改变 PowerContext 的 Handoff lifecycle。 -# Unresolved questions +# Open questions -以下问题需要在 RFC 合并前确认,但不改变核心安全边界: +以下产品选择仍在已实现安全边界之外: -- 首个外部 conformance adapter 选择 Casbin 还是 OpenFGA; -- 内置 Provider 是否随默认 Server extra 安装,还是作为独立 optional extra; - Dashboard 如何从部署方的身份目录选择 canonical recipient;目录搜索本身不由本 RFC 的 Access API 提供; -- enforced deployment 是否要求 Provider 同时支持 `safe_resource_filtering`,还是允许禁用相关 Dashboard 列表; +- 哪个外部 identity source 提供可信 group membership;内置 Provider 当前报告 `group_subjects=false`; - `handoff.receiver` 的产品默认过期时间是否由 deployment policy 决定,还是 UI 必须每次显式选择; - Handoff receiver 创建 Receipt 后,UI 是否建议管理员另行授予 `scope.contributor`,但不能自动执行该升级; +- 后续 governed workflow 是否允许 Artifact ownership transfer; - Prompt Artifact 的后续 lifecycle 采用固定 Review policy,还是区分个人私有模板与组织 approved template。 以下问题明确推迟:custom role、organization hierarchy、cross-tenant export、anonymous share link、temporary elevation、approval @@ -1380,7 +1383,6 @@ workflow、通用 Source object-level ACL、动态 Memory collection 和 Artifac - 对 Handoff 导出的独立脱敏、watermark 和 data-loss-prevention policy; - 注册更多 approved Artifact Family 使用现有 `artifact` Resource Kind 和基础 `artifact.read` action; - 用独立 RFC 定义可供 Skill、Prompt 或其他 execution content 共用的 `execution_target` Resource Kind 和 per-target grant; -- 在独立 Receiver distribution contract 和 trust-boundary review 完成后增加 remote managed Skill target; - 带显式成员和 Revision manifest 的共享 collection,以及经过 Context policy 的订阅式选择; - 在有明确 revocation-staleness guarantee 后增加 bounded decision cache。 diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 3646b7ae3..2e7bb0a56 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -1665,6 +1665,7 @@ async def clear_scope_binding( async def publish_artifact( request: PublishArtifactRequest, publications: Annotated[ArtifactPublicationApplication, Depends(_require_publication_application)], + http_request: Request, ) -> TransportArtifactPublication: result = await publications.publish( DomainArtifactPublicationRequest( @@ -1676,6 +1677,16 @@ async def publish_artifact( idempotency_key=request.idempotency_key, ) ) + await _establish_created_owner( + http_request, + ResourceRef.artifact( + result.target.scope_id, + family=result.target.artifact.family, + artifact_id=result.target.artifact.artifact_id, + ), + idempotency_key=f"artifact-publication-owner:{result.target.artifact.artifact_id}", + operation=PUBLISH_ARTIFACT.operation_id, + ) return TransportArtifactPublication.model_validate(result.model_dump(mode="json")) diff --git a/tests/test_access_http.py b/tests/test_access_http.py index ee6ad9155..eb4ed79d2 100644 --- a/tests/test_access_http.py +++ b/tests/test_access_http.py @@ -899,6 +899,29 @@ async def scenario() -> None: assert published.status_code == 201, published.json() assert published.json()["source"] == payload["source"] assert publications.requests[0].source.artifact.revision == 7 + target_resource = ResourceRef.artifact( + "scope-b", + family="skill", + artifact_id=published.json()["target"]["artifact"]["artifact_id"], + ) + target_owner = await service.artifact_owner(target_resource) + assert target_owner is not None + assert target_owner.owner == BOB + assert ( + await service.check( + BOB, + AccessAction.ARTIFACT_WRITE, + target_resource, + context=AUDIT, + ) + ).allowed + repeated = await bob.post( + "/v1/artifact-publications", + headers=_auth("bob-token"), + json=payload, + ) + assert repeated.status_code == 201, repeated.json() + assert await service.artifact_owner(target_resource) == target_owner alice_provider = StaticBearerAuthenticationProvider("alice-token", ALICE) alice_app = create_app( @@ -914,7 +937,7 @@ async def scenario() -> None: json=payload, ) assert denied.status_code == 403 - assert len(publications.requests) == 1 + assert len(publications.requests) == 2 asyncio.run(scenario()) From 82572ef33ccce99336364a8f6d26f72c6ec91230 Mon Sep 17 00:00:00 2001 From: Teingi Date: Fri, 4 Sep 2026 16:05:39 +0800 Subject: [PATCH 16/22] fix(authz): preserve trusted AuthZEN context --- docs/en/rfcs/1396_handoff_access_control.md | 14 +++ docs/zh/rfcs/1396_handoff_access_control.md | 12 +++ src/powercontext/server/authz/authzen.py | 20 +++-- tests/test_access_adapters.py | 97 ++++++++++++++++++++- 4 files changed, 134 insertions(+), 9 deletions(-) diff --git a/docs/en/rfcs/1396_handoff_access_control.md b/docs/en/rfcs/1396_handoff_access_control.md index af976087f..1b55425eb 100644 --- a/docs/en/rfcs/1396_handoff_access_control.md +++ b/docs/en/rfcs/1396_handoff_access_control.md @@ -613,6 +613,13 @@ records a Server-side proposed-owner attestation; approval establishes that Prin an existing identity must retain its existing owner. Cross-Scope publication establishes the publisher as owner of the new target identity. +The first version assumes that a deployment enables enforced mode before it persists its first Artifact. It does not +backfill or infer owners for a catalog populated while access control was disabled, and it exposes no general owner +repair workflow. Switching such a catalog to enforced mode without a separate operator migration leaves those +Artifacts unavailable by design. If domain persistence succeeds but owner establishment fails, the request still +fails closed; only a business flow that explicitly supports idempotent replay may repair the relation on retry. A +general transactional outbox and operator recovery procedure are future work outside this RFC. + ## Action vocabulary First-version actions are stable lowercase dotted strings: @@ -1172,6 +1179,13 @@ resource, and context and maps only a bounded decision plus optional policy revi decision-only: safe resource filtering and relationship management are unavailable. OPA and Cerbos are possible future adapters, not current deployment options. +The standard AuthZEN context retains `request_id`, `transport`, and `operation`. A `context.powercontext` extension +also carries the trusted `actor` as a Principal object or `null`, plus `subject_groups` as a list of Group objects. +These identities use the same deployment-wide opaque IDs normalized by the Authentication Provider; there is no +separate caller-supplied issuer field. The adapter must preserve this context for both point and batch decisions so an +external PDP can enforce group membership and on-behalf-of constraints with the same authenticated facts as the +Server-owned Providers. + Decision interoperability does not imply policy administration interoperability. If an organization manages policy through GitOps, IAM, or a separate administration plane, PowerContext consumes decisions and safe resource filters but does not write policy. The deployment declares `relationship_management=false`, and the Dashboard does not present a diff --git a/docs/zh/rfcs/1396_handoff_access_control.md b/docs/zh/rfcs/1396_handoff_access_control.md index aeb452af1..3f53616df 100644 --- a/docs/zh/rfcs/1396_handoff_access_control.md +++ b/docs/zh/rfcs/1396_handoff_access_control.md @@ -575,6 +575,12 @@ Entry 和首次 Handoff commit 由创建者拥有。新 Experience/Skill Candida 批准后再建立 ownership;以已有 identity 为 target 的 Candidate 必须保留原 owner。跨 Scope publication 的新 target identity 归 publisher 所有。 +首版假设 deployment 在持久化第一个 Artifact 前就已启用 enforced mode。它不会为 access control disabled 期间已写入的 +catalog 回填或推断 owner,也不提供通用 owner repair 流程。未经过独立 operator migration 就把这类 catalog 切换到 +enforced mode 时,其中的 Artifact 按设计保持不可用。若 domain persistence 已成功但 owner establishment 失败,请求仍 +fail closed;只有明确支持幂等重放的业务流程才能通过重试修复 relation。通用 transactional outbox 和 operator recovery +流程属于本 RFC 之外的未来工作。 + ## Action vocabulary 首版 action 是稳定、小写、点分隔的字符串: @@ -1092,6 +1098,12 @@ group 和 resource-filter capability。 只把有界 decision 和可选 policy revision 映射回 `AccessDecision`。它只提供 decision,不支持安全 resource filtering 或 relationship management。OPA/Cerbos 是未来 adapter,不是当前 deployment option。 +标准 AuthZEN context 保留 `request_id`、`transport` 和 `operation`。`context.powercontext` 扩展还会携带可信 `actor`: +其值为 Principal object 或 `null`;同时把 `subject_groups` 作为 Group object 列表传递。这些 identity 使用 +Authentication Provider 已归一化的同一套 deployment-wide opaque ID,不接受调用方另行提交 `issuer`。Point 和 batch +decision 都必须保留该 context,使外部 PDP 能基于与 Server-owned Provider 相同的认证事实执行 group membership 和 +on-behalf-of 约束。 + 这些 adapter 的 decision interoperability 不代表 policy administration interoperability。若组织在 GitOps、IAM 或 独立管理面维护 policy,PowerContext 只消费判定和安全 resource filter,不写 policy。部署必须明确 `relationship_management=false`,Dashboard 不显示成功的 self-service share control。若 adapter 不能从 PDP search 或 diff --git a/src/powercontext/server/authz/authzen.py b/src/powercontext/server/authz/authzen.py index de5c576bd..1cdaf1a95 100644 --- a/src/powercontext/server/authz/authzen.py +++ b/src/powercontext/server/authz/authzen.py @@ -24,7 +24,7 @@ from powercontext.limits import MAX_POLICY_REVISION_LENGTH from powercontext.server.authz.errors import AccessUnavailableError -from powercontext.server.authz.models import AccessDecision, MemoryEntrySelector, ResourceRef +from powercontext.server.authz.models import AccessDecision, AccessSubjectRef, MemoryEntrySelector, ResourceRef from powercontext.server.authz.service import ( AccessRequest, AuthorizedResourceFilter, @@ -116,21 +116,29 @@ async def _post(self, path: str, payload: Mapping[str, object]) -> Mapping[str, def _access_request(request: AccessRequest) -> dict[str, object]: return { - "subject": { - "type": request.subject.type, - "id": request.subject.id, - "properties": ({} if request.subject.description is None else {"description": request.subject.description}), - }, + "subject": _subject(request.subject), "action": {"name": request.action.value}, "resource": _resource(request.resource), "context": { "request_id": request.context.request_id, "transport": request.context.transport, "operation": request.context.operation, + "powercontext": { + "actor": None if request.context.actor is None else _subject(request.context.actor), + "subject_groups": [_subject(group) for group in request.context.subject_groups], + }, }, } +def _subject(subject: AccessSubjectRef) -> dict[str, object]: + return { + "type": subject.type, + "id": subject.id, + "properties": {} if subject.description is None else {"description": subject.description}, + } + + def _resource(resource: ResourceRef) -> dict[str, object]: properties: dict[str, object] = {} if resource.deployment_id is not None: diff --git a/tests/test_access_adapters.py b/tests/test_access_adapters.py index 8702865df..5368e7b7b 100644 --- a/tests/test_access_adapters.py +++ b/tests/test_access_adapters.py @@ -37,6 +37,7 @@ BuiltinAuthorizationProvider, CasbinAuthorizationProvider, CreateBinding, + GroupRef, MemoryEntrySelector, PrincipalRef, ResourceRef, @@ -113,8 +114,18 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_authzen_uses_logical_identity_and_description_without_issuer() -> None: +def test_authzen_uses_logical_identity_and_trusted_powercontext_context() -> None: seen: list[dict[str, object]] = [] + subject = PrincipalRef(type="user", id="workforce:alice", description="Alice") + actor = PrincipalRef(type="service", id="agent:codex", description="Codex") + group = GroupRef(type="group", id="workforce:payments", description="Payments") + context = AccessAuditContext( + transport="http", + operation="adapter-conformance", + request_id="req-adapter", + actor=actor, + subject_groups=(group,), + ) def handler(request: httpx.Request) -> httpx.Response: assert request.headers["Authorization"] == "Bearer provider-token" @@ -138,12 +149,12 @@ async def scenario() -> None: selector=MemoryEntrySelector(entry_id="entry-a"), ) decision = await provider.check( - AccessRequest(subject=ALICE, action=AccessAction.ARTIFACT_READ, resource=resource, context=AUDIT) + AccessRequest(subject=subject, action=AccessAction.ARTIFACT_READ, resource=resource, context=context) ) assert decision.allowed assert decision.policy_revision == "pdp-42" assert seen[0] == { - "subject": {"type": "user", "id": "alice", "properties": {"description": "Alice"}}, + "subject": {"type": "user", "id": "workforce:alice", "properties": {"description": "Alice"}}, "action": {"name": "artifact.read"}, "resource": { "type": "artifact", @@ -158,6 +169,20 @@ async def scenario() -> None: "request_id": "req-adapter", "transport": "http", "operation": "adapter-conformance", + "powercontext": { + "actor": { + "type": "service", + "id": "agent:codex", + "properties": {"description": "Codex"}, + }, + "subject_groups": [ + { + "type": "group", + "id": "workforce:payments", + "properties": {"description": "Payments"}, + } + ], + }, }, } with pytest.raises(AccessUnavailableError, match="filtering"): @@ -174,6 +199,72 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_authzen_group_context_matches_builtin_and_casbin_decisions() -> None: + group = GroupRef(type="group", id="workforce:payments") + actor = PrincipalRef(type="service", id="agent:codex") + scope = ResourceRef.scope("scope-a") + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + trusted = payload["context"]["powercontext"] + assert trusted["actor"] == {"type": "service", "id": "agent:codex", "properties": {}} + group_ids = {item["id"] for item in trusted["subject_groups"]} + return httpx.Response(200, json={"decision": "workforce:payments" in group_ids}) + + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile: + repository = RelationalAccessRepository(profile.database) + await _seed_admin(repository) + builtin = BuiltinAuthorizationProvider(repository) + casbin = CasbinAuthorizationProvider(repository) + service = AccessControlService( + builtin, + relationships=repository, + audit=repository, + provider_capabilities=AccessProviderCapabilities( + safe_resource_filtering=True, + multi_requirement_check=True, + relationship_management=True, + group_subjects=True, + ), + ) + await service.create_binding( + ADMIN, + CreateBinding( + subject=group, + resource=scope, + role=AccessRole.SCOPE_VIEWER, + idempotency_key="group-scope-viewer", + ), + context=AUDIT, + ) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + authzen = AuthZenAuthorizationProvider("http://127.0.0.1:9876", http_client=client) + for subject_groups, expected in (((group,), True), ((), False)): + context = AccessAuditContext( + transport="http", + operation="adapter-conformance", + actor=actor, + subject_groups=subject_groups, + ) + request = AccessRequest( + subject=BOB, + action=AccessAction.SCOPE_READ, + resource=scope, + context=context, + ) + decisions = await asyncio.gather( + builtin.check(request), + casbin.check(request), + authzen.check(request), + ) + assert all(decision.allowed is expected for decision in decisions) + + asyncio.run(scenario()) + + def test_authzen_fails_closed_on_malformed_decisions_and_cannot_manage_relationships() -> None: async def scenario() -> None: malformed = httpx.MockTransport(lambda _request: httpx.Response(200, json={"decision": "allow"})) From f6d2600694b397cf69de8d327ce38f7cb28be1f6 Mon Sep 17 00:00:00 2001 From: Teingi Date: Fri, 4 Sep 2026 17:15:54 +0800 Subject: [PATCH 17/22] fix: align Casbin contract and quality checks --- docs/en/rfcs/1396_handoff_access_control.md | 35 ++++++++++--------- docs/zh/rfcs/1396_handoff_access_control.md | 31 ++++++++-------- .../plugins/powercontext/hooks/bind_tools.py | 2 +- .../plugins/powercontext/hooks/recall.py | 2 +- .../powercontext/hooks/session_binding.py | 2 +- 5 files changed, 39 insertions(+), 33 deletions(-) diff --git a/docs/en/rfcs/1396_handoff_access_control.md b/docs/en/rfcs/1396_handoff_access_control.md index 1b55425eb..7cc02b2ee 100644 --- a/docs/en/rfcs/1396_handoff_access_control.md +++ b/docs/en/rfcs/1396_handoff_access_control.md @@ -840,11 +840,13 @@ class RelationshipWriter(Protocol): ) -> AccessBinding: ... ``` -The built-in Provider and included Casbin adapter implement both `AuthorizationProvider` and `RelationshipWriter` over -the canonical relational Access repository. The included AuthZEN adapter is decision-only. With that adapter, -PowerContext Binding mutation returns `relationship_management_unavailable`, and administrators configure -relationships in the external system. Future OpenFGA, OPA, or Cerbos adapters must declare the capabilities they -actually implement. The Server must not report a successful grant and then write only a local shadow record. +The built-in and included Casbin compositions pair their `AuthorizationProvider` with the canonical relational Access +repository as the `RelationshipWriter`; the Provider class itself does not own relationship mutation. An external +decision adapter may instead supply a matching `RelationshipWriter` and declare `relationship_management=true`, so +receiver and other Bindings are not restricted to the built-in store. The included AuthZEN adapter is decision-only. +With that adapter, PowerContext Binding mutation returns `relationship_management_unavailable`, and administrators +configure relationships in the external system. Future OpenFGA, OPA, or Cerbos adapters must declare the capabilities +they actually implement. The Server must not report a successful grant and then write only a local shadow record. ## Access Binding model @@ -1152,17 +1154,18 @@ tests and does not provide passwords, a directory, or a custom policy language. The included Casbin adapter uses the canonical Access relationships with Casbin enforcement semantics: -- subject maps to a deployment-wide opaque Principal or group ID; -- domain maps a server resource to the deployment access namespace and a scope or Artifact resource to its canonical - scope resource namespace; -- object maps to a canonical server key, scope key, or Artifact key containing Family and selector; -- action uses this RFC's action vocabulary; -- role assignment and policy mutation use the Casbin management API and a persistence adapter. - -The Casbin domain is an adapter policy namespace. It does not turn `scope_id` into authentication or tenant proof. The -adapter derives the domain from a trusted ResourceRef supplied by the Server. For list filtering, logical-object policy -produces canonical keys while scope or server role assignments produce parent constraints; the Casbin adapter does not -enumerate the business Repository. +- trusted subject and group IDs select active Bindings from the canonical repository before evaluation; +- `act` uses this RFC's action vocabulary and `obj` uses a canonical server, scope, or Artifact key; +- `scope` and `deployment` are trusted parent constraints, not authentication or tenant proof; +- the fixed PowerContext role tables expand active Bindings into concrete action policies; +- the canonical relational Access repository remains the source of truth for Bindings and ownership. The adapter + materializes those relationships into a fresh embedded Casbin enforcer for evaluation and does not maintain a second + persistent Casbin policy store. + +For list filtering, logical-object policy produces canonical keys while scope or server role assignments produce +parent constraints; the Casbin adapter does not enumerate the business Repository. A future native Casbin-backed +composition may provide both decision and relationship management, but its writer must satisfy the same canonical +idempotency, versioning, ownership, and audit contracts before declaring `relationship_management=true`. ### Future OpenFGA adapter diff --git a/docs/zh/rfcs/1396_handoff_access_control.md b/docs/zh/rfcs/1396_handoff_access_control.md index 3f53616df..f1afa1337 100644 --- a/docs/zh/rfcs/1396_handoff_access_control.md +++ b/docs/zh/rfcs/1396_handoff_access_control.md @@ -789,10 +789,12 @@ class RelationshipWriter(Protocol): ) -> AccessBinding: ... ``` -内置 Provider 和已包含的 Casbin adapter 都基于 canonical relational Access repository,同时实现 -`AuthorizationProvider` 与 `RelationshipWriter`。已包含的 AuthZEN adapter 只提供 decision;此时 PowerContext 的 -Binding mutation endpoint 明确返回 `relationship_management_unavailable`,管理员通过外部系统配置关系。未来的 -OpenFGA、OPA 或 Cerbos adapter 必须如实声明所实现 capability。Server 不能声称 grant 成功后再只写本地影子记录。 +内置 composition 和已包含的 Casbin composition 都把各自的 `AuthorizationProvider` 与 canonical relational Access +repository 提供的 `RelationshipWriter` 配对;Provider class 本身不负责 relationship mutation。外部 decision adapter +也可以提供配套 `RelationshipWriter` 并声明 `relationship_management=true`,因此 receiver 等 Binding 不局限于内置 +store。已包含的 AuthZEN adapter 只提供 decision;此时 PowerContext 的 Binding mutation endpoint 明确返回 +`relationship_management_unavailable`,管理员通过外部系统配置关系。未来的 OpenFGA、OPA 或 Cerbos adapter 必须 +如实声明所实现 capability。Server 不能声称 grant 成功后再只写本地影子记录。 ## Access Binding model @@ -1074,16 +1076,17 @@ conformance test 的参考语义;它不提供用户密码、目录或自定义 已包含的 Casbin adapter 使用 canonical Access relationship 和 Casbin enforcement semantics: -- subject 映射为 deployment-wide opaque Principal 或 group ID; -- domain 对 server resource 映射为 deployment access namespace,对 scope/artifact resource 映射为 canonical scope - resource namespace; -- object 映射为 canonical server key、scope key 或包含 Family/selector 的 canonical Artifact key; -- action 使用本 RFC 的 action vocabulary; -- role assignment 和 policy mutation 通过 Casbin management API 与持久化 adapter 完成。 - -Casbin domain 是 adapter policy namespace,不把 `scope_id` 变成认证或 tenant 证明。Adapter 仍从 Server 传入的可信 -ResourceRef 建立 domain。生成列表 filter 时,逻辑 object policy 产生 canonical key,scope/server role assignment 产生 -对应 parent constraint;Casbin adapter 不需要枚举业务 Repository。 +- 可信 subject/group ID 在判定前用于从 canonical repository 选择 active Binding; +- `act` 使用本 RFC 的 action vocabulary,`obj` 使用 canonical server、scope 或 Artifact key; +- `scope` 和 `deployment` 是可信 parent constraint,不是认证或 tenant 证明; +- 固定 PowerContext role table 把 active Binding 展开成具体 action policy; +- canonical relational Access repository 仍是 Binding 和 ownership 的事实源。Adapter 在判定时把这些 relationship + materialize 到新的 embedded Casbin enforcer,不维护第二套持久化 Casbin policy store。 + +生成列表 filter 时,逻辑 object policy 产生 canonical key,scope/server role assignment 产生对应 parent constraint; +Casbin adapter 不需要枚举业务 Repository。未来 native Casbin-backed composition 可以同时提供 decision 和 relationship +management,但 writer 必须满足相同的 canonical idempotency、versioning、ownership 和 audit contract,才能声明 +`relationship_management=true`。 ### Future OpenFGA adapter diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index 9e542da4b..78d1cecbd 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -26,7 +26,7 @@ _PLUGIN_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_PLUGIN_ROOT)) -from scripts.scope_binding import ( # noqa: E402 # ty: ignore[unresolved-import] +from scripts.scope_binding import ( # noqa: E402 ScopeBindingError, binding_keys, resolve_scope_id, diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index aff01d788..f3f27148b 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -37,7 +37,7 @@ from hooks import prepared_context as _prepared_context # noqa: E402 from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 -from scripts.scope_binding import resolve_scope_id # noqa: E402 # ty: ignore[unresolved-import] +from scripts.scope_binding import resolve_scope_id # noqa: E402 from settings import CodexPluginSettings # noqa: E402 _MAX_CONTEXT_BYTES = _prepared_context.MAX_CONTEXT_BYTES diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 54a612f97..a8174fd79 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -26,7 +26,7 @@ _PLUGIN_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_PLUGIN_ROOT)) -from scripts.scope_binding import ScopeBindingError, resolve_scope_id # noqa: E402 # ty: ignore[unresolved-import] +from scripts.scope_binding import ScopeBindingError, resolve_scope_id # noqa: E402 from settings import CodexPluginSettings # noqa: E402 From 5b30385c039047a540042b0fe875971d6aa617c3 Mon Sep 17 00:00:00 2001 From: Teingi Date: Fri, 4 Sep 2026 17:58:14 +0800 Subject: [PATCH 18/22] test(cli): remove stale validation mock --- tests/test_config_cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index 4f100e7fc..bbb6f95ed 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -360,8 +360,7 @@ def test_validate_accepts_multiline_quoted_dashboard_scopes(tmp_path: Path) -> N content = f"{content}\n{multiline}\n" environment.write_text(content, encoding="utf-8") - with patch.object(config_cli, "_validate_provider_models"): - result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) assert result.exit_code == 0 assert "Configuration is valid" in result.output From e408d96681990fb6ff3b8ebe6283c0ab10ad6e48 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sat, 5 Sep 2026 01:29:54 +0800 Subject: [PATCH 19/22] fix(access): own only incubated candidates --- src/powercontext/builtin/runtime/models.py | 1 + .../builtin/runtime/relational.py | 5 +- src/powercontext/server/factory.py | 29 ++-------- .../runtime/test_experience_incubation.py | 3 + tests/test_server.py | 58 ++++++++++++++++++- 5 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index bfa8300b9..9285d4a0a 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -133,6 +133,7 @@ class ExperienceIncubationResult(BaseModel): current_cursor: int = Field(ge=0) source_count: int = Field(ge=0) candidate_count: int = Field(ge=0) + candidate_ids: tuple[str, ...] = () @property def processed(self) -> bool: diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 07f81e451..def4430a9 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -1321,16 +1321,18 @@ async def flush(self, *, limit: int) -> ExperienceIncubationResult: ) plans = await pipeline.incubate(tuple(row.value for row in eligible_rows)) _validate_experience_plans(plans, eligible_rows) + candidate_ids: list[str] = [] async with self._services.database.transaction() as connection: review = self._services.review(connection) for plan in plans: - await review.propose_experience( + candidate = await review.propose_experience( plan.proposal, sources=plan.sources, artifacts=(), target=None, reason=plan.reason, ) + candidate_ids.append(candidate.candidate_id) await self._services.repositories.cursors.save( connection, self._services.scope_id, @@ -1344,6 +1346,7 @@ async def flush(self, *, limit: int) -> ExperienceIncubationResult: current_cursor=action.through, source_count=len(eligible_rows), candidate_count=len(plans), + candidate_ids=tuple(candidate_ids), ) async def _sources( diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 5ac6f80ab..bf0cb3629 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -36,10 +36,8 @@ from powercontext.builtin.runtime import ( BuiltinRuntime, ExperienceIncubationResult, - ListArtifactCandidatesRequest, MemoryEntryRecord, MemoryFlushResult, - ReviewedCandidate, ) from powercontext.builtin.runtime.application import ScheduledExperienceRunner, ScheduledSourceRunner from powercontext.builtin.runtime.composition import open_builtin_runtime @@ -336,18 +334,15 @@ async def incubate_experience(scope_id: str, runtime: BuiltinRuntime) -> Experie context = AccessAuditContext(transport="background", operation="incubate_experience_candidates") await access.bootstrap_static_scope(principal, scope_id, context=context) await access.require(principal, AccessAction.SCOPE_CONTRIBUTE, ResourceRef.scope(scope_id), context=context) - before = await _pending_experience_candidates(runtime, scope_id) result = await runtime.experience.for_scope(scope_id).incubate() - after = await _pending_experience_candidates(runtime, scope_id) - for candidate_id in after.keys() - before.keys(): - candidate = after[candidate_id] + for candidate_id in result.candidate_ids: await access.attest_candidate_owner( scope_id=scope_id, - candidate_id=candidate.candidate_id, - family=candidate.family, + candidate_id=candidate_id, + family="experience", proposed_owner=principal, target=None, - idempotency_key=f"background-candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=f"background-candidate-owner:{scope_id}:{candidate_id}", ) return result @@ -383,22 +378,6 @@ def _memory_resource(scope_id: str, entry: MemoryEntryRecord) -> ResourceRef: ) -async def _pending_experience_candidates( - runtime: BuiltinRuntime, - scope_id: str, -) -> dict[str, ReviewedCandidate]: - candidates: dict[str, ReviewedCandidate] = {} - cursor: str | None = None - while True: - page = await runtime.review.for_scope(scope_id).list( - ListArtifactCandidatesRequest(family="experience", cursor=cursor, limit=100) - ) - candidates.update((candidate.candidate_id, candidate) for candidate in page.candidates) - cursor = page.next_cursor - if cursor is None: - return candidates - - def _mount_optional_web_ui(app: FastAPI, settings: ServerSettings) -> None: app.state.dashboard_started = False app.state.dashboard_startup_error = None diff --git a/tests/builtin/runtime/test_experience_incubation.py b/tests/builtin/runtime/test_experience_incubation.py index 447009c4d..437eef474 100644 --- a/tests/builtin/runtime/test_experience_incubation.py +++ b/tests/builtin/runtime/test_experience_incubation.py @@ -113,6 +113,9 @@ async def scenario() -> None: assert replay.processed is False assert len(inbox.candidates) == 1 candidate = inbox.candidates[0] + assert incubated.candidate_ids == (candidate.candidate_id,) + assert ordinary.candidate_ids == () + assert replay.candidate_ids == () assert candidate.sources == (outcome.source_ref,) assert candidate.result_artifact is None diff --git a/tests/test_server.py b/tests/test_server.py index 8e9dea613..67b5ff508 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,7 +19,9 @@ import shlex from datetime import datetime, timedelta from pathlib import Path +from types import SimpleNamespace from typing import cast +from unittest.mock import AsyncMock import httpx import pytest @@ -39,7 +41,13 @@ from powercontext.builtin.persistence.oceanbase import OceanBaseConfig from powercontext.builtin.persistence.seekdb import SeekDBConfig from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.runtime import InferenceConfig, MemoryExtractionProfile, RuntimeConfig +from powercontext.builtin.runtime import ( + BuiltinRuntime, + ExperienceIncubationResult, + InferenceConfig, + MemoryExtractionProfile, + RuntimeConfig, +) from powercontext.builtin.runtime.readiness import READINESS_PROBE_TIMEOUT_SECONDS from powercontext.http import ( Capabilities, @@ -47,8 +55,8 @@ ReadinessStatus, ) from powercontext.server.app import create_app -from powercontext.server.authz import AccessControlService -from powercontext.server.factory import create_server_app +from powercontext.server.authz import AccessControlService, PrincipalRef +from powercontext.server.factory import _scheduled_access_runners, create_server_app from powercontext.server.settings import ( AccessControlConfig, BearerAuthConfig, @@ -405,6 +413,50 @@ def test_server_scheduler_uses_the_powercontext_data_directory(tmp_path, monkeyp assert (data_dir / "scheduler.db").is_file() +def test_scheduled_experience_owns_only_candidates_created_by_its_incubation() -> None: + async def scenario() -> None: + result = ExperienceIncubationResult( + previous_cursor=0, + high_watermark=2, + current_cursor=2, + source_count=1, + candidate_count=1, + candidate_ids=("scheduled-candidate",), + ) + incubate = AsyncMock(return_value=result) + runtime = SimpleNamespace( + experience=SimpleNamespace(for_scope=lambda _scope_id: SimpleNamespace(incubate=incubate)) + ) + access = AsyncMock(spec=AccessControlService) + settings = ServerSettings( + runtime=RuntimeConfig(experience_schedule_seconds=1), + access=AccessControlConfig( + mode="enforced", + background_principal_id="scheduled-experience", + ), + mcp=McpConfig(enabled=False), + ) + source_runner, experience_runner = _scheduled_access_runners( + settings, + access, + legacy_static_principal=None, + ) + + assert source_runner is None + assert experience_runner is not None + assert await experience_runner("scope-1", cast(BuiltinRuntime, runtime)) == result + access.attest_candidate_owner.assert_awaited_once_with( + scope_id="scope-1", + candidate_id="scheduled-candidate", + family="experience", + proposed_owner=PrincipalRef(type="service", id="scheduled-experience"), + target=None, + idempotency_key="background-candidate-owner:scope-1:scheduled-candidate", + ) + + asyncio.run(scenario()) + + def test_settings_load_bearer_authentication_without_exposing_token(monkeypatch) -> None: monkeypatch.setenv("POWERCONTEXT_SERVER_ACCESS_MODE", "enforced") monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_TOKEN", "server-secret") From 0111b4499aecc6efa3e41579daec9c50bcaa137b Mon Sep 17 00:00:00 2001 From: Teingi Date: Sat, 5 Sep 2026 01:48:50 +0800 Subject: [PATCH 20/22] test(access): harden integration coverage --- .gitignore | 1 + .../memory-powercontext/src/http.test.ts | 30 +++++ .../plugins/powercontext/tests/client.spec.ts | 22 ++++ .../powercontext/tests/e2e/package.spec.ts | 2 +- tests/e2e/real_experience_skill/harness.py | 112 +++++++++++++++++- .../test_memory_routing.py | 37 ++++-- .../e2e/test_real_experience_skill_harness.py | 76 ++++++++++++ tests/integrations/test_hermes_provider.py | 28 +++++ 8 files changed, 295 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 7c2192fb5..2b2d7c945 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,7 @@ venv.bak/ .ropeproject # Generated outputs +site/ e2e/bub/results/ # mypy diff --git a/integrations/openclaw/plugins/memory-powercontext/src/http.test.ts b/integrations/openclaw/plugins/memory-powercontext/src/http.test.ts index 739eda862..25887a9fe 100644 --- a/integrations/openclaw/plugins/memory-powercontext/src/http.test.ts +++ b/integrations/openclaw/plugins/memory-powercontext/src/http.test.ts @@ -23,6 +23,36 @@ afterEach(() => { }); describe("PowerContext HTTP errors", () => { + it("forwards the configured bearer token and preserves Access denial details", async () => { + const tokenEnv = "POWERCONTEXT_OPENCLAW_TEST_TOKEN"; + process.env[tokenEnv] = "integration-token"; + try { + vi.stubGlobal( + "fetch", + vi.fn(async (_url, init) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer integration-token"); + return new Response( + JSON.stringify({ error: { code: "access_denied", message: "scope access denied" } }), + { status: 403, headers: { "content-type": "application/json" } }, + ); + }), + ); + const config = resolvePowerContextConfig(undefined, { + endpoint: "http://powercontext.test", + tokenEnv, + }); + const client = createPowerContextClient(() => config); + + await expect(client.get("/v1/scopes/scope%3Afeature")).rejects.toMatchObject({ + path: "/v1/scopes/scope%3Afeature", + status: 403, + code: "access_denied", + }); + } finally { + delete process.env[tokenEnv]; + } + }); + it("preserves the structured error code from an actual endpoint response", async () => { vi.stubGlobal( "fetch", diff --git a/integrations/opencode/plugins/powercontext/tests/client.spec.ts b/integrations/opencode/plugins/powercontext/tests/client.spec.ts index 3b6d1c200..27ca9f9b2 100644 --- a/integrations/opencode/plugins/powercontext/tests/client.spec.ts +++ b/integrations/opencode/plugins/powercontext/tests/client.spec.ts @@ -28,6 +28,28 @@ function clientFor(response: Response): PowerContextClient { } describe('PowerContextClient response limits', () => { + it('forwards authorization and preserves Access denial details', async () => { + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + authorization: 'Bearer integration-token', + requestTimeoutMs: 1000, + fetch: async (_url, init) => { + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer integration-token') + return new Response( + JSON.stringify({ error: { code: 'access_denied', message: 'scope access denied' } }), + { status: 403, headers: { 'X-PowerContext-Request-ID': 'request-access-1' } }, + ) + }, + }) + + await expect(client.request('get_scope', { scope_id: 'scope:feature' })).rejects.toMatchObject({ + statusCode: 403, + code: 'access_denied', + serverMessage: 'scope access denied', + requestId: 'request-access-1', + }) + }) + it('binds scope resource paths and omits path values from the request body', async () => { const requests: Array<{ url: string; init: RequestInit }> = [] const client = new PowerContextClient({ diff --git a/integrations/pi/plugins/powercontext/tests/e2e/package.spec.ts b/integrations/pi/plugins/powercontext/tests/e2e/package.spec.ts index c9d0a470e..2d79b5943 100644 --- a/integrations/pi/plugins/powercontext/tests/e2e/package.spec.ts +++ b/integrations/pi/plugins/powercontext/tests/e2e/package.spec.ts @@ -89,5 +89,5 @@ describe('PowerContext Pi package e2e', () => { } finally { await rm(agentDirectory, { force: true, recursive: true }) } - }) + }, 30_000) }) diff --git a/tests/e2e/real_experience_skill/harness.py b/tests/e2e/real_experience_skill/harness.py index d528acbdf..f69be0af5 100644 --- a/tests/e2e/real_experience_skill/harness.py +++ b/tests/e2e/real_experience_skill/harness.py @@ -57,6 +57,7 @@ CandidateFamily, CandidateStatus, CaptureContentSourceRequest, + CreateScopeRequest, ExperienceArtifact, ExperienceProposal, ExternalSkillImportMode, @@ -100,6 +101,10 @@ "configured-real-foreign:", ) _SCOPE_TABLES = ( + "pc_access_audit", + "pc_access_candidate_owners", + "pc_access_artifact_owners", + "pc_access_relationships", "pc_memory_vector_entries", "pc_memory_entry_heads", "pc_memory_entry_versions", @@ -115,7 +120,14 @@ "pc_external_skill_registrations", "pc_sources", "pc_source_journal_heads", + "pc_scope_bindings", + "pc_scope_context_references", + "pc_scope_external_references", + "pc_scope_creation_requests", + "pc_scope_settings", + "pc_scopes", ) +_ACCESS_BINDING_LEASES_TABLE = "pc_access_binding_leases" PRODUCER_SCHEMA = { "type": "object", "additionalProperties": False, @@ -389,6 +401,13 @@ def main(argv: Sequence[str] | None = None) -> int: # noqa: C901 - one exceptio else: if configured_scopes is None or configured_server_settings is None or external_skill is None: _fail("configured E2E state was not initialized") + configured_scopes = asyncio.run( + _create_configured_scopes( + server_url=server.base_url, + idempotency_keys=configured_scopes, + api_token=configured_access_token, + ) + ) journey = asyncio.run( _run_configured_journey( recorder=recorder, @@ -427,11 +446,12 @@ def main(argv: Sequence[str] | None = None) -> int: # noqa: C901 - one exceptio except Exception as error: cleanup_errors.append(f"server: {error}") database_cleanup: dict[str, object] | None = None - if configured_settings is not None and configured_scopes is not None and arguments.cleanup: + if configured_settings is not None and arguments.cleanup: try: - database_cleanup = asyncio.run( - _purge_database_scopes(configured_settings.database, configured_scopes.all) - ) + discovered_scopes = asyncio.run(_discover_harness_scopes(configured_settings.database)) + configured_scope_ids = () if configured_scopes is None else configured_scopes.all + cleanup_scopes = tuple(dict.fromkeys((*configured_scope_ids, *discovered_scopes))) + database_cleanup = asyncio.run(_purge_database_scopes(configured_settings.database, cleanup_scopes)) except Exception as error: cleanup_errors.append(f"database: {type(error).__name__}: {error}") codex_home_path = isolated_home @@ -2050,6 +2070,41 @@ def _new_configured_scopes() -> ConfiguredScopes: ) +async def _create_configured_scopes( + *, + server_url: str, + idempotency_keys: ConfiguredScopes, + api_token: str | None, +) -> ConfiguredScopes: + async with PowerContextClient(server_url, token=api_token) as client: + memory = await client.create_scope( + CreateScopeRequest( + title="Configured real Memory", + summary="Isolated Memory retrieval boundary for the configured real-service E2E.", + idempotency_key=idempotency_keys.memory, + ) + ) + artifacts = await client.create_scope( + CreateScopeRequest( + title="Configured real Experience and Skill", + summary="Isolated governed Artifact boundary for the configured real-service E2E.", + idempotency_key=idempotency_keys.artifacts, + ) + ) + foreign = await client.create_scope( + CreateScopeRequest( + title="Configured real foreign evidence", + summary="Isolated negative-control boundary for cross-Scope evidence checks.", + idempotency_key=idempotency_keys.foreign, + ) + ) + return ConfiguredScopes( + memory=memory.scope_id, + artifacts=artifacts.scope_id, + foreign=foreign.scope_id, + ) + + def _without_scheduled_processing(settings: ServerSettings) -> ServerSettings: runtime = settings.runtime.model_copy( update={ @@ -2089,7 +2144,19 @@ async def _discover_harness_scopes(database: DatabaseConfig) -> tuple[str, ...]: async def discover(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> tuple[str, ...]: scopes: set[str] = set() async with profile.database.transaction() as connection: - for table_name in await _existing_scope_tables(connection): + table_names = set( + await connection.run_sync(lambda sync_connection: inspect(sync_connection).get_table_names()) + ) + if "pc_scope_creation_requests" in table_names: + statement = text( + "SELECT DISTINCT scope_id FROM pc_scope_creation_requests WHERE idempotency_key LIKE :prefix" + ) + for prefix in _HARNESS_SCOPE_PREFIXES: + scopes.update( + str(value) + for value in (await connection.execute(statement, {"prefix": f"{prefix}%"})).scalars() + ) + for table_name in (name for name in _SCOPE_TABLES if name in table_names): statement = text( f"SELECT DISTINCT scope_id FROM {table_name} WHERE scope_id LIKE :prefix" # noqa: S608 ) @@ -2136,6 +2203,39 @@ async def purge(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> di async with profile.database.transaction() as connection: tables = await _existing_scope_tables(connection) before = await _scope_counts(connection, scopes, tables=tables) + table_names = set( + await connection.run_sync(lambda sync_connection: inspect(sync_connection).get_table_names()) + ) + access_binding_ids: tuple[str, ...] = () + lease_count_before = 0 + if "pc_access_relationships" in table_names and scopes: + access_binding_ids = tuple( + str(value) + for value in ( + await connection.execute( + text( + "SELECT binding_id FROM pc_access_relationships WHERE scope_id IN :scope_ids" + ).bindparams(bindparam("scope_ids", expanding=True)), + {"scope_ids": scopes}, + ) + ).scalars() + ) + if _ACCESS_BINDING_LEASES_TABLE in table_names and access_binding_ids: + lease_count_before = int( + await connection.scalar( + text( + f"SELECT COUNT(*) FROM {_ACCESS_BINDING_LEASES_TABLE} WHERE binding_id IN :binding_ids" # noqa: S608 + ).bindparams(bindparam("binding_ids", expanding=True)), + {"binding_ids": access_binding_ids}, + ) + or 0 + ) + await connection.execute( + text( + f"DELETE FROM {_ACCESS_BINDING_LEASES_TABLE} WHERE binding_id IN :binding_ids" # noqa: S608 + ).bindparams(bindparam("binding_ids", expanding=True)), + {"binding_ids": access_binding_ids}, + ) if scopes: for table_name in tables: statement = text( @@ -2148,6 +2248,8 @@ async def purge(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> di table_name: sum(scope_counts[table_name] for scope_counts in before.values()) for table_name in _SCOPE_TABLES } + if lease_count_before: + rows_before[_ACCESS_BINDING_LEASES_TABLE] = lease_count_before rows_after = { table_name: sum(scope_counts[table_name] for scope_counts in after.values()) for table_name in _SCOPE_TABLES } diff --git a/tests/e2e/real_experience_skill/test_memory_routing.py b/tests/e2e/real_experience_skill/test_memory_routing.py index 92647c8d6..823a66920 100644 --- a/tests/e2e/real_experience_skill/test_memory_routing.py +++ b/tests/e2e/real_experience_skill/test_memory_routing.py @@ -35,10 +35,11 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.client import PowerContextClient from powercontext.http import ( + CreateScopeRequest, ListMemoryEntriesRequest, ) from powercontext.server.factory import create_server_app -from powercontext.server.settings import McpConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings PROJECT_ROOT = Path(__file__).resolve().parents[3] CODEX_PLUGIN = PROJECT_ROOT / "integrations" / "codex" @@ -61,6 +62,8 @@ def __init__(self, tmp_path: Path) -> None: self.base_url = f"http://{host}:{port}" app = create_server_app( settings=ServerSettings( + auth=BearerAuthConfig(), + access=AccessControlConfig(mode="disabled"), database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory-routing.db'}"), mcp=McpConfig(enabled=True), ) @@ -165,6 +168,7 @@ def _run_codex(arguments: list[str], *, environment: dict[str, str], timeout: in def _run_prompt( home: Path, repository: Path, + scope_id: str, prompt: str, output_path: Path, *, @@ -173,7 +177,7 @@ def _run_prompt( environment = { **os.environ, "CODEX_HOME": str(home), - "POWERCONTEXT_CODEX_SCOPE_ID": SCOPE_ID, + "POWERCONTEXT_CODEX_SCOPE_ID": scope_id, "NO_COLOR": "1", } result = _run_codex( @@ -226,10 +230,25 @@ def _has_tool_name(events: list[dict[str, Any]], tool_name: str) -> bool: return bool(_tool_calls(events, tool_name)) -def _list_entries(server_url: str) -> list[Any]: +def _create_scope(server_url: str) -> str: + async def create() -> str: + async with PowerContextClient(server_url) as client: + scope = await client.create_scope( + CreateScopeRequest( + title="Codex Memory routing", + summary="Explicit Memory save and search routing acceptance.", + idempotency_key=SCOPE_ID, + ) + ) + return scope.scope_id + + return asyncio.run(create()) + + +def _list_entries(server_url: str, scope_id: str) -> list[Any]: async def read() -> list[Any]: async with PowerContextClient(server_url) as client: - result = await client.list_memory_entries(ListMemoryEntriesRequest(scope_id=SCOPE_ID)) + result = await client.list_memory_entries(ListMemoryEntriesRequest(scope_id=scope_id)) return result.entries return asyncio.run(read()) @@ -243,6 +262,7 @@ def test_real_codex_routes_explicit_save_and_search_without_handoff_selector( server = _RunningServer(tmp_path) server.start() try: + scope_id = _create_scope(server.base_url) with tempfile.TemporaryDirectory(prefix="codex-memory-routing-") as root_name: root = Path(root_name) home = _prepare_codex_home(root, mcp_url=f"{server.base_url}/mcp", timeout=timeout) @@ -252,6 +272,7 @@ def test_real_codex_routes_explicit_save_and_search_without_handoff_selector( save_events, save_message = _run_prompt( home, repository, + scope_id, "remember I prefer uv for Python", root / "save.last.json", timeout=timeout, @@ -259,7 +280,7 @@ def test_real_codex_routes_explicit_save_and_search_without_handoff_selector( save_calls = _tool_calls(save_events, "remember_memory") assert save_calls, save_message assert not _has_tool_name(save_events, "select_handoff_workstream") - entries = _list_entries(server.base_url) + entries = _list_entries(server.base_url, scope_id) assert any("uv" in entry.text and "Python" in entry.text for entry in entries) assert any(entry.kind == "preference" for entry in entries) lowered_save_message = save_message.lower() @@ -268,7 +289,8 @@ def test_real_codex_routes_explicit_save_and_search_without_handoff_selector( search_events, search_message = _run_prompt( home, repository, - "What do you remember about Python tooling?", + scope_id, + "Search my memories for Python tooling.", root / "search.last.json", timeout=timeout, ) @@ -298,6 +320,7 @@ def test_real_codex_does_not_claim_save_success_when_mcp_is_unavailable( events, message = _run_prompt( home, repository, + SCOPE_ID, "remember I prefer uv for Python", root / "unavailable.last.json", timeout=timeout, @@ -308,4 +331,4 @@ def test_real_codex_does_not_claim_save_success_when_mcp_is_unavailable( token in lowered for token in ("memory was saved", "memory saved", "successfully saved", "记忆已保存", "已成功保存") ) - assert any(token in lowered for token in ("unavailable", "could not", "failed", "not saved", "未保存")) + assert any(token in lowered for token in ("unavailable", "could not", "failed", "not saved", "未保存")), message diff --git a/tests/e2e/test_real_experience_skill_harness.py b/tests/e2e/test_real_experience_skill_harness.py index 5de8264f2..c4b24005b 100644 --- a/tests/e2e/test_real_experience_skill_harness.py +++ b/tests/e2e/test_real_experience_skill_harness.py @@ -63,3 +63,79 @@ async def scenario() -> tuple[dict[str, object], int]: assert cleanup["remaining_row_count"] == 0 assert cleanup["remaining_harness_scope_count"] == 0 assert remaining == 1 + + +def test_preflight_cleanup_discovers_registered_scope_and_removes_access_rows(tmp_path: Path) -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'registered.db'}") + + async def scenario() -> tuple[dict[str, object], dict[str, int]]: + async with SQLiteProfile.open(database, tables=()) as profile, profile.database.transaction() as connection: + await connection.exec_driver_sql("CREATE TABLE pc_scopes (scope_id TEXT NOT NULL)") + await connection.exec_driver_sql( + "CREATE TABLE pc_scope_creation_requests (idempotency_key TEXT NOT NULL, scope_id TEXT NOT NULL)" + ) + await connection.exec_driver_sql( + "CREATE TABLE pc_access_relationships (binding_id TEXT NOT NULL, scope_id TEXT NOT NULL)" + ) + await connection.exec_driver_sql("CREATE TABLE pc_access_binding_leases (binding_id TEXT NOT NULL)") + await connection.execute( + text("INSERT INTO pc_scopes (scope_id) VALUES (:scope_id)"), + [{"scope_id": "scope:generated"}, {"scope_id": "scope:keep"}], + ) + await connection.execute( + text( + "INSERT INTO pc_scope_creation_requests (idempotency_key, scope_id) " + "VALUES (:idempotency_key, :scope_id)" + ), + [ + { + "idempotency_key": "configured-real-memory:registered", + "scope_id": "scope:generated", + }, + {"idempotency_key": "keep", "scope_id": "scope:keep"}, + ], + ) + await connection.execute( + text("INSERT INTO pc_access_relationships (binding_id, scope_id) VALUES (:binding_id, :scope_id)"), + [ + {"binding_id": "binding:generated", "scope_id": "scope:generated"}, + {"binding_id": "binding:keep", "scope_id": "scope:keep"}, + ], + ) + await connection.execute( + text("INSERT INTO pc_access_binding_leases (binding_id) VALUES (:binding_id)"), + [{"binding_id": "binding:generated"}, {"binding_id": "binding:keep"}], + ) + cleanup = await _purge_existing_harness_scopes(database) + async with SQLiteProfile.open(database, tables=()) as profile, profile.database.transaction() as connection: + remaining = { + table_name: int( + await connection.scalar(text(f"SELECT COUNT(*) FROM {table_name}")) or 0 # noqa: S608 + ) + for table_name in ( + "pc_scopes", + "pc_scope_creation_requests", + "pc_access_relationships", + "pc_access_binding_leases", + ) + } + return cleanup, remaining + + cleanup, remaining = asyncio.run(scenario()) + + assert cleanup["scope_count"] == 1 + assert cleanup["rows_before"] == { + "pc_access_relationships": 1, + "pc_scope_creation_requests": 1, + "pc_scopes": 1, + "pc_access_binding_leases": 1, + } + assert cleanup["rows_after"] == {} + assert cleanup["remaining_row_count"] == 0 + assert cleanup["remaining_harness_scope_count"] == 0 + assert remaining == { + "pc_scopes": 1, + "pc_scope_creation_requests": 1, + "pc_access_relationships": 1, + "pc_access_binding_leases": 1, + } diff --git a/tests/integrations/test_hermes_provider.py b/tests/integrations/test_hermes_provider.py index ad00ce5d6..a05728623 100644 --- a/tests/integrations/test_hermes_provider.py +++ b/tests/integrations/test_hermes_provider.py @@ -1272,3 +1272,31 @@ def read(self, _limit): assert caught.value.path == "/v1/memory/entries/get" assert caught.value.code == "memory_not_found" assert caught.value.server_message == "entry missing" + + +def test_http_client_forwards_authorization_and_preserves_access_denial(hermes_modules): + provider_module, _cli_module = hermes_modules + client_module = importlib.import_module("plugins.powercontext.client") + + class Response: + status = 403 + + def read(self, _limit): + return b'{"error":{"code":"access_denied","message":"scope access denied"}}' + + def transport(request, _timeout): + assert request.get_header("Authorization") == "Bearer integration-token" + return Response() + + client = provider_module.PowerContextClient( + "http://powercontext.test:8000", + authorization="Bearer integration-token", + transport=transport, + ) + + with pytest.raises(client_module.PowerContextHTTPError) as caught: + client.get_memory_entry("project:test", {"entry_id": "forbidden"}) + + assert caught.value.status == 403 + assert caught.value.code == "access_denied" + assert caught.value.server_message == "scope access denied" From 84430424e56c9842e4bd830a17efaed12b199538 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sat, 5 Sep 2026 10:54:31 +0800 Subject: [PATCH 21/22] fix: defer server imports and register recall test scopes --- src/powercontext/service/launcher.py | 4 +++- tests/e2e/real_experience_skill/recall_evaluation.py | 12 +++++++++++- tests/test_service.py | 9 ++++----- tests/test_service_environment.py | 2 +- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/powercontext/service/launcher.py b/src/powercontext/service/launcher.py index 867dd8a10..af77ea3fc 100644 --- a/src/powercontext/service/launcher.py +++ b/src/powercontext/service/launcher.py @@ -25,7 +25,6 @@ from typing import TextIO from urllib.parse import urlsplit -from powercontext.server import cli as server_cli from powercontext.server.configuration import ServerConfigurationError, server_settings_context from powercontext.service.environment import ProtectedEnvironmentFileError, load_protected_environment_file from powercontext.service.model import EnvironmentFileIdentity, ProbeState @@ -87,6 +86,9 @@ def main(arguments: list[str] | None = None) -> int: if probe.state is ProbeState.CONFLICT: logger.error("Personal service endpoint conflict: %s", probe.detail) return 1 + # Help and preflight exits do not need the Server runtime dependencies. + from powercontext.server import cli as server_cli + server_cli._run_configured_server(settings) return 0 except (ProtectedEnvironmentFileError, ServerConfigurationError) as error: diff --git a/tests/e2e/real_experience_skill/recall_evaluation.py b/tests/e2e/real_experience_skill/recall_evaluation.py index 7a24cc4f8..5092a0a4e 100644 --- a/tests/e2e/real_experience_skill/recall_evaluation.py +++ b/tests/e2e/real_experience_skill/recall_evaluation.py @@ -42,6 +42,7 @@ ProposeExperienceRequest, open_builtin_runtime, ) +from powercontext.builtin.scope import ScopeDraft PROJECT_ROOT = Path(__file__).resolve().parents[3] DEFAULT_OUTPUT_ROOT = Path(tempfile.gettempdir()) / "powercontext-experience-recall-evaluation" @@ -138,8 +139,17 @@ async def _approved_contexts(database: Path) -> dict[str, PreparedContext]: config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{database}")) prepared: dict[str, PreparedContext] = {} async with open_builtin_runtime(config) as runtime: + if runtime.scopes is None: + _fail("Experience recall evaluation requires the Scope registry") for task in _tasks(): - scope_id = f"experience-recall-eval:{task.name}" + scope = await runtime.scopes.create( + ScopeDraft( + title=task.name, + summary="Isolated approved Experience recall evaluation.", + idempotency_key=f"experience-recall-eval:{task.name}", + ) + ) + scope_id = scope.scope_id source = await runtime.sources.for_scope(scope_id).capture( CaptureSource( source_id="prior-task-outcome", diff --git a/tests/test_service.py b/tests/test_service.py index 78ba0740e..0c773bc3f 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1418,7 +1418,7 @@ def test_service_launcher_hands_control_to_the_foreground_server_runner( "probe_server", lambda _endpoint: ProbeResult(ProbeState.UNREACHABLE, "not listening"), ) - monkeypatch.setattr(service_launcher.server_cli, "_run_configured_server", run_server) + monkeypatch.setattr("powercontext.server.cli._run_configured_server", run_server) data_dir = tmp_path / "data" exit_code = service_launcher.main(["--endpoint", "http://127.0.0.1:8000", "--data-dir", str(data_dir)]) @@ -1445,8 +1445,7 @@ def test_service_launcher_pins_the_recorded_data_directory( lambda _endpoint: ProbeResult(ProbeState.UNREACHABLE, "not listening"), ) monkeypatch.setattr( - service_launcher.server_cli, - "_run_configured_server", + "powercontext.server.cli._run_configured_server", lambda _settings: observed_data.append(powercontext_data_dir()), ) @@ -1471,7 +1470,7 @@ def test_service_launcher_does_not_start_over_an_existing_powercontext_server( "probe_server", lambda endpoint: ProbeResult(ProbeState.LIVE, f"{endpoint} status=ok"), ) - monkeypatch.setattr(service_launcher.server_cli, "_run_configured_server", run_server) + monkeypatch.setattr("powercontext.server.cli._run_configured_server", run_server) exit_code = service_launcher.main(["--endpoint", "http://127.0.0.1:8000", "--data-dir", str(tmp_path / "data")]) @@ -1495,7 +1494,7 @@ def run_server(_settings: object) -> None: print("server output") print("server error", file=sys.stderr) - monkeypatch.setattr(service_launcher.server_cli, "_run_configured_server", run_server) + monkeypatch.setattr("powercontext.server.cli._run_configured_server", run_server) exit_code = service_launcher.main([ "--endpoint", diff --git a/tests/test_service_environment.py b/tests/test_service_environment.py index d08f01bf5..70b7018b9 100644 --- a/tests/test_service_environment.py +++ b/tests/test_service_environment.py @@ -270,7 +270,7 @@ def test_launcher_rejects_env_drift_without_starting_server( else: environment.chmod(0o640) runner = Mock() - monkeypatch.setattr(service_launcher.server_cli, "_run_configured_server", runner) + monkeypatch.setattr("powercontext.server.cli._run_configured_server", runner) exit_code = service_launcher.main(definition.launcher_arguments()[3:]) From c71d370c7a7cd076ea046c2bced9610408233759 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sat, 5 Sep 2026 22:59:39 +0800 Subject: [PATCH 22/22] fix(access): include inherited resources in discovery --- src/powercontext/server/app.py | 61 ++++---- tests/e2e/test_access_control_http.py | 201 +++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 27 deletions(-) diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index bd2c37842..8ca91cfd0 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -172,7 +172,6 @@ CandidateTerminalError, InvalidCandidateError, ) -from powercontext.builtin.review import CandidateStatus as RuntimeCandidateStatus from powercontext.builtin.review.generation import ( GeneratedCandidateResult as RuntimeGeneratedCandidateResult, ) @@ -1445,27 +1444,41 @@ async def _query_authorized_resources( resources = {resource.key: resource for resource in authorized.exact_resources} if not authorized.parent_constraints: return tuple(resources.values()) - if resource_type is not AccessResourceType.ARTIFACT or any( - parent.type is not AccessResourceType.SCOPE for parent in authorized.parent_constraints - ): + if resource_type not in {AccessResourceType.SCOPE, AccessResourceType.ARTIFACT}: raise AccessUnavailableError("safe_resource_filtering_unavailable") application = _require_application(request) access = _require_access_control(request) + scope_ids = await _authorized_scope_ids(request, authorized.parent_constraints) families = (family,) if family is not None else ("handoff", "memory", "experience", "skill") + for scope_id in scope_ids: + if resource_type is AccessResourceType.SCOPE: + resource = ResourceRef.scope(scope_id) + resources[resource.key] = resource + else: + for selected_family in families: + discovered = await _discover_scope_artifact_resources(application, scope_id, selected_family) + for resource in discovered: + if await access.artifact_owner(resource) is not None: + resources[resource.key] = resource + if len(resources) > authorized.max_direct_resource_keys: + raise AccessUnavailableError("resource_filter_limit_exceeded") + if len(resources) > authorized.max_direct_resource_keys: + raise AccessUnavailableError("resource_filter_limit_exceeded") + return tuple(resources.values()) - for parent in authorized.parent_constraints: - scope_id = parent.scope_id - if scope_id is None: + +async def _authorized_scope_ids(request: Request, parents: Sequence[ResourceRef]) -> tuple[str, ...]: + access = _require_access_control(request) + scope_ids: set[str] = set() + for parent in parents: + if parent.type is AccessResourceType.SERVER and parent.deployment_id == access.deployment_id: + scope_ids.update(scope.scope_id for scope in await _require_scope_application(request).list()) + elif parent.type is AccessResourceType.SCOPE and parent.scope_id is not None: + scope_ids.add(parent.scope_id) + else: raise AccessUnavailableError("safe_resource_filtering_unavailable") - for selected_family in families: - discovered = await _discover_scope_artifact_resources(application, scope_id, selected_family) - for resource in discovered: - if await access.artifact_owner(resource) is not None: - resources[resource.key] = resource - if len(resources) > authorized.max_direct_resource_keys: - raise AccessUnavailableError("resource_filter_limit_exceeded") - return tuple(resources.values()) + return tuple(sorted(scope_ids)) async def _discover_scope_artifact_resources( @@ -1489,7 +1502,7 @@ async def _discover_scope_artifact_resources( for entry in entries.entries ) if family in {"experience", "skill"}: - return await _approved_artifact_resources( + return await _committed_artifact_resources( application, scope_id, cast(Literal["experience", "skill"], family), @@ -1497,7 +1510,7 @@ async def _discover_scope_artifact_resources( raise AccessInvalidRequestError("artifact-family") -async def _approved_artifact_resources( +async def _committed_artifact_resources( application: ServerApplication, scope_id: str, family: Literal["experience", "skill"], @@ -1505,18 +1518,14 @@ async def _approved_artifact_resources( resources: list[ResourceRef] = [] cursor: str | None = None while True: - page = await application.review.for_scope(scope_id).list( - RuntimeListArtifactCandidatesRequest( - status=RuntimeCandidateStatus.APPROVED, - family=family, - cursor=cursor, - limit=100, - ) + page = await application.records.for_scope(scope_id).query_artifacts( + family, + cursor=cursor, + limit=100, ) resources.extend( ResourceRef.artifact(scope_id, family=artifact.family, artifact_id=artifact.artifact_id) - for candidate in page.candidates - if (artifact := candidate.result_artifact) is not None + for artifact in page.items ) cursor = page.next_cursor if cursor is None: diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py index 0144155cf..2a66ca195 100644 --- a/tests/e2e/test_access_control_http.py +++ b/tests/e2e/test_access_control_http.py @@ -51,7 +51,7 @@ ) from powercontext.server.authentication import StaticBearerAuthenticationProvider from powercontext.server.authz import AccessControlService, MemoryEntrySelector, PrincipalRef, ResourceRef -from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.composition import open_builtin_access_control, open_casbin_access_control from powercontext.server.factory import create_server_app from powercontext.server.settings import ( AccessControlConfig, @@ -476,6 +476,205 @@ async def scenario() -> None: asyncio.run(scenario()) +@pytest.mark.parametrize("provider", ["builtin", "casbin"]) +@pytest.mark.parametrize("family", ["experience", "skill"]) +def test_resource_discovery_includes_base_artifacts_under_scope_grants( + tmp_path: Path, provider: str, family: str +) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'discovery.db'}") + open_access = open_builtin_access_control if provider == "builtin" else open_casbin_access_control + async with open_access( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT_ID + ) as access_control: + async with _client( + _app(database, access_control, ADMIN, "admin-token", tmp_path / "admin-scheduler.db"), + "admin-token", + ) as admin: + scopes = [ + await admin.create_scope( + CreateScopeRequest(title=title, summary="Resource discovery", idempotency_key=title) + ) + for title in ("visible", "hidden") + ] + content = ( + { + "situation": "An Artifact was created through the base API.", + "action": "Discover it through a Scope grant.", + "outcome": "The readable Artifact appears in the list.", + "lesson": "Discovery includes every committed Artifact.", + } + if family == "experience" + else { + "name": "resource-discovery", + "description": "Check inherited Artifact access", + "instructions": "Compare exact reads with resource discovery.", + "validation": ["Authorized Artifacts appear in the list"], + } + ) + artifacts = [ + await admin.create_artifact( + scope.scope_id, + CreateArtifactRequest.model_validate({"family": family, "content": content}), + ) + for scope in (scopes[0], scopes[0], scopes[1]) + ] + await admin.create_access_binding( + CreateAccessBindingRequest.model_validate({ + "subject": {"type": VIEWER.type, "id": VIEWER.id}, + "resource": {"type": "scope", "scope_id": scopes[0].scope_id}, + "role": "scope.viewer", + "idempotency_key": "visible-scope-viewer", + }) + ) + for principal in (VIEWER, RECEIVER): + await admin.create_access_binding( + CreateAccessBindingRequest.model_validate({ + "subject": {"type": principal.type, "id": principal.id}, + "resource": { + "type": "artifact", + "scope_id": scopes[0].scope_id, + "identity": {"family": family, "artifact_id": artifacts[0].artifact_id}, + }, + "role": "artifact.viewer", + "idempotency_key": f"direct-viewer-{principal.id}", + }) + ) + + query = ListAccessResourcesRequest( + action=AccessAction.ARTIFACT_READ, + resource_type=AccessResourceType.ARTIFACT, + family=family, + limit=1, + ) + async with _client( + _app(database, access_control, VIEWER, "viewer-token", tmp_path / "viewer-scheduler.db"), + "viewer-token", + ) as viewer: + assert await viewer.get_artifact(scopes[0].scope_id, family, artifacts[1].artifact_id) is not None + first = await viewer.list_access_resources(query) + assert first.total == 2 + assert len(first.items) == 1 + assert first.next_cursor is not None + second = await viewer.list_access_resources(query.model_copy(update={"cursor": first.next_cursor})) + assert second.total == 2 + assert len(second.items) == 1 + assert second.next_cursor is None + assert { + item.model_dump(mode="json")["identity"]["artifact_id"] for item in first.items + second.items + } == {artifact.artifact_id for artifact in artifacts[:2]} + with pytest.raises(ForbiddenResponseError): + await viewer.get_artifact(scopes[1].scope_id, family, artifacts[2].artifact_id) + + async with _client( + _app(database, access_control, RECEIVER, "receiver-token", tmp_path / "receiver-scheduler.db"), + "receiver-token", + ) as receiver: + direct = await receiver.list_access_resources(query) + assert direct.total == 1 + assert direct.items[0].model_dump(mode="json")["identity"]["artifact_id"] == artifacts[0].artifact_id + with pytest.raises(ForbiddenResponseError): + await receiver.get_artifact(scopes[0].scope_id, family, artifacts[1].artifact_id) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("provider", ["builtin", "casbin"]) +def test_server_administrators_discover_managed_resources_without_content_access(tmp_path: Path, provider: str) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'admin-discovery.db'}") + open_access = open_builtin_access_control if provider == "builtin" else open_casbin_access_control + async with open_access( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT_ID + ) as access_control: + async with _client( + _app(database, access_control, ADMIN, "admin-token", tmp_path / "admin-scheduler.db"), + "admin-token", + ) as admin: + scope = await admin.create_scope( + CreateScopeRequest(title="Managed Scope", summary="Admin discovery", idempotency_key="managed") + ) + expected_scopes = {item.scope_id for item in (await admin.list_scopes()).items} + artifact = await admin.create_artifact( + scope.scope_id, + CreateArtifactRequest.model_validate({ + "family": "experience", + "content": { + "situation": "A server administrator needs to manage access.", + "action": "List manageable resources.", + "outcome": "Resource identities are visible without content access.", + "lesson": "Administration does not imply content read.", + }, + }), + ) + await admin.create_access_binding( + CreateAccessBindingRequest.model_validate({ + "subject": {"type": RECEIVER.type, "id": RECEIVER.id}, + "resource": {"type": "server", "deployment_id": DEPLOYMENT_ID}, + "role": "server.admin", + "idempotency_key": "bob-server-admin", + }) + ) + for principal in (RECEIVER, VIEWER): + await admin.create_access_binding( + CreateAccessBindingRequest.model_validate({ + "subject": {"type": principal.type, "id": principal.id}, + "resource": {"type": "scope", "scope_id": scope.scope_id}, + "role": "scope.admin", + "idempotency_key": f"scope-admin-{principal.id}", + }) + ) + + query = ListAccessResourcesRequest( + action=AccessAction.SCOPE_ADMIN, resource_type=AccessResourceType.SCOPE, limit=1 + ) + async with _client( + _app(database, access_control, RECEIVER, "receiver-token", tmp_path / "receiver-scheduler.db"), + "receiver-token", + ) as receiver: + discovered: list[str] = [] + while True: + page = await receiver.list_access_resources(query) + assert page.total == len(expected_scopes) + assert len(page.items) == 1 + discovered.extend(item.model_dump(mode="json")["scope_id"] for item in page.items) + if page.next_cursor is None: + break + query = query.model_copy(update={"cursor": page.next_cursor}) + assert set(discovered) == expected_scopes + assert len(discovered) == len(expected_scopes) + manageable = await receiver.list_access_resources( + ListAccessResourcesRequest( + action=AccessAction.ARTIFACT_SHARE, + resource_type=AccessResourceType.ARTIFACT, + family="experience", + ) + ) + assert manageable.total == 1 + assert manageable.items[0].model_dump(mode="json")["identity"]["artifact_id"] == artifact.artifact_id + for action, resource_type in ( + (AccessAction.SCOPE_READ, AccessResourceType.SCOPE), + (AccessAction.ARTIFACT_READ, AccessResourceType.ARTIFACT), + ): + readable = await receiver.list_access_resources( + ListAccessResourcesRequest(action=action, resource_type=resource_type) + ) + assert readable.total == 0 + assert readable.items == [] + with pytest.raises(ForbiddenResponseError): + await receiver.get_artifact(scope.scope_id, "experience", artifact.artifact_id) + + async with _client( + _app(database, access_control, VIEWER, "viewer-token", tmp_path / "viewer-scheduler.db"), + "viewer-token", + ) as viewer: + direct = await viewer.list_access_resources(query.model_copy(update={"cursor": None})) + assert direct.total == 1 + assert direct.items[0].model_dump(mode="json")["scope_id"] == scope.scope_id + + asyncio.run(scenario()) + + def _app( database: SQLiteConfig, access_control: AccessControlService,