From 770aa2693ed02087607788d7a5f9977ab5476735 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sun, 30 Aug 2026 19:05:21 +0800 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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(