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