diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 0050d9a8..1d2a5b15 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -2818,3 +2818,29 @@ the independent checker against its unchanged 90 percent floor. The repair adds focused malformed-selection and exact-Git-identity cases; the identical command now passes 206 tests at 90.18 percent branch coverage. Exact-SHA internal repair review and fresh hosted checks remain. + +## 2026-07-22 - WS-AUTH-001-10B2 Internal Implementation Review + +AUTH-10B2 exact implementation SHA +`6c7108251829c3c2853fc384bd8cc0668381ac7b` activates exactly three +privacy-safe project candidate/grant reads after the durable authorization-read +gate. Two internal repair cycles closed dependency-order, nonhuman concealment, +cursor replay/pre-query, no-count SQL, service filtering, OpenAPI, hosted shape, +and documentation proof gaps. Senior engineering, architecture, reuse/dedup, +security/auth, QA/test, test delta, product/ops, docs, and CI integrity all pass +with no open finding. Focused and isolated PostgreSQL evidence passes; GitHub +full CI/coverage, hosted API E2E, CodeRabbit, and human review remain. + +## 2026-07-22 - WS-AUTH-001-10B2 External Review Reconciliation + +The first GitHub run found two valid integration gaps: hosted E2E used an actor +without project-scoped Project Manager authority, and the closed audit fixture +omitted the three activated actions. Repair SHA `95c3ecf7` provisions a +distinct reader through the public grant API and restores exact audit parity; +all nine internal tracks pass that repair. Fresh Backend run `29892395881` +passes preflight, hosted E2E, all four shards, and aggregate coverage, and Agent +Gates passes. CodeRabbit's refreshed description check passes and it has no +inline finding. Its unexplained 37.61 percent docstring warning is +non-actionable because the unchanged repository gate independently passes the +same head at 87.6 percent against the 80 percent floor. Human review and +explicit approval of PR #178 remain. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10B2-project-role-grant-reads.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10B2-project-role-grant-reads.md index 4e9c1c56..5f59037f 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10B2-project-role-grant-reads.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10B2-project-role-grant-reads.md @@ -43,8 +43,11 @@ backend/app/core/config.py backend/app/main.py backend/tests/test_actors.py backend/tests/test_authorization.py +backend/tests/test_api_controls.py +backend/tests/test_audit.py backend/tests/test_projects.py backend/tests/test_config.py +backend/tests/conftest.py backend/scripts/api_contract_e2e.py docs/operations_authorization_service.md docs/spec_authorization_service.md @@ -104,9 +107,11 @@ principals deny before product-row lookup. mismatches, and lifecycle concealment do not fabricate authorization-denial evidence except candidate terminal/archived status, which is an evidenced kernel resource-guard denial after canonical project load. Tests prove each path. -- A composed human-only prelookup dependency rejects fixed services before - `ProjectRepository.get_project`; agent and Space kinds remain rejected by the - existing actor-resolution boundary. Because no canonical project exists at +- A composed human-only prelookup dependency rejects every verified nonhuman + subject kind before `ProjectRepository.get_project`. This includes fixed + services and the currently unsupported agent and Space kinds, and intentionally + fails earlier than the general actor-resolution boundary for these three + privacy-sensitive routes. Because no canonical project exists at this point, this prelookup rejection does not fabricate an action decision or authority audit event. It returns the same public 404 and tests prove zero project/candidate/grant query. It never constructs a canonical context from @@ -116,7 +121,10 @@ principals deny before product-row lookup. `(created_at, actor_profile_id)`; executes no count; and returns only ID plus nullable display name. Active-link eligibility uses `EXISTS`, not a multiplicative join, so each profile occurs once even if bad/legacy data - exposes multiple eligible links; regression proof covers uniqueness. Only + exposes multiple eligible links. Current PostgreSQL also enforces the existing + one-link-per-profile unique constraint, so regression proof binds that + structural constraint plus the defensive `EXISTS` query rather than inserting + an impossible second link. Only draft, active, and paused projects qualify. - Grant list/detail remain readable in every existing project state. List applies optional status/role before keyset and `limit + 1`, orders ascending @@ -176,9 +184,13 @@ principals deny before product-row lookup. project-state behavior, strict fields/no totals, and the unchanged no-migration/no-PREP/no-mutation boundary. - Hosted `api_contract_e2e.py` provisions an isolated cursor secret and exercises - all three real routes, practical 429/503 mappings, identical 404 concealment, - OpenAPI action IDs, strict shapes, and absence of extra surfaces. The merge - intent names only 10B2 and its successor 10C. + all three real routes, identical 404 concealment, OpenAPI action IDs, strict + shapes, and absence of extra surfaces. Deterministic ASGI dependency tests own + practical 429/`Retry-After` and retryable 503 injection because making the + isolated hosted database unavailable would also destroy the server and its + cleanup boundary; they exercise the real nested rate dependency and prove the + failure precedes project SQL. The merge intent names only 10B2 and its + successor 10C. ## Verification commands @@ -204,6 +216,49 @@ integrity, docs, reuse/dedup, and test delta. Review rate-before-lookup, canonical scope, identical audited concealment, filter-before-keyset behavior, minimal fields, cursor binding, and exact activation. +## Preimplementation plan-review resolution + +- Dependency precedence is exact: verified token, one `authorization_read` + consumption, actor resolution plus human-only admission, canonical project + load, kernel authorization, cursor validation, then row query. For every + verified token, exhausted 429 or private retryable 503 therefore precedes the + identical nonhuman 404. Tests cover human, service, agent, Space, concealed, + missing-resource, and invalid-cursor paths at this ordering. +- Candidate project-state eligibility remains a typed resource guard inside the + single `AuthorizationService.require` decision. Grant list/detail deliberately + carry no project-state guard. +- Cursor-secret parsing and redaction land before route activation and startup + validates the required canonical Base64 32-byte value. There is no default, + test compatibility value, auth/rate-secret fallback, or serialization path. + Coordinated rotation intentionally invalidates outstanding cursors and requires + quiescence plus restart across replicas. The shared pytest harness explicitly + provisions an isolated test-only value because startup validation applies to + every application fixture; this is environment provisioning, not a Settings + default or compatibility fallback. +- Permission/scope and candidate lifecycle denials retain dependency-owned + rollback, bounded denial restaging, commit, and identical 404 translation. + Evidence persistence failure maps to private retryable 503. Missing project or + grant, project/grant mismatch, and nonhuman prelookup rejection create no + fabricated decision or audit row; routes neither catch `AuthorizationDenied` + nor commit. +- Activation is atomic with the complete route boundary: exactly the three + existing `ActionOwner.AUTH_10B` rows and three OpenAPI operations become + active. AUTH-10C remains planned. No CI workflow, threshold, skip, xfail, or + fixture-scope workaround is permitted; any required file outside the contract + is a stop for contract amendment. +- Final architecture review confirmed that rejecting every verified nonhuman + token immediately after the durable rate gate is the smaller route-specific + boundary: it avoids actor-registry and product SQL and emits no fabricated + action decision. The earlier fixed-service-only wording is superseded; + general actor resolution remains unchanged for every other route. +- The existing exact OpenAPI inventory gate must move from 62 to 65 routes and + record precisely these three actions. `backend/tests/test_api_controls.py` is + allowed only for that strengthened manifest regression; no inventory + assertion may be removed or loosened. +- The existing action-aware audit registry test must add the same exact three + active actions. `backend/tests/test_audit.py` is allowed only for that closed + active-set parity assertion; no audit validation may be weakened. + ## Stop conditions Stop on client project truth, authorization after row query, count leakage, diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-external-review-response.md new file mode 100644 index 00000000..e1b2c78b --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-external-review-response.md @@ -0,0 +1,51 @@ +# External Review Response: WS-AUTH-001-10B2 + +## Comments addressed + +- The first GitHub Backend run exposed two valid integration gaps: the hosted + reader lacked project-scoped Project Manager authority, and the closed audit + active-action fixture omitted the three newly activated reads. Repair SHA + `95c3ecf77afed2746a66f314d05eb547cfa15f3c` provisions a distinct reader + through the existing public grant API and restores exact audit parity. +- CodeRabbit warned that the PR description omitted trust-bundle sections. The + live PR description now follows the repository template, and CodeRabbit's + refreshed Description check passes. + +## Comments deferred + +- None. + +## Non-actionable findings + +- CodeRabbit reports 37.61 percent docstring coverage without identifying a + file or symbol. The repository's unchanged authoritative docstring command + passes the same head at 87.6 percent against its 80 percent floor. GitHub + preflight independently passes that gate. Adding unscoped docstrings merely + to match an unexplained external calculation would expand the reviewed diff + without improving the repository-defined evidence. +- GitHub's Node.js 20 action-runtime annotations concern unchanged pinned + workflow dependencies and are outside this no-workflow chunk. + +## Human decisions needed + +- Human review and explicit approval remain required before PR #178 may merge. + +## Commands rerun + +```text +GitHub Backend run 29892395881: +- preflight: pass +- api_e2e: pass +- shards 1-4: pass +- aggregate test and coverage: pass + +GitHub Agent Gates: pass +CodeRabbit refreshed Description check: pass +``` + +## Remaining risks + +- CodeRabbit did not perform an inline code review because its review allowance + was rate-limited; it posted no unresolved actionable code comment. +- Authorization/privacy remains L1 and therefore still requires the recorded + human review focus before explicit merge approval. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-internal-review-evidence.md new file mode 100644 index 00000000..f3240826 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-internal-review-evidence.md @@ -0,0 +1,97 @@ +# Internal Review Evidence: WS-AUTH-001-10B2 + +## Chunk And Reviewed Code + +`WS-AUTH-001-10B2` — Privacy-Safe Project Role Grant Reads. Risk: L1 +authorization/privacy. Trusted main is `14fa4316f7d984f2176657bfafd2a2dae56f944e`. + +Reviewed code SHA: `041c0455379dab73d044d9f952f259a9afc17983` +Reviewed at: 2026-07-23T00:18:30Z +Reviewer run IDs: /root/auth10b1_final_core, /root/auth10b1_final_security_qa, /root/auth10b1_final_ops_docs_ci + +Runtime repair SHA `95c3ecf77afed2746a66f314d05eb547cfa15f3c` +was reviewed by the same tracks before the evidence-only descendants. +Prior post-start reconciliation SHA +`2b7c5a83d8871b05266ee3c0a9991ca4bc686e12` integrated main +`afde967d56914cdc6a941aa17b3ff0e98b2e9c64` without an AUTH runtime, +contract, test, or evidence-content change. Latest main +`9033a97a4be6d762cae4b210018ef81a079395c2` was then reconciled at the +reviewed SHA above. AUTH production runtime and contract are unchanged; the +trusted-main shared test/reset changes were conflict-resolved and reverified. +Prior latest-main conflict-resolution SHA +`649069f38e2eb374a4302ea859a70a842e81215d` remains historical evidence. +Latest main `93c14181f8a14c88c2cbfb2f2ea886c8d46deec8` was integrated at the +reviewed SHA above. AUTH production runtime and contract remain unchanged; +the inherited artifact operator routes and workflow coverage strengthening +were integrated and reverified. + +## Implemented Contract + +- Activates exactly three AUTH-10B project candidate/grant read actions/routes; + AUTH-10C remains planned. +- Orders one durable rate consumption, nonhuman concealment, project load, + kernel authorization, cursor validation, then private row SQL. +- Adds strict count-free responses and HMAC-SHA256 keysets bound to normalized + action, project, filters, limit, order, timestamp, and UUID. +- Requires an independent canonical Base64 32-byte startup key with no default, + fallback, serialization, or authentication/rate-key reuse. +- Adds no migration and changes no PREP, issue, revoke, or mutation behavior. + +## Deterministic Evidence + +```text +148 passed — complete configuration test file +42 passed, 218 deselected — cursor/rate/nonhuman/kernel/read-service row +1 passed — exact OpenAPI inventory and protected-action manifest +2 passed — OpenAPI plus one-link structural candidate constraint +2 passed in 53.82s — isolated PostgreSQL candidate/grant keysets, filters, +service exclusion, project binding, limit+1, and no-COUNT SQL +Ruff app/tests/scripts — passed +Python compileall app/hosted drill — passed +Docstring coverage — 87.6% against 80% floor +Markdown links and stale wording/authorization scans — passed +git diff --check — passed +``` + +The repository-wide suite is GitHub-owned because this workstation previously +required about four hours and a broader local run hit SQLAlchemy process +instability. GitHub must prove all shards, hosted API E2E, the 78% repository +floor, and the 90% authorization-subsystem floor. + +## Integrity And Review + +No test was deleted, skipped, xfailed, or weakened. AUTH-10B2 itself +strengthens the exact OpenAPI inventory from 62 to 65 routes and 60 to 63 +protected operations. Composed with latest main, the reviewed tree contains 74 +routes with hash +`f0076e4145d8fe68365912a6e5bc047dece3ae2ac1e69619018b5119a9710c09` +and 72 protected operations with hash +`b05ebbca27a538958af6c122403ccf3997ab08326943150f6c763ce9c321eec5`. +AUTH changed no workflow, dependency, command, shard, threshold, or coverage +setting; latest main's inherited workflow change strengthens router coverage. + +| Reviewer | Result | Blocking findings | +|---|---|---| +| Senior engineering | PASS | none | +| Architecture | PASS | none | +| Reuse/dedup | PASS | none | +| Security/auth | PASS | none | +| QA/test | PASS | none | +| Test delta | PASS | none | +| Product/ops | PASS | none | +| Docs | PASS | none | +| CI integrity | PASS | none | + +Exact-SHA reviewers: `/root/auth10b1_final_core`, +`/root/auth10b1_final_security_qa`, and `/root/auth10b1_final_ops_docs_ci`. +Two repair cycles closed every valid finding. Open reviewer sessions: none. + +Open sub-agent sessions: none +Valid findings addressed: yes + +## Internal Repair Re-review + +All nine internal tracks pass repair SHA +`95c3ecf77afed2746a66f314d05eb547cfa15f3c` with no open finding. External +GitHub and CodeRabbit findings, repairs, and final statuses are recorded only in +`WS-AUTH-001-10B2-external-review-response.md`. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-pr-trust-bundle.md new file mode 100644 index 00000000..8ab6a63e --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10B2-pr-trust-bundle.md @@ -0,0 +1,61 @@ +# PR Trust Bundle: WS-AUTH-001-10B2 + +## Chunk And Goal + +`WS-AUTH-001-10B2` exposes privacy-safe contributor candidates and immutable +project-role grant history through three exact authenticated, rate-controlled +read actions. Risk is L1 authorization/privacy. + +## Human-Approved Intent + +The user explicitly started this signed successor after 10B1 merged and requires +end-to-end safety before 10C. Signed loop memory binds it to main `14fa4316`. + +## Change, Design, And Scope + +- Added signed keyset cursors and a required independent startup secret. +- Added candidate and grant-history repository reads with exact filters, + project binding, `limit + 1`, no count, and timestamp/UUID progression. +- Added typed contexts, kernel guard, action-aware concealment, minimal schemas, + and exactly three `/api/v1/projects/...` GET routes. +- Strengthened PostgreSQL, OpenAPI, config, focused, and hosted API proof. +- Documented provisioning, rotation, disclosure, authority, state, and errors. +- Added one merge intent naming 10C as an explicit-start successor. + +Authorization precedes cursor/row lookup. Candidate lifecycle remains inside the +kernel; grant history remains readable in every project state. All verified +nonhumans are rate-gated then concealed before actor/product SQL. Sensitive +absence and denials share one 404. + +All files are contract-allowed. There is no migration, workflow/dependency edit, +PREP change, mutation activation, frontend work, or AUTH-10C surface. Responses +never expose totals, identity-link facts, issuer/subject, contact, claims, or keys. + +## Proof And Review + +Focused evidence passes: 148 config tests; 42 cursor/rate/kernel/service tests; +exact OpenAPI inventory; and two isolated PostgreSQL candidate/grant tests. +Ruff, compileall, docstrings, links, stale scans, and diff hygiene pass. Hosted +E2E provisions a cursor key and checks all routes, strict nonempty nested shapes, +stable concealment, exact actions, and 10C/context absence. + +No test or CI threshold was weakened. All nine internal tracks pass exact SHA +`95c3ecf77afed2746a66f314d05eb547cfa15f3c` with no open finding. + +## External Review, Risk, And Human Focus + +GitHub Backend run `29892395881` passes preflight, hosted API E2E, all four +isolated shards, and aggregate coverage. Agent Gates passes. CodeRabbit has no +inline code finding; its refreshed description check passes. Its remaining +37.61 percent docstring warning conflicts with the unchanged authoritative +repository gate, which passes at 87.6 percent, and identifies no file or symbol. +The external-response artifact records this as non-actionable rather than +expanding the reviewed diff to satisfy an unexplained external calculation. +The first hosted run exposed missing project-scoped reader authority and one +stale exact audit action set. Repair SHA `95c3ecf7` provisions a distinct reader +through the public project-scoped AdminRoleGrant API and adds only the three +activated actions to audit parity; all internal tracks pass the repair. +Human review should focus on ordering, PM/Audit scope and lifecycle boundaries, +cursor binding, response minimization, identical concealment, and the exact +three-action activation. The user retains merge ownership; do not merge without +explicit approval of the specific PR. diff --git a/.agent-loop/merge-intents/WS-AUTH-001-10B2.json b/.agent-loop/merge-intents/WS-AUTH-001-10B2.json new file mode 100644 index 00000000..b72bf63b --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-10B2.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-10B2", + "chunk_title": "Privacy-Safe Project Role Grant Reads", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-10C", + "next_chunk_title": "Project Role Grant Mutations", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index 917f5f42..27f2a2a0 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -16,6 +16,7 @@ get_auth_verification_result, ) from app.api.deps.rate_controls import enforce_rate_control, get_rate_control_service +from app.api.deps.api_controls import enforce_authorization_read_rate_limit from app.core.api_controls import StructuredHTTPException, request_ids from app.db.session import get_db_session from app.modules.actors.service import ( @@ -120,6 +121,18 @@ def authorization_http_error(exc: AuthorizationDenied) -> StructuredHTTPExceptio "grant_not_found": "Grant not found", "resource_not_found": "Resource not found", } + concealed_project_reads = { + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + } + if exc.decision.action_id in concealed_project_reads: + return StructuredHTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project authorization resource not found", + error_code="project_authorization_resource_not_found", + error_message="Project authorization resource not found", + ) code = exc.public_code message = messages[code] status_code = ( @@ -135,6 +148,20 @@ def authorization_http_error(exc: AuthorizationDenied) -> StructuredHTTPExceptio ) +async def enforce_human_authorization_read( + _rate_control: Annotated[None, Depends(enforce_authorization_read_rate_limit)], + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], +) -> None: + """Consume rate first, then conceal every nonhuman authorization read.""" + if result.token.subject_kind != "human": + raise StructuredHTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project authorization resource not found", + error_code="project_authorization_resource_not_found", + error_message="Project authorization resource not found", + ) + + async def get_authorization_service( request: Request, resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], diff --git a/backend/app/core/config.py b/backend/app/core/config.py index cec36099..cb938fd1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -210,6 +210,7 @@ class Settings(BaseSettings): ) _api_rate_limit_key_secret: SecretStr | None = PrivateAttr(default=None) + _pagination_cursor_hmac_secret: SecretStr | None = PrivateAttr(default=None) _artifact_s3_access_key_id: SecretStr | None = PrivateAttr(default=None) _artifact_s3_secret_access_key: SecretStr | None = PrivateAttr(default=None) _artifact_s3_session_token: SecretStr | None = PrivateAttr(default=None) @@ -217,13 +218,16 @@ class Settings(BaseSettings): def __init__(self, **values: object) -> None: """Remove rate-control secret material before structured validation.""" secret: SecretStr | None = None + cursor_secret: SecretStr | None = None s3_secrets = _EMPTY_ARTIFACT_S3_SECRETS try: _normalize_artifact_s3_endpoint_input(values) secret = _extract_api_rate_limit_key_secret(values) + cursor_secret = _extract_pagination_cursor_hmac_secret(values) s3_secrets = _extract_artifact_s3_static_secrets(values) super().__init__(**values) self._api_rate_limit_key_secret = secret + self._pagination_cursor_hmac_secret = cursor_secret self._set_artifact_s3_static_secrets(s3_secrets) if not _ALTERNATE_VALIDATION_RESTORES_SECRETS.get(): self._validate_artifact_s3_secret_contract() @@ -231,6 +235,7 @@ def __init__(self, **values: object) -> None: _clear_settings_private_secrets(self) values.clear() secret = None + cursor_secret = None s3_secrets = _EMPTY_ARTIFACT_S3_SECRETS raise @@ -239,6 +244,7 @@ def model_validate(cls, obj: object, **kwargs: object) -> Self: """Sanitize secret-bearing mappings before Pydantic retains input.""" if isinstance(obj, Mapping) and ( "api_rate_limit_key_secret" in obj + or "pagination_cursor_hmac_secret" in obj or _ARTIFACT_S3_SENSITIVE_INPUT_FIELDS.intersection(obj) ): sanitized = dict(obj) @@ -272,6 +278,7 @@ def model_validate_json( raise ValueError("invalid settings JSON") if isinstance(parsed, Mapping) and ( "api_rate_limit_key_secret" in parsed + or "pagination_cursor_hmac_secret" in parsed or _ARTIFACT_S3_SENSITIVE_INPUT_FIELDS.intersection(parsed) ): try: @@ -292,6 +299,7 @@ def model_validate_strings(cls, obj: object, **kwargs: object) -> Self: """Sanitize string-mapping secret input before structured validation.""" if isinstance(obj, Mapping) and ( "api_rate_limit_key_secret" in obj + or "pagination_cursor_hmac_secret" in obj or _ARTIFACT_S3_SENSITIVE_INPUT_FIELDS.intersection(obj) ): sanitized = dict(obj) @@ -313,6 +321,7 @@ def _validate_secret_mapping( """Validate a mapping without retaining supplied secret material.""" supplied_s3_fields = _ARTIFACT_S3_SECRET_FIELDS.intersection(sanitized) secret: SecretStr | None = None + cursor_secret: SecretStr | None = None s3_secrets = _EMPTY_ARTIFACT_S3_SECRETS settings: Settings | None = None try: @@ -322,9 +331,16 @@ def _validate_secret_mapping( if "api_rate_limit_key_secret" in sanitized else None ) + cursor_secret = ( + _extract_pagination_cursor_hmac_secret(sanitized) + if "pagination_cursor_hmac_secret" in sanitized + else None + ) s3_secrets = _extract_artifact_s3_static_secrets(sanitized) if secret is not None: sanitized["api_rate_limit_key_secret"] = None + if cursor_secret is not None: + sanitized["pagination_cursor_hmac_secret"] = None for field_name in supplied_s3_fields: sanitized[field_name] = None restore_token = _ALTERNATE_VALIDATION_RESTORES_SECRETS.set(True) @@ -334,6 +350,8 @@ def _validate_secret_mapping( _ALTERNATE_VALIDATION_RESTORES_SECRETS.reset(restore_token) if secret is not None: settings._api_rate_limit_key_secret = secret + if cursor_secret is not None: + settings._pagination_cursor_hmac_secret = cursor_secret settings._set_artifact_s3_static_secrets(s3_secrets) settings._validate_artifact_s3_secret_contract() return settings @@ -342,6 +360,7 @@ def _validate_secret_mapping( _clear_settings_private_secrets(settings) sanitized.clear() secret = None + cursor_secret = None s3_secrets = _EMPTY_ARTIFACT_S3_SECRETS settings = None raise @@ -358,6 +377,9 @@ def settings_customise_sources( """Keep the manually resolved rate key out of dotenv validation input.""" if isinstance(dotenv_settings, DotEnvSettingsSource): dotenv_settings.env_vars.pop("workstream_api_rate_limit_key_secret", None) + dotenv_settings.env_vars.pop( + "workstream_pagination_cursor_hmac_secret", None + ) for field_name in _ARTIFACT_S3_SECRET_FIELDS: dotenv_settings.env_vars.pop(f"workstream_{field_name}", None) return init_settings, env_settings, dotenv_settings, file_secret_settings @@ -367,6 +389,11 @@ def api_rate_limit_key_secret(self) -> SecretStr | None: """Return the validated, redacted rate-control key setting.""" return self._api_rate_limit_key_secret + @property + def pagination_cursor_hmac_secret(self) -> SecretStr | None: + """Return the validated, redacted pagination-cursor key setting.""" + return self._pagination_cursor_hmac_secret + @property def artifact_s3_access_key_id(self) -> SecretStr | None: """Return the local/CI MinIO access-key identifier.""" @@ -535,6 +562,7 @@ def _clear_settings_private_secrets(settings: object) -> None: if not isinstance(private_values, dict): return private_values["_api_rate_limit_key_secret"] = None + private_values["_pagination_cursor_hmac_secret"] = None private_values["_artifact_s3_access_key_id"] = None private_values["_artifact_s3_secret_access_key"] = None private_values["_artifact_s3_session_token"] = None @@ -600,6 +628,32 @@ def _extract_api_rate_limit_key_secret(values: dict[str, object]) -> SecretStr | return secret +def _extract_pagination_cursor_hmac_secret( + values: dict[str, object], +) -> SecretStr | None: + """Resolve the required cursor key without retaining structured input.""" + raw_value = _pop_optional_secret_source( + values, + field_name="pagination_cursor_hmac_secret", + environment_name="WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET", + ) + if raw_value is _MISSING_SECRET or raw_value is None: + return None + if isinstance(raw_value, SecretStr): + secret = raw_value + elif isinstance(raw_value, str): + secret = SecretStr(raw_value) + else: + del raw_value + raise ValueError("invalid pagination cursor HMAC secret") + try: + decode_pagination_cursor_hmac_secret(secret) + except ValueError: + del raw_value, secret + raise + return secret + + def _extract_artifact_s3_static_secrets( values: dict[str, object], ) -> tuple[SecretStr | None, SecretStr | None, SecretStr | None]: @@ -703,3 +757,21 @@ def decode_api_rate_limit_key_secret(value: SecretStr) -> bytes: del value, secret, encoded, decoded raise ValueError("invalid API rate limit key secret") return decoded + + +def decode_pagination_cursor_hmac_secret(value: SecretStr) -> bytes: + """Decode one canonical padded Base64 32-byte cursor HMAC key.""" + secret = value.get_secret_value() + if not secret.isascii(): + del value, secret + raise ValueError("invalid pagination cursor HMAC secret") + try: + encoded = secret.encode("ascii") + decoded = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError): + del value, secret + raise ValueError("invalid pagination cursor HMAC secret") from None + if len(decoded) != 32 or base64.b64encode(decoded) != encoded: + del value, secret, encoded, decoded + raise ValueError("invalid pagination cursor HMAC secret") + return decoded diff --git a/backend/app/main.py b/backend/app/main.py index e031ab92..ebd873f2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,11 @@ install_api_control_openapi, ) from app.core.auth import build_auth_verifier, cache_auth_verifier, prepare_auth_verifier -from app.core.config import Settings, get_settings +from app.core.config import ( + Settings, + decode_pagination_cursor_hmac_secret, + get_settings, +) from app.interfaces.artifacts import ArtifactStoreBootstrap, ArtifactStoreNamespaceClaim PRODUCTION_LIKE_ENVIRONMENTS = {"staging", "preview", "prod", "production"} @@ -60,6 +64,9 @@ async def _application_lifespan(app: FastAPI) -> AsyncIterator[None]: """Reject invalid production authentication configuration before serving.""" settings: Settings = app.state.settings + if settings.pagination_cursor_hmac_secret is None: + raise RuntimeError("pagination cursor HMAC secret is required") + decode_pagination_cursor_hmac_secret(settings.pagination_cursor_hmac_secret) if ( settings.environment in PRODUCTION_LIKE_ENVIRONMENTS and not app.state.auth_configuration_valid diff --git a/backend/app/modules/actors/repository.py b/backend/app/modules/actors/repository.py index 487c707d..95cb9639 100644 --- a/backend/app/modules/actors/repository.py +++ b/backend/app/modules/actors/repository.py @@ -3,8 +3,10 @@ from __future__ import annotations import hashlib +from datetime import datetime +from uuid import UUID -from sqlalchemy import func, select, update +from sqlalchemy import exists, func, or_, select, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession @@ -42,6 +44,42 @@ def _advisory_key(*values: str, domain: bytes = b"") -> int: digest = hashlib.sha256(framed).digest() return int.from_bytes(digest[:8], "big", signed=True) + async def list_contributor_candidates( + self, + *, + caller_actor_profile_id: UUID, + cursor: tuple[datetime, UUID] | None, + limit: int, + ) -> list[ActorProfile]: + """List unique eligible humans using a strict timestamp/UUID keyset.""" + active_link = exists( + select(ActorIdentityLink.id).where( + ActorIdentityLink.actor_profile_id == ActorProfile.id, + ActorIdentityLink.status == "active", + ) + ) + query = select(ActorProfile).where( + ActorProfile.actor_kind == "human", + ActorProfile.status == "active", + ActorProfile.id != str(caller_actor_profile_id), + active_link, + ) + if cursor is not None: + created_at, actor_profile_id = cursor + query = query.where( + or_( + ActorProfile.created_at > created_at, + ( + (ActorProfile.created_at == created_at) + & (ActorProfile.id > str(actor_profile_id)) + ), + ) + ) + rows = await self._session.scalars( + query.order_by(ActorProfile.created_at, ActorProfile.id).limit(limit + 1) + ) + return list(rows.all()) + async def get_identity_link( self, issuer: str, diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 47c4a7c5..337167b3 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -316,17 +316,17 @@ def _active( PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B, ), - _planned( + _active( ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10B, ), - _planned( + _active( ActionId.PROJECT_ROLE_GRANT_LIST, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B, ), - _planned( + _active( ActionId.PROJECT_ROLE_GRANT_READ, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B, @@ -630,6 +630,9 @@ def _index_actions( ActionId.ACTOR_PROFILE_DEACTIVATE, ActionId.ACTOR_IDENTITY_LINK_REVOKE, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, } if { definition.action_id diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 246c54d8..accde975 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -45,6 +45,9 @@ IdentityLinkStatus, MatchedAuthorityKind, PermissionCatalogueResourceContext, + ProjectContributorCandidateCollectionResourceContext, + ProjectRoleGrantCollectionResourceContext, + ProjectRoleGrantReadResourceContext, PreparedAuthorizationUnsupported, PreparedAuthorityScope, PreparedAuthorityScopeKind, @@ -78,6 +81,9 @@ ActionId.ACTOR_PROFILE_DEACTIVATE, ActionId.ACTOR_IDENTITY_LINK_REVOKE, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, } ) _SERIALIZED_ADMIN_READS = frozenset( @@ -688,6 +694,9 @@ async def _admin_guard( resource, ActorAdminRoleGrantHistoryResourceContext ) and not await self._admin.actor_exists(resource.resource_id): return AuthorizationDenialCode.ACTOR_NOT_FOUND + elif isinstance(resource, ProjectContributorCandidateCollectionResourceContext): + if resource.project_status not in {"draft", "active", "paused"}: + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED return None @staticmethod @@ -710,6 +719,11 @@ def _admin_resource_matches( ActionId.ACTOR_PROFILE_DEACTIVATE: ActorProfileLifecycleResourceContext, ActionId.ACTOR_IDENTITY_LINK_REVOKE: ActorIdentityLinkLifecycleResourceContext, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE: ActorIdentityLinkLifecycleResourceContext, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST: ( + ProjectContributorCandidateCollectionResourceContext + ), + ActionId.PROJECT_ROLE_GRANT_LIST: ProjectRoleGrantCollectionResourceContext, + ActionId.PROJECT_ROLE_GRANT_READ: ProjectRoleGrantReadResourceContext, }.get(action_id) if expected is None or not isinstance(resource, expected): return False diff --git a/backend/app/modules/authorization/pagination.py b/backend/app/modules/authorization/pagination.py new file mode 100644 index 00000000..d5e4dbef --- /dev/null +++ b/backend/app/modules/authorization/pagination.py @@ -0,0 +1,178 @@ +"""Strict signed keyset cursors for privacy-sensitive authorization reads.""" + +from __future__ import annotations + +import base64 +import binascii +from datetime import UTC, datetime +import hashlib +import hmac +import json +import re +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, field_validator + +from app.core.hashing import canonical_json_hash +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.schemas import ProjectRole + +_BASE64URL = re.compile(r"^[A-Za-z0-9_-]+$") +_MAX_DECODED_BYTES = 384 +_STRICT = ConfigDict(extra="forbid", frozen=True, strict=True, hide_input_in_errors=True) +_ORDER = "timestamp_uuid_asc" + + +class InvalidPaginationCursor(ValueError): + """One generic failure for malformed, forged, or replayed cursors.""" + + +class _CursorPayload(BaseModel): + model_config = _STRICT + + v: Literal[1] + q: str + ts: str + id: str + + @field_validator("q") + @classmethod + def validate_digest(cls, value: str) -> str: + if re.fullmatch(r"sha256:[0-9a-f]{64}", value) is None: + raise ValueError("invalid cursor") + return value + + +class _CursorEnvelope(BaseModel): + model_config = _STRICT + + p: _CursorPayload + s: str + + @field_validator("s") + @classmethod + def validate_signature(cls, value: str) -> str: + if len(value) != 43 or _BASE64URL.fullmatch(value) is None: + raise ValueError("invalid cursor") + return value + + +def authorization_read_query_digest( + *, + action_id: ActionId, + project_id: UUID, + limit: int, + status: Literal["active", "revoked"] | None = None, + role: ProjectRole | None = None, +) -> str: + """Bind a cursor to one normalized read query and ordering contract.""" + return canonical_json_hash( + { + "action_id": action_id.value, + "limit": limit, + "order": _ORDER, + "project_id": str(project_id), + "role": role.value if role is not None else None, + "status": status, + } + ) + + +class AuthorizationReadCursorCodec: + """Encode and verify one canonical HMAC-bound keyset boundary.""" + + def __init__(self, secret: bytes) -> None: + if len(secret) != 32: + raise ValueError("invalid pagination cursor HMAC secret") + self._secret = bytes(secret) + + def encode(self, *, query_digest: str, timestamp: datetime, resource_id: UUID) -> str: + """Return one canonical unpadded Base64url cursor.""" + payload = _CursorPayload( + v=1, + q=query_digest, + ts=_canonical_timestamp(timestamp), + id=str(resource_id), + ) + payload_bytes = _canonical_json(payload.model_dump(mode="json")) + signature = hmac.new(self._secret, payload_bytes, hashlib.sha256).digest() + envelope = {"p": payload.model_dump(mode="json"), "s": _encode_base64url(signature)} + return _encode_base64url(_canonical_json(envelope)) + + def decode(self, value: str, *, query_digest: str) -> tuple[datetime, UUID]: + """Verify a cursor and return its strict timestamp/UUID boundary.""" + try: + decoded = _decode_base64url(value) + if len(decoded) > _MAX_DECODED_BYTES: + raise InvalidPaginationCursor("invalid cursor") + raw = json.loads(decoded, object_pairs_hook=_reject_duplicate_keys) + envelope = _CursorEnvelope.model_validate(raw) + payload_bytes = _canonical_json(envelope.p.model_dump(mode="json")) + signature = _decode_base64url(envelope.s) + expected = hmac.new(self._secret, payload_bytes, hashlib.sha256).digest() + if not hmac.compare_digest(expected, signature): + raise InvalidPaginationCursor("invalid cursor") + if not hmac.compare_digest(envelope.p.q, query_digest): + raise InvalidPaginationCursor("invalid cursor") + timestamp = _parse_canonical_timestamp(envelope.p.ts) + resource_id = UUID(envelope.p.id) + if str(resource_id) != envelope.p.id: + raise InvalidPaginationCursor("invalid cursor") + return timestamp, resource_id + except InvalidPaginationCursor: + raise + except (ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error): + raise InvalidPaginationCursor("invalid cursor") from None + + +def _canonical_json(value: dict[str, Any]) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def _encode_base64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _decode_base64url(value: str) -> bytes: + if not value or len(value) > 512 or _BASE64URL.fullmatch(value) is None: + raise InvalidPaginationCursor("invalid cursor") + padding = "=" * (-len(value) % 4) + decoded = base64.b64decode( + (value + padding).encode("ascii"), + altchars=b"-_", + validate=True, + ) + if _encode_base64url(decoded) != value: + raise InvalidPaginationCursor("invalid cursor") + return decoded + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise InvalidPaginationCursor("invalid cursor") + value[key] = item + return value + + +def _canonical_timestamp(value: datetime) -> str: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("cursor timestamp must be timezone-aware") + return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + + +def _parse_canonical_timestamp(value: str) -> datetime: + if not value.endswith("Z"): + raise InvalidPaginationCursor("invalid cursor") + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + if _canonical_timestamp(parsed) != value: + raise InvalidPaginationCursor("invalid cursor") + return parsed diff --git a/backend/app/modules/authorization/read_service.py b/backend/app/modules/authorization/read_service.py new file mode 100644 index 00000000..d81a3010 --- /dev/null +++ b/backend/app/modules/authorization/read_service.py @@ -0,0 +1,229 @@ +"""Privacy-safe orchestration for project-role authorization reads.""" + +from __future__ import annotations + +from uuid import UUID + +from app.modules.actors.repository import ActorRepository +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.models import ( + ProjectRoleGrant, + ProjectRoleQualificationSnapshot, +) +from app.modules.authorization.pagination import ( + AuthorizationReadCursorCodec, + InvalidPaginationCursor, + authorization_read_query_digest, +) +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + ProjectContributorCandidateCollectionResourceContext, + ProjectRoleGrantCollectionResourceContext, + ProjectRoleGrantReadResourceContext, +) +from app.modules.authorization.schemas import ( + ContributorCandidateListResponse, + ContributorCandidateRead, + ProjectRole, + ProjectRoleGrantListResponse, + ProjectRoleGrantRead, + ProjectRoleQualificationSnapshotRead, + QualificationAvailabilitySnapshot, +) +from app.modules.projects.models import Project + + +class ProjectRoleReadResourceNotFound(LookupError): + """One concealed absence for project or project-role grant reads.""" + + +class ProjectRoleReadService: + """Authorize before decoding cursors and querying private read rows.""" + + def __init__( + self, + authorization: AuthorizationService, + actors: ActorRepository, + grants: AdminAuthorizationRepository, + cursor_codec: AuthorizationReadCursorCodec, + ) -> None: + self._authorization = authorization + self._actors = actors + self._grants = grants + self._cursor_codec = cursor_codec + + async def list_contributor_candidates( + self, + *, + project: Project, + caller_actor_profile_id: UUID, + limit: int, + cursor: str | None, + ) -> ContributorCandidateListResponse: + """Return one authorized, count-free page of eligible humans.""" + project_id = UUID(project.id) + await self._authorization.require( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ProjectContributorCandidateCollectionResourceContext( + resource_type="project_contributor_candidate_collection", + resource_id=project_id, + scope_project_id=project_id, + project_status=project.status, + ), + ) + digest = authorization_read_query_digest( + action_id=ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + project_id=project_id, + limit=limit, + ) + boundary = self._cursor_codec.decode(cursor, query_digest=digest) if cursor else None + rows = await self._actors.list_contributor_candidates( + caller_actor_profile_id=caller_actor_profile_id, + cursor=boundary, + limit=limit, + ) + visible = rows[:limit] + next_cursor = None + if len(rows) > limit and visible: + last = visible[-1] + next_cursor = self._cursor_codec.encode( + query_digest=digest, + timestamp=last.created_at, + resource_id=UUID(last.id), + ) + return ContributorCandidateListResponse( + items=[ + ContributorCandidateRead( + actor_profile_id=UUID(row.id), + display_name=row.display_name, + ) + for row in visible + ], + next_cursor=next_cursor, + ) + + async def list_project_role_grants( + self, + *, + project: Project, + status: str | None, + role: ProjectRole | None, + limit: int, + cursor: str | None, + ) -> ProjectRoleGrantListResponse: + """Return one authorized page of immutable project grant history.""" + project_id = UUID(project.id) + await self._authorization.require( + ActionId.PROJECT_ROLE_GRANT_LIST, + ProjectRoleGrantCollectionResourceContext( + resource_type="project_role_grant_collection", + resource_id=project_id, + scope_project_id=project_id, + project_status=project.status, + ), + ) + digest = authorization_read_query_digest( + action_id=ActionId.PROJECT_ROLE_GRANT_LIST, + project_id=project_id, + status=status, + role=role, + limit=limit, + ) + boundary = self._cursor_codec.decode(cursor, query_digest=digest) if cursor else None + rows = await self._grants.list_project_role_grants( + project_id=project_id, + status=status, + role=role.value if role is not None else None, + cursor=boundary, + limit=limit, + ) + visible = rows[:limit] + next_cursor = None + if len(rows) > limit and visible: + last = visible[-1][0] + next_cursor = self._cursor_codec.encode( + query_digest=digest, + timestamp=last.granted_at, + resource_id=last.id, + ) + return ProjectRoleGrantListResponse( + items=[_grant_read(grant, snapshot) for grant, snapshot in visible], + next_cursor=next_cursor, + ) + + async def read_project_role_grant( + self, + *, + project: Project, + grant_id: UUID, + ) -> ProjectRoleGrantRead: + """Return one authorized grant only when it belongs to the path project.""" + project_id = UUID(project.id) + await self._authorization.require( + ActionId.PROJECT_ROLE_GRANT_READ, + ProjectRoleGrantReadResourceContext( + resource_type="project_role_grant", + resource_id=grant_id, + scope_project_id=project_id, + project_status=project.status, + ), + ) + row = await self._grants.get_project_role_grant( + project_id=project_id, + grant_id=grant_id, + ) + if row is None: + raise ProjectRoleReadResourceNotFound("project role grant not found") + return _grant_read(*row) + + +def _grant_read( + grant: ProjectRoleGrant, + snapshot: ProjectRoleQualificationSnapshot, +) -> ProjectRoleGrantRead: + qualification = ProjectRoleQualificationSnapshotRead( + id=snapshot.id, + requested_role=ProjectRole(snapshot.requested_role), + skills_snapshot=QualificationAvailabilitySnapshot.model_validate( + snapshot.skills_snapshot, + strict=False, + ), + reputation_snapshot=QualificationAvailabilitySnapshot.model_validate( + snapshot.reputation_snapshot, + strict=False, + ), + prior_project_work_refs=[UUID(value) for value in snapshot.prior_project_work_refs], + external_expertise_refs=list(snapshot.external_expertise_refs), + captured_by_actor_profile_id=UUID(snapshot.captured_by_actor_profile_id), + captured_by_admin_role_grant_id=snapshot.captured_by_admin_role_grant_id, + captured_at=snapshot.captured_at, + ) + return ProjectRoleGrantRead( + id=grant.id, + project_id=UUID(grant.project_id), + actor_profile_id=UUID(grant.actor_profile_id), + role=ProjectRole(grant.role), + status=grant.status, + version=grant.version, + grant_method=grant.grant_method, + qualification_snapshot=qualification, + granted_by_actor_profile_id=UUID(grant.granted_by_actor_profile_id), + granted_by_admin_role_grant_id=grant.granted_by_admin_role_grant_id, + granted_at=grant.granted_at, + grant_reason=grant.grant_reason, + revoked_by_actor_profile_id=( + UUID(grant.revoked_by_actor_profile_id) + if grant.revoked_by_actor_profile_id is not None + else None + ), + revoked_at=grant.revoked_at, + revoked_reason=grant.revoked_reason, + ) + + +__all__ = [ + "InvalidPaginationCursor", + "ProjectRoleReadResourceNotFound", + "ProjectRoleReadService", +] diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py index 563f5f29..cb3ea99a 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -15,6 +15,8 @@ AdminRoleGrant, AuthorityControl, AuthorityIdempotencyRecord, + ProjectRoleGrant, + ProjectRoleQualificationSnapshot, ) from app.modules.authorization.policy import permissions_for from app.modules.authorization.schemas import ( @@ -42,6 +44,70 @@ def __init__(self, session: AsyncSession) -> None: self._session = session self._projects = ProjectRepository(session) + async def list_project_role_grants( + self, + *, + project_id: UUID, + status: str | None, + role: str | None, + cursor: tuple[datetime, UUID] | None, + limit: int, + ) -> list[tuple[ProjectRoleGrant, ProjectRoleQualificationSnapshot]]: + """List grant history with its exact immutable snapshot and keyset.""" + query = ( + select(ProjectRoleGrant, ProjectRoleQualificationSnapshot) + .join( + ProjectRoleQualificationSnapshot, + ProjectRoleQualificationSnapshot.id + == ProjectRoleGrant.qualification_snapshot_id, + ) + .where(ProjectRoleGrant.project_id == str(project_id)) + ) + if status is not None: + query = query.where(ProjectRoleGrant.status == status) + if role is not None: + query = query.where(ProjectRoleGrant.role == role) + if cursor is not None: + granted_at, grant_id = cursor + query = query.where( + or_( + ProjectRoleGrant.granted_at > granted_at, + and_( + ProjectRoleGrant.granted_at == granted_at, + ProjectRoleGrant.id > grant_id, + ), + ) + ) + rows = await self._session.execute( + query.order_by(ProjectRoleGrant.granted_at, ProjectRoleGrant.id) + .limit(limit + 1) + .execution_options(populate_existing=True) + ) + return list(rows.tuples().all()) + + async def get_project_role_grant( + self, + *, + project_id: UUID, + grant_id: UUID, + ) -> tuple[ProjectRoleGrant, ProjectRoleQualificationSnapshot] | None: + """Load one grant only through its canonical project relationship.""" + row = ( + await self._session.execute( + select(ProjectRoleGrant, ProjectRoleQualificationSnapshot) + .join( + ProjectRoleQualificationSnapshot, + ProjectRoleQualificationSnapshot.id + == ProjectRoleGrant.qualification_snapshot_id, + ) + .where( + ProjectRoleGrant.project_id == str(project_id), + ProjectRoleGrant.id == grant_id, + ) + ) + ).one_or_none() + return tuple(row) if row is not None else None + async def lock_control(self) -> AuthorityControl: """Lock the irreversible singleton before any administrative mutation.""" control = await self._session.scalar( diff --git a/backend/app/modules/authorization/router.py b/backend/app/modules/authorization/router.py index c930969e..578ffa28 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -6,18 +6,24 @@ from typing import Annotated, Literal, TypeVar from uuid import UUID -from fastapi import APIRouter, Depends, Header, Query, status +from fastapi import APIRouter, Depends, Header, Query, Request, status from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps.api_controls import enforce_admin_mutation_rate_limit from app.api.deps.rate_controls import service_unavailable_error from app.api.deps.auth import get_application_auth_verifier -from app.api.deps.authorization import get_authorization_actor, get_authorization_service +from app.api.deps.authorization import ( + enforce_human_authorization_read, + get_authorization_actor, + get_authorization_service, +) from app.core.api_controls import ApiErrorResponse, StructuredHTTPException +from app.core.config import decode_pagination_cursor_hmac_secret from app.db.session import get_db_session from app.interfaces.auth import AuthVerificationUnavailableError, AuthVerifier from app.modules.actors.service import ActorService, ResolvedActor +from app.modules.actors.repository import ActorRepository from app.modules.actors.schemas import ( ActorIdentityLinkAdminResponse, ActorProfileAdminResponse, @@ -36,6 +42,15 @@ ) from app.modules.authorization.catalogue import ActionId from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.pagination import ( + AuthorizationReadCursorCodec, + InvalidPaginationCursor, +) +from app.modules.authorization.read_service import ( + ProjectRoleReadResourceNotFound, + ProjectRoleReadService, +) +from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.lifecycle_schemas import ( ActorLifecycleBody, ActorLifecycleMutationResponse, @@ -74,9 +89,14 @@ AdminScope, AuthorityOperation, ServiceActorCreateRequest, + ContributorCandidateListResponse, + ProjectRole, + ProjectRoleGrantListResponse, + ProjectRoleGrantRead, derive_reason_digest, derive_service_identity_digest, ) +from app.modules.projects.repository import ProjectRepository from app.modules.authorization.service_actor_schemas import ( ServiceActorProvisionBody, ServiceActorProvisionResponse, @@ -104,6 +124,30 @@ def _actor_resource_not_found() -> StructuredHTTPException: return _domain_error(404, "actor_resource_not_found", "Actor resource not found") +def _project_role_resource_not_found() -> StructuredHTTPException: + return _domain_error( + 404, + "project_authorization_resource_not_found", + "Project authorization resource not found", + ) + + +def _project_role_read_service( + request: Request, + session: AsyncSession, + authorization: AuthorizationService, +) -> ProjectRoleReadService: + secret = request.app.state.settings.pagination_cursor_hmac_secret + if secret is None: + raise service_unavailable_error() + return ProjectRoleReadService( + authorization, + ActorRepository(session), + AdminAuthorizationRepository(session), + AuthorizationReadCursorCodec(decode_pagination_cursor_hmac_secret(secret)), + ) + + def _scope_resource_id(scope_type: AdminScope, project_id: UUID | None): if scope_type is AdminScope.SYSTEM and project_id is None: return "workstream:admin_role_grants" @@ -1124,3 +1168,100 @@ async def revoke_admin_role_grant( except SQLAlchemyError as exc: await session.rollback() raise service_unavailable_error() from exc + + +async def _canonical_project(session: AsyncSession, project_id: UUID): + project = await ProjectRepository(session).get_project(str(project_id)) + if project is None: + raise _project_role_resource_not_found() + return project + + +@router.get( + "/projects/{project_id}/contributor-candidates", + response_model=ContributorCandidateListResponse, + dependencies=[Depends(enforce_human_authorization_read)], + openapi_extra={ + "x-workstream-action-id": ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST.value + }, +) +async def list_project_contributor_candidates( + project_id: UUID, + request: Request, + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], + limit: Annotated[int, Query(ge=1, le=100)] = 50, + cursor: Annotated[str | None, Query(max_length=512)] = None, +) -> ContributorCandidateListResponse: + """List privacy-safe contributor candidates for one canonical project.""" + project = await _canonical_project(session, project_id) + service = _project_role_read_service(request, session, authorization) + try: + return await service.list_contributor_candidates( + project=project, + caller_actor_profile_id=UUID(resolved.profile.id), + limit=limit, + cursor=cursor, + ) + except InvalidPaginationCursor as exc: + raise _domain_error(400, "invalid_cursor", "Invalid cursor") from exc + + +@router.get( + "/projects/{project_id}/role-grants", + response_model=ProjectRoleGrantListResponse, + dependencies=[Depends(enforce_human_authorization_read)], + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_ROLE_GRANT_LIST.value}, +) +async def list_project_role_grants( + project_id: UUID, + request: Request, + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], + status_filter: Annotated[ + Literal["active", "revoked"] | None, + Query(alias="status"), + ] = None, + role: Annotated[ProjectRole | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 50, + cursor: Annotated[str | None, Query(max_length=512)] = None, +) -> ProjectRoleGrantListResponse: + """List immutable project-role grant history without a count.""" + project = await _canonical_project(session, project_id) + service = _project_role_read_service(request, session, authorization) + try: + return await service.list_project_role_grants( + project=project, + status=status_filter, + role=role, + limit=limit, + cursor=cursor, + ) + except InvalidPaginationCursor as exc: + raise _domain_error(400, "invalid_cursor", "Invalid cursor") from exc + + +@router.get( + "/projects/{project_id}/role-grants/{grant_id}", + response_model=ProjectRoleGrantRead, + dependencies=[Depends(enforce_human_authorization_read)], + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_ROLE_GRANT_READ.value}, +) +async def read_project_role_grant( + project_id: UUID, + grant_id: UUID, + request: Request, + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ProjectRoleGrantRead: + """Read one grant only through its canonical project relationship.""" + project = await _canonical_project(session, project_id) + service = _project_role_read_service(request, session, authorization) + try: + return await service.read_project_role_grant( + project=project, + grant_id=grant_id, + ) + except ProjectRoleReadResourceNotFound as exc: + raise _project_role_resource_not_found() from exc diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 0fb69afa..51d179c5 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -287,6 +287,48 @@ class ServiceActorProvisionResourceContext(BaseModel): resource_id: ServiceIdentity +class ProjectContributorCandidateCollectionResourceContext(BaseModel): + """Canonical project facts for privacy-safe candidate discovery.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_contributor_candidate_collection"] + resource_id: UUID + scope_project_id: UUID + project_status: Literal["draft", "active", "paused", "archived"] + + @model_validator(mode="after") + def bind_project(self): + if self.resource_id != self.scope_project_id: + raise ValueError("invalid candidate project scope") + return self + + +class ProjectRoleGrantCollectionResourceContext(BaseModel): + """Canonical project facts for one grant-history collection.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_role_grant_collection"] + resource_id: UUID + scope_project_id: UUID + project_status: Literal["draft", "active", "paused", "archived"] + + @model_validator(mode="after") + def bind_project(self): + if self.resource_id != self.scope_project_id: + raise ValueError("invalid project-role grant scope") + return self + + +class ProjectRoleGrantReadResourceContext(BaseModel): + """Canonical project and grant identifiers for one history read.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_role_grant"] + resource_id: UUID + scope_project_id: UUID + project_status: Literal["draft", "active", "paused", "archived"] + + AuthorizationResourceContext = ( ActorSelfResourceContext | ActorProfileAdminReadResourceContext @@ -301,6 +343,9 @@ class ServiceActorProvisionResourceContext(BaseModel): | AdminRoleGrantIssueResourceContext | AdminRoleGrantResourceContext | ServiceActorProvisionResourceContext + | ProjectContributorCandidateCollectionResourceContext + | ProjectRoleGrantCollectionResourceContext + | ProjectRoleGrantReadResourceContext ) @@ -357,6 +402,9 @@ class AuthorizationDecision(BaseModel): "admin_role_grant_issue", "admin_role_grant", "service_actor_provisioning", + "project_contributor_candidate_collection", + "project_role_grant_collection", + "project_role_grant", ] resource_id: ( UUID diff --git a/backend/app/modules/authorization/schemas.py b/backend/app/modules/authorization/schemas.py index cfe17c0f..f7c017bc 100644 --- a/backend/app/modules/authorization/schemas.py +++ b/backend/app/modules/authorization/schemas.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from enum import StrEnum import json import re @@ -68,6 +69,66 @@ class ProjectRole(StrEnum): ADJUDICATOR = "adjudicator" +class ContributorCandidateRead(BaseModel): + """Minimal contributor candidate disclosed to one authorized manager.""" + + model_config = _MODEL_CONFIG + actor_profile_id: UUID + display_name: str | None + + +class ContributorCandidateListResponse(BaseModel): + """Count-free keyset page of privacy-safe candidates.""" + + model_config = _MODEL_CONFIG + items: list[ContributorCandidateRead] + next_cursor: str | None + + +class ProjectRoleQualificationSnapshotRead(BaseModel): + """Strict bounded qualification evidence attached to one grant.""" + + model_config = _MODEL_CONFIG + id: UUID + requested_role: ProjectRole + skills_snapshot: QualificationAvailabilitySnapshot + reputation_snapshot: QualificationAvailabilitySnapshot + prior_project_work_refs: list[UUID] + external_expertise_refs: list[ReferenceToken] + captured_by_actor_profile_id: UUID + captured_by_admin_role_grant_id: UUID + captured_at: datetime + + +class ProjectRoleGrantRead(BaseModel): + """Exact privacy-bounded project-role grant history shape.""" + + model_config = _MODEL_CONFIG + id: UUID + project_id: UUID + actor_profile_id: UUID + role: ProjectRole + status: Literal["active", "revoked"] + version: Literal[1, 2] + grant_method: Literal["manual"] + qualification_snapshot: ProjectRoleQualificationSnapshotRead + granted_by_actor_profile_id: UUID + granted_by_admin_role_grant_id: UUID + granted_at: datetime + grant_reason: str + revoked_by_actor_profile_id: UUID | None + revoked_at: datetime | None + revoked_reason: str | None + + +class ProjectRoleGrantListResponse(BaseModel): + """Count-free keyset page of project-role grant history.""" + + model_config = _MODEL_CONFIG + items: list[ProjectRoleGrantRead] + next_cursor: str | None + + class QualificationAvailability(StrEnum): """Whether one qualification source produced bounded references.""" diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index e61c5cad..60dfc298 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -21,7 +21,7 @@ from alembic import command from alembic.config import Config from pydantic import SecretStr -from sqlalchemy import text +from sqlalchemy import select, text from app.db import session as db_session from app.modules.api_controls.service import ( @@ -29,6 +29,11 @@ RateControlService, rate_key_digest, ) +from app.modules.authorization.models import ( + AdminRoleGrant, + ProjectRoleGrant, + ProjectRoleQualificationSnapshot, +) from app.modules.projects.models import PostSubmitCheckerPolicy, ProjectSetupRun from app.modules.projects.post_submit_policy import ( build_project_post_submit_checker_spec, @@ -228,6 +233,10 @@ def api_environment() -> dict[str, str]: "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", base64.b64encode(os.urandom(32)).decode("ascii"), ) + env.setdefault( + "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET", + base64.b64encode(os.urandom(32)).decode("ascii"), + ) env["PYTHONPATH"] = str(project_root()) return env @@ -280,6 +289,65 @@ async def exercise_rate_control_contract(env: dict[str, str]) -> None: await session.commit() +async def seed_project_role_read_contract( + *, + project_id: str, + actor_profile_id: str, + manager_actor_profile_id: str, +) -> str: + """Seed one bounded read fixture until AUTH-10C owns public mutations.""" + snapshot_id = uuid4() + grant_id = uuid4() + async with db_session.get_session_factory()() as session: + admin_grant_id = await session.scalar( + select(AdminRoleGrant.id).where( + AdminRoleGrant.target_actor_profile_id == manager_actor_profile_id, + AdminRoleGrant.status == "active", + ) + ) + assert admin_grant_id is not None + session.add( + ProjectRoleQualificationSnapshot( + id=snapshot_id, + project_id=project_id, + actor_profile_id=actor_profile_id, + requested_role="submitter", + skills_snapshot={ + "availability": "available", + "reference_ids": ["skill:api-contract"], + "unavailable_reason": None, + }, + reputation_snapshot={ + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + prior_project_work_refs=[], + external_expertise_refs=[], + captured_by_actor_profile_id=manager_actor_profile_id, + captured_by_admin_role_grant_id=admin_grant_id, + ) + ) + await session.flush() + session.add( + ProjectRoleGrant( + id=grant_id, + project_id=project_id, + actor_profile_id=actor_profile_id, + role="submitter", + status="active", + version=1, + grant_method="manual", + qualification_snapshot_id=snapshot_id, + granted_by_actor_profile_id=manager_actor_profile_id, + granted_by_admin_role_grant_id=admin_grant_id, + grant_reason="API contract read fixture", + ) + ) + await session.commit() + return str(grant_id) + + def start_api_server(port: int, env: dict[str, str]) -> tuple[subprocess.Popen, Path]: """Start a real uvicorn server process. @@ -899,6 +967,13 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: audience=flow_audience, secret=flow_secret, ) + project_reader_token = issue_flow_token( + f"real-api-project-reader-{run_id}", + [], + issuer=flow_issuer, + audience=flow_audience, + secret=flow_secret, + ) worker_subject = f"real-api-worker-{run_id}" worker_token = issue_flow_token( worker_subject, @@ -966,6 +1041,33 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: async with httpx.AsyncClient(base_url=base_url, timeout=10) as client: await request_json(client, "GET", "/health") await request_json(client, "GET", "/api/v1/health") + openapi = await request_json(client, "GET", "/openapi.json") + read_actions = { + path: item["get"]["x-workstream-action-id"] + for path, item in openapi["paths"].items() + if path in { + "/api/v1/projects/{project_id}/contributor-candidates", + "/api/v1/projects/{project_id}/role-grants", + "/api/v1/projects/{project_id}/role-grants/{grant_id}", + } + } + assert read_actions == { + "/api/v1/projects/{project_id}/contributor-candidates": ( + "project.contributor_candidate.list" + ), + "/api/v1/projects/{project_id}/role-grants": "project_role_grant.list", + "/api/v1/projects/{project_id}/role-grants/{grant_id}": ( + "project_role_grant.read" + ), + } + assert not any("authorization-context" in path for path in openapi["paths"]) + assert not any( + operation.get("x-workstream-action-id") + in {"project_role_grant.issue", "project_role_grant.revoke"} + for path_item in openapi["paths"].values() + for method, operation in path_item.items() + if method in {"get", "post", "put", "patch", "delete"} + ) await request_json(client, "GET", "/api/v1/auth/me", expected_status=401) await request_json(client, "GET", "/api/v1/auth/me", invalid_token, expected_status=401) await request_json(client, "GET", "/api/v1/auth/me", wrong_issuer_token, expected_status=401) @@ -989,6 +1091,12 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: ) assert bootstrap_code == 0 assert bootstrap["result_code"] == "bootstrapped" + project_reader_profile = await request_json( + client, + "GET", + "/api/v1/actors/me", + project_reader_token, + ) service_payload = { "service_identity": "workstream.artifact.verifier", "subject": f"real-api-artifact-verifier-{run_id}", @@ -1200,6 +1308,48 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: 201, ) await request_json(client, "GET", f"/api/v1/projects/{project['id']}", manager_token) + project_manager_grant = await client.post( + "/api/v1/admin-role-grants", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": project_reader_profile["actor_profile_id"], + "role": "project_manager", + "scope_type": "project", + "scope_project_id": project["id"], + "reason": "Real API project-role read authority proof", + }, + ) + assert project_manager_grant.status_code == 201, project_manager_grant.text + candidates = await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}/contributor-candidates?limit=1", + project_reader_token, + ) + assert set(candidates) == {"items", "next_cursor"} + grants = await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}/role-grants?limit=1", + project_reader_token, + ) + assert grants == {"items": [], "next_cursor": None} + missing_grant = await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}/role-grants/{uuid4()}", + project_reader_token, + expected_status=404, + ) + missing_project = await request_json( + client, + "GET", + f"/api/v1/projects/{uuid4()}/role-grants/{uuid4()}", + project_reader_token, + expected_status=404, + ) + assert missing_grant["error"]["code"] == missing_project["error"]["code"] + assert missing_grant["error"]["message"] == missing_project["error"]["message"] guide = await request_json( client, @@ -1357,6 +1507,52 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert worker_profile["external_issuer"] == flow_issuer assert worker_profile["status"] == "active" assert set(worker_profile["skill_tags"]) == {"stem", "proofs"} + role_grant_id = await seed_project_role_read_contract( + project_id=project["id"], + actor_profile_id=canonical_actor["actor_profile_id"], + manager_actor_profile_id=manager_profile["actor_profile_id"], + ) + candidates = await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}/contributor-candidates?limit=100", + project_reader_token, + ) + assert {"actor_profile_id", "display_name"} == set(candidates["items"][0]) + grants = await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}/role-grants?status=active&role=submitter", + project_reader_token, + ) + assert [item["id"] for item in grants["items"]] == [role_grant_id] + grant = await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}", + project_reader_token, + ) + assert set(grant) == { + "id", "project_id", "actor_profile_id", "role", "status", "version", + "grant_method", "qualification_snapshot", "granted_by_actor_profile_id", + "granted_by_admin_role_grant_id", "granted_at", "grant_reason", + "revoked_by_actor_profile_id", "revoked_at", "revoked_reason", + } + assert set(grant["qualification_snapshot"]) == { + "id", "requested_role", "skills_snapshot", "reputation_snapshot", + "prior_project_work_refs", "external_expertise_refs", + "captured_by_actor_profile_id", "captured_by_admin_role_grant_id", + "captured_at", + } + assert set(grant["qualification_snapshot"]["skills_snapshot"]) == { + "availability", "reference_ids", "unavailable_reason", + } + assert set(grant["qualification_snapshot"]["reputation_snapshot"]) == { + "availability", "reference_ids", "unavailable_reason", + } + assert grant["revoked_by_actor_profile_id"] is None + assert grant["revoked_at"] is None + assert grant["revoked_reason"] is None await request_json(client, "GET", f"/api/v1/tasks/{task['id']}", worker_token) ready_work_context = await request_json( client, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4bc8d6d2..19f99f99 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable, Iterator from contextlib import AbstractContextManager, contextmanager import asyncio +import base64 import fcntl from functools import partial import hashlib @@ -87,6 +88,19 @@ TestDatabaseReset = Callable[..., Awaitable[None]] DatabaseLock = Callable[[], AbstractContextManager[None]] ResetHook = Callable[[], Awaitable[None]] +PAGINATION_CURSOR_HMAC_SECRET = base64.b64encode(bytes(range(32))).decode("ascii") + + +@pytest.fixture(autouse=True) +def pagination_cursor_hmac_secret(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Provision the required cursor key explicitly for isolated test apps.""" + monkeypatch.setenv( + "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET", + PAGINATION_CURSOR_HMAC_SECRET, + ) + get_settings.cache_clear() + yield + get_settings.cache_clear() async def _assert_owned_test_database( diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 67a86308..bc8b8759 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -9,13 +9,13 @@ from pathlib import Path import time from typing import cast -from uuid import uuid4 +from uuid import UUID, uuid4 from alembic.config import Config from httpx import ASGITransport, AsyncClient from pydantic import ValidationError import pytest -from sqlalchemy import func, select, text +from sqlalchemy import UniqueConstraint, event, func, select, text from sqlalchemy.exc import SQLAlchemyError from app.api.deps.auth import get_auth_verification_result @@ -63,6 +63,111 @@ RATE_SECRET = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +def test_candidate_exists_query_is_backed_by_one_link_per_profile_constraint() -> None: + assert any( + isinstance(constraint, UniqueConstraint) + and {column.name for column in constraint.columns} == {"actor_profile_id"} + for constraint in ActorIdentityLink.__table__.constraints + ) + + +@pytest.mark.asyncio +async def test_contributor_candidate_query_filters_and_paginates_without_gaps( + actor_database_env: str, +) -> None: + created_at = datetime(2026, 7, 22, tzinfo=UTC) + caller_id = uuid4() + eligible_ids = sorted((uuid4(), uuid4()), key=str) + inactive_id = uuid4() + revoked_id = uuid4() + service_id = uuid4() + async with db_session.get_session_factory()() as session: + profiles = [ + ActorProfile( + id=str(actor_id), + actor_kind="human", + status=("suspended" if actor_id == inactive_id else "active"), + provisioning_method="automatic_first_access", + created_by=str(actor_id), + created_at=created_at, + suspended_by=(str(caller_id) if actor_id == inactive_id else None), + suspended_at=(created_at if actor_id == inactive_id else None), + suspension_reason=("test hold" if actor_id == inactive_id else None), + ) + for actor_id in [caller_id, *eligible_ids, inactive_id, revoked_id] + ] + session.add_all(profiles) + session.add( + ActorProfile( + id=str(service_id), + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + created_by=str(caller_id), + created_at=created_at, + ) + ) + await session.flush() + session.add_all( + [ + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=str(actor_id), + issuer=ISSUER, + subject=f"candidate-{actor_id}", + subject_kind="human", + status=("revoked" if actor_id == revoked_id else "active"), + linked_by=str(actor_id), + last_verified_at=created_at, + revoked_by=(str(caller_id) if actor_id == revoked_id else None), + revoked_at=(created_at if actor_id == revoked_id else None), + revoked_reason=("test revoke" if actor_id == revoked_id else None), + ) + for actor_id in [caller_id, *eligible_ids, inactive_id, revoked_id] + ] + ) + session.add( + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=str(service_id), + issuer=ISSUER, + subject=f"candidate-service-{service_id}", + subject_kind="service", + status="active", + linked_by=str(caller_id), + last_verified_at=None, + ) + ) + await session.commit() + + repository = ActorRepository(session) + statements: list[str] = [] + + def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany): + statements.append(statement) + + engine = db_session.get_engine().sync_engine + event.listen(engine, "before_cursor_execute", record_sql) + try: + first = await repository.list_contributor_candidates( + caller_actor_profile_id=caller_id, + cursor=None, + limit=1, + ) + second = await repository.list_contributor_candidates( + caller_actor_profile_id=caller_id, + cursor=(first[0].created_at, UUID(first[0].id)), + limit=1, + ) + finally: + event.remove(engine, "before_cursor_execute", record_sql) + assert [row.id for row in first] == [str(eligible_ids[0]), str(eligible_ids[1])] + assert [row.id for row in second] == [str(eligible_ids[1])] + assert all("count(" not in statement.lower() for statement in statements) + assert all(str(service_id) != row.id for row in first) + + def test_actor_admin_response_requires_exact_service_identity_pair() -> None: now = datetime.now(UTC) common = { diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 74123b6a..9f8121fe 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -439,13 +439,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: for method, operation in path_item.items() if method in methods and operation.get("security") ) - assert len(route_inventory) == 71 + assert len(route_inventory) == 74 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "a986e2e29f87b06b47d88063a240a7ef2dfe3a9ed9457e17b1b3b945fedb42a3" + "f0076e4145d8fe68365912a6e5bc047dece3ae2ac1e69619018b5119a9710c09" ) - assert len(protected_inventory) == 69 + assert len(protected_inventory) == 72 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "b8574eeaf9a216b65d4c12fb470e0b5835186e0e2f16ce750f8859dd79d71de9" + "b05ebbca27a538958af6c122403ccf3997ab08326943150f6c763ce9c321eec5" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( @@ -495,7 +495,35 @@ def test_openapi_documents_request_error_and_response_context() -> None: "actor.admin_role_grant_history.read" ), "POST /api/v1/admin-role-grants/{grant_id}/revoke": "admin_role_grant.revoke", + "GET /api/v1/projects/{project_id}/contributor-candidates": ( + "project.contributor_candidate.list" + ), + "GET /api/v1/projects/{project_id}/role-grants": "project_role_grant.list", + "GET /api/v1/projects/{project_id}/role-grants/{grant_id}": ( + "project_role_grant.read" + ), + } + project_read_shapes = { + "/api/v1/projects/{project_id}/contributor-candidates": ( + "ContributorCandidateListResponse" + ), + "/api/v1/projects/{project_id}/role-grants": "ProjectRoleGrantListResponse", + "/api/v1/projects/{project_id}/role-grants/{grant_id}": "ProjectRoleGrantRead", } + for path, schema_name in project_read_shapes.items(): + operation = schema["paths"][path]["get"] + assert operation["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": f"#/components/schemas/{schema_name}" + } + assert schema["components"]["schemas"][schema_name]["additionalProperties"] is False + assert not any("authorization-context" in path for path in schema["paths"]) + assert not any( + operation.get("x-workstream-action-id") + in {"project_role_grant.issue", "project_role_grant.revoke"} + for path_item in schema["paths"].values() + for method, operation in path_item.items() + if method in methods + ) for path, schema_name in ( ("/api/v1/actors/{actor_profile_id}", "ActorProfileAdminResponse"), ( diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index c572a267..a2094793 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -177,6 +177,9 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ACTOR_PROFILE_DEACTIVATE, ActionId.ACTOR_IDENTITY_LINK_REVOKE, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, } with pytest.raises(TypeError, match="invalid authority audit input"): _authority_input( diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 50778e64..e33acb22 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -12,12 +12,15 @@ from dataclasses import replace from datetime import UTC, datetime import inspect +import hashlib +import hmac import json import pickle from pathlib import Path from types import SimpleNamespace from uuid import UUID, uuid4 +from httpx import ASGITransport, AsyncClient import pytest # type: ignore[import-not-found] from pydantic import ValidationError # type: ignore[import-not-found] from sqlalchemy import text @@ -29,12 +32,18 @@ from starlette.requests import Request from app.api.deps.authorization import ( + authorization_http_error, get_authorization_actor, get_authorization_service, get_prepared_authorization_service, ) +from app.api.deps.api_controls import enforce_authorization_read_rate_limit +from app.api.deps.auth import get_auth_verification_result from app.core.api_controls import StructuredHTTPException -from app.core.config import get_settings +from app.core.config import Settings, get_settings +from app.core.hashing import canonical_json_hash +from app.main import create_app +from app.modules.projects.repository import ProjectRepository from app.modules.audit.schemas import ( ActorReferenceKind, AuthorityAuditEventInput, @@ -60,6 +69,12 @@ IdentityLinkLifecycleService, ) from app.modules.authorization.models import AdminRoleGrant +from app.modules.authorization.pagination import ( + AuthorizationReadCursorCodec, + InvalidPaginationCursor, + authorization_read_query_digest, +) +from app.modules.authorization.read_service import ProjectRoleReadService from app.modules.authorization.catalogue import ( ACTION_BY_ID, ACTION_DEFINITIONS, @@ -151,6 +166,9 @@ PreparedAuthorityScopeKind, MatchedAuthorityKind, PermissionCatalogueResourceContext, + ProjectContributorCandidateCollectionResourceContext, + ProjectRoleGrantCollectionResourceContext, + ProjectRoleGrantReadResourceContext, ServiceActorProvisionResourceContext, ServiceAuthorizationContext, SystemResourceContext, @@ -164,6 +182,402 @@ DIGEST = "sha256:" + "a" * 64 + +def test_authorization_read_cursor_round_trip_and_query_binding() -> None: + codec = AuthorizationReadCursorCodec(bytes(range(32))) + project_id = UUID("00000000-0000-4000-8000-000000000001") + resource_id = UUID("00000000-0000-4000-8000-000000000002") + boundary = datetime(2026, 7, 22, 1, 2, 3, 456789, tzinfo=UTC) + digest = authorization_read_query_digest( + action_id=ActionId.PROJECT_ROLE_GRANT_LIST, + project_id=project_id, + status="active", + role=ProjectRole.REVIEWER, + limit=50, + ) + + cursor = codec.encode(query_digest=digest, timestamp=boundary, resource_id=resource_id) + + assert len(cursor) <= 512 + assert codec.decode(cursor, query_digest=digest) == (boundary, resource_id) + replay_digest = authorization_read_query_digest( + action_id=ActionId.PROJECT_ROLE_GRANT_LIST, + project_id=project_id, + status="revoked", + role=ProjectRole.REVIEWER, + limit=50, + ) + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + codec.decode(cursor, query_digest=replay_digest) + + +@pytest.mark.parametrize( + "value", + ["", "=", "a===", "*", "a" * 513], +) +def test_authorization_read_cursor_rejects_malformed_encoding(value: str) -> None: + codec = AuthorizationReadCursorCodec(bytes(range(32))) + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + codec.decode(value, query_digest=DIGEST) + + +def test_authorization_read_cursor_rejects_tampering_and_wrong_key() -> None: + digest = authorization_read_query_digest( + action_id=ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + project_id=UUID("00000000-0000-4000-8000-000000000001"), + limit=10, + ) + codec = AuthorizationReadCursorCodec(bytes(range(32))) + cursor = codec.encode( + query_digest=digest, + timestamp=datetime(2026, 7, 22, tzinfo=UTC), + resource_id=UUID("00000000-0000-4000-8000-000000000002"), + ) + tampered = cursor[:-1] + ("A" if cursor[-1] != "A" else "B") + + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + codec.decode(tampered, query_digest=digest) + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + AuthorizationReadCursorCodec(bytes(reversed(range(32)))).decode( + cursor, + query_digest=digest, + ) + + +def _signed_cursor_envelope(envelope: dict, *, secret: bytes = bytes(range(32))) -> str: + payload = envelope["p"] + payload_bytes = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ).encode() + envelope["s"] = base64.urlsafe_b64encode( + hmac.new(secret, payload_bytes, hashlib.sha256).digest() + ).decode().rstrip("=") + return base64.urlsafe_b64encode( + json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode() + ).decode().rstrip("=") + + +@pytest.mark.parametrize( + "payload_update,envelope_update", + [ + ({"v": 2}, {}), + ({"v": "1"}, {}), + ({"extra": True}, {}), + ({"ts": "2026-07-22T00:00:00Z"}, {}), + ({"ts": "2026-07-22T00:00:00.000000+00:00"}, {}), + ({"id": "ABCDEF00-0000-4000-8000-000000000002"}, {}), + ({"id": 2}, {}), + ({}, {"extra": True}), + ], +) +def test_authorization_read_cursor_rejects_strict_payload_variants( + payload_update: dict, + envelope_update: dict, +) -> None: + payload = { + "v": 1, + "q": DIGEST, + "ts": "2026-07-22T00:00:00.000000Z", + "id": "00000000-0000-4000-8000-000000000002", + } + payload.update(payload_update) + envelope = {"p": payload, "s": ""} + envelope.update(envelope_update) + cursor = _signed_cursor_envelope(envelope) + + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + AuthorizationReadCursorCodec(bytes(range(32))).decode( + cursor, + query_digest=DIGEST, + ) + + +@pytest.mark.parametrize("missing_key", ["v", "q", "ts", "id"]) +def test_authorization_read_cursor_rejects_missing_payload_keys(missing_key: str) -> None: + payload = { + "v": 1, + "q": DIGEST, + "ts": "2026-07-22T00:00:00.000000Z", + "id": "00000000-0000-4000-8000-000000000002", + } + del payload[missing_key] + cursor = _signed_cursor_envelope({"p": payload, "s": ""}) + + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + AuthorizationReadCursorCodec(bytes(range(32))).decode( + cursor, + query_digest=DIGEST, + ) + + +def test_authorization_read_cursor_rejects_oversized_decoded_value() -> None: + value = base64.urlsafe_b64encode(b"{" + b" " * 383 + b"}").decode().rstrip("=") + assert len(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))) > 384 + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + AuthorizationReadCursorCodec(bytes(range(32))).decode( + value, + query_digest=DIGEST, + ) + + +def test_authorization_read_cursor_rejects_missing_envelope_and_duplicate_keys() -> None: + missing_signature = base64.urlsafe_b64encode( + json.dumps( + { + "p": { + "v": 1, + "q": DIGEST, + "ts": "2026-07-22T00:00:00.000000Z", + "id": "00000000-0000-4000-8000-000000000002", + } + }, + separators=(",", ":"), + ).encode() + ).decode().rstrip("=") + duplicate = base64.urlsafe_b64encode(b'{"p":{},"p":{},"s":"x"}').decode().rstrip("=") + codec = AuthorizationReadCursorCodec(bytes(range(32))) + for value in (missing_signature, duplicate): + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + codec.decode(value, query_digest=DIGEST) + + +@pytest.mark.parametrize( + "digest_kwargs", + [ + {"action_id": ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST}, + {"project_id": UUID("00000000-0000-4000-8000-000000000099")}, + {"status": "revoked"}, + {"role": ProjectRole.ADJUDICATOR}, + {"limit": 51}, + ], +) +def test_authorization_read_cursor_rejects_cross_query_replay( + digest_kwargs: dict, +) -> None: + project_id = UUID("00000000-0000-4000-8000-000000000001") + baseline = { + "action_id": ActionId.PROJECT_ROLE_GRANT_LIST, + "project_id": project_id, + "status": "active", + "role": ProjectRole.REVIEWER, + "limit": 50, + } + digest = authorization_read_query_digest(**baseline) + cursor = AuthorizationReadCursorCodec(bytes(range(32))).encode( + query_digest=digest, + timestamp=datetime(2026, 7, 22, tzinfo=UTC), + resource_id=UUID("00000000-0000-4000-8000-000000000002"), + ) + baseline.update(digest_kwargs) + + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + AuthorizationReadCursorCodec(bytes(range(32))).decode( + cursor, + query_digest=authorization_read_query_digest(**baseline), + ) + + +def test_authorization_read_cursor_rejects_cross_order_replay() -> None: + project_id = UUID("00000000-0000-4000-8000-000000000001") + alternate_order_digest = canonical_json_hash( + { + "action_id": ActionId.PROJECT_ROLE_GRANT_LIST.value, + "limit": 50, + "order": "timestamp_uuid_desc", + "project_id": str(project_id), + "role": None, + "status": None, + } + ) + cursor = AuthorizationReadCursorCodec(bytes(range(32))).encode( + query_digest=alternate_order_digest, + timestamp=datetime(2026, 7, 22, tzinfo=UTC), + resource_id=UUID("00000000-0000-4000-8000-000000000002"), + ) + canonical_digest = authorization_read_query_digest( + action_id=ActionId.PROJECT_ROLE_GRANT_LIST, + project_id=project_id, + limit=50, + ) + + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + AuthorizationReadCursorCodec(bytes(range(32))).decode( + cursor, + query_digest=canonical_digest, + ) + + +@pytest.mark.asyncio +async def test_invalid_cursor_is_rejected_after_authorization_before_grant_sql() -> None: + calls: list[str] = [] + + class Authorization: + async def require(self, *_args) -> SimpleNamespace: + calls.append("authorization") + return SimpleNamespace(allowed=True) + + class Grants: + async def list_project_role_grants(self, **_kwargs): + calls.append("grant_sql") + raise AssertionError("grant SQL must not run") + + service = ProjectRoleReadService( + Authorization(), # type: ignore[arg-type] + SimpleNamespace(), # type: ignore[arg-type] + Grants(), # type: ignore[arg-type] + AuthorizationReadCursorCodec(bytes(range(32))), + ) + with pytest.raises(InvalidPaginationCursor, match="invalid cursor"): + await service.list_project_role_grants( + project=SimpleNamespace( + id="00000000-0000-4000-8000-000000000001", + status="active", + ), + status=None, + role=None, + limit=50, + cursor="forged", + ) + assert calls == ["authorization"] + + +@pytest.mark.asyncio +async def test_candidate_service_cursor_uses_last_visible_equal_timestamp_boundary() -> None: + created_at = datetime(2026, 7, 22, tzinfo=UTC) + ids = [UUID(f"00000000-0000-4000-8000-{index:012d}") for index in range(1, 4)] + observed: list[tuple[datetime, UUID] | None] = [] + + class Authorization: + async def require(self, *_args) -> SimpleNamespace: + return SimpleNamespace(allowed=True) + + class Actors: + async def list_contributor_candidates(self, *, cursor, **_kwargs): + observed.append(cursor) + start = 0 if cursor is None else ids.index(cursor[1]) + 1 + return [ + SimpleNamespace(id=str(value), display_name=None, created_at=created_at) + for value in ids[start : start + 3] + ] + + codec = AuthorizationReadCursorCodec(bytes(range(32))) + service = ProjectRoleReadService( + Authorization(), # type: ignore[arg-type] + Actors(), # type: ignore[arg-type] + SimpleNamespace(), # type: ignore[arg-type] + codec, + ) + project = SimpleNamespace(id=str(uuid4()), status="active") + first = await service.list_contributor_candidates( + project=project, + caller_actor_profile_id=uuid4(), + limit=2, + cursor=None, + ) + assert [item.actor_profile_id for item in first.items] == ids[:2] + assert first.next_cursor is not None + second = await service.list_contributor_candidates( + project=project, + caller_actor_profile_id=uuid4(), + limit=2, + cursor=first.next_cursor, + ) + assert [item.actor_profile_id for item in second.items] == ids[2:] + assert second.next_cursor is None + assert observed == [None, (created_at, ids[1])] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code", [429, 503]) +async def test_authorization_read_rate_failure_precedes_project_lookup( + monkeypatch: pytest.MonkeyPatch, + status_code: int, +) -> None: + app = create_app(Settings(environment="test")) + lookups = 0 + + consumptions = 0 + + async def fail_rate_first() -> None: + nonlocal consumptions + consumptions += 1 + raise StructuredHTTPException( + status_code=status_code, + detail="rate gate failed", + error_code=("rate_limit_exceeded" if status_code == 429 else "service_unavailable"), + error_message="rate gate failed", + retryable=True, + headers=({"Retry-After": "1"} if status_code == 429 else None), + ) + + async def forbidden_project_lookup(*_args, **_kwargs): + nonlocal lookups + lookups += 1 + raise AssertionError("project lookup must not run") + + async def verified_human(): + return SimpleNamespace(token=SimpleNamespace(subject_kind="human")) + + app.dependency_overrides[enforce_authorization_read_rate_limit] = fail_rate_first + app.dependency_overrides[get_auth_verification_result] = verified_human + monkeypatch.setattr(ProjectRepository, "get_project", forbidden_project_lookup) + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get( + f"/api/v1/projects/{uuid4()}/role-grants", + headers={"Authorization": "Bearer test"}, + ) + + assert response.status_code == status_code + if status_code == 429: + assert response.headers["Retry-After"] == "1" + else: + assert response.json()["error"]["retryable"] is True + assert consumptions == 1 + assert lookups == 0 + + +@pytest.mark.asyncio +async def test_human_read_admission_conceals_every_nonhuman_kind( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for subject_kind in ("service", "agent", "space"): + app = create_app(Settings(environment="test")) + consumptions = 0 + lookups = 0 + + async def consume_once() -> None: + nonlocal consumptions + consumptions += 1 + + async def verified_nonhuman(): + return SimpleNamespace(token=SimpleNamespace(subject_kind=subject_kind)) + + async def forbidden_project_lookup(*_args, **_kwargs): + nonlocal lookups + lookups += 1 + raise AssertionError("project lookup must not run") + + app.dependency_overrides[enforce_authorization_read_rate_limit] = consume_once + app.dependency_overrides[get_auth_verification_result] = verified_nonhuman + monkeypatch.setattr(ProjectRepository, "get_project", forbidden_project_lookup) + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get(f"/api/v1/projects/{uuid4()}/role-grants") + + assert response.status_code == 404 + assert response.json()["error"]["code"] == ( + "project_authorization_resource_not_found" + ) + assert consumptions == 1 + assert lookups == 0 + ART_CUSTODY_EXPECTATIONS = { "artifact.binding.read": ( "artifact.binding.read", @@ -486,6 +900,9 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.ACTOR_PROFILE_DEACTIVATE, ActionId.ACTOR_IDENTITY_LINK_REVOKE, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, } assert { definition.action_id.value: ( @@ -563,11 +980,11 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> assert sum( definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS - ) == 17 + ) == 20 assert sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS - ) == 53 + ) == 50 assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF ) @@ -696,7 +1113,7 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - assert "The ART transfer adds no migration" in operations assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations - assert "74 PermissionIds, 70 ActionIds, 17 active actions, and\n53 planned actions" in operations + assert "74 PermissionIds, 70 ActionIds, 20 active actions, and\n50 planned actions" in operations def test_rev_custody_documentation_matches_the_independent_catalogue_fixture() -> None: @@ -1560,6 +1977,132 @@ async def lock_identity_link_lifecycle_target(self, identity_link_id): return SimpleNamespace(id=identity_link_id) +@pytest.mark.asyncio +async def test_project_role_reads_use_exact_scope_and_candidate_kernel_guard() -> None: + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + facts = _PreparedAdminFacts(context) + project_id = uuid4() + service, evidence = _runtime_service(context, admin_repository=facts) + + decision = await service.require( + ActionId.PROJECT_ROLE_GRANT_LIST, + ProjectRoleGrantCollectionResourceContext( + resource_type="project_role_grant_collection", + resource_id=project_id, + scope_project_id=project_id, + project_status="archived", + ), + ) + assert decision.allowed is True + assert decision.matched_scope_project_id == project_id + assert decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT + + detail = await service.require( + ActionId.PROJECT_ROLE_GRANT_READ, + ProjectRoleGrantReadResourceContext( + resource_type="project_role_grant", + resource_id=uuid4(), + scope_project_id=project_id, + project_status="archived", + ), + ) + assert detail.allowed is True + + for project_status in ("draft", "active", "paused"): + candidate = await service.require( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ProjectContributorCandidateCollectionResourceContext( + resource_type="project_contributor_candidate_collection", + resource_id=project_id, + scope_project_id=project_id, + project_status=project_status, + ), + ) + assert candidate.allowed is True + + for project_status in ("draft", "active", "paused", "archived"): + history = await service.require( + ActionId.PROJECT_ROLE_GRANT_LIST, + ProjectRoleGrantCollectionResourceContext( + resource_type="project_role_grant_collection", + resource_id=project_id, + scope_project_id=project_id, + project_status=project_status, + ), + ) + assert history.allowed is True + + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ProjectContributorCandidateCollectionResourceContext( + resource_type="project_contributor_candidate_collection", + resource_id=project_id, + scope_project_id=project_id, + project_status="archived", + ), + ) + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED + assert evidence.events[-1].denial_code == "resource_guard_denied" + + +@pytest.mark.parametrize( + "action_id", + [ + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + ], +) +@pytest.mark.parametrize( + "denial_code", + [ + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED, + AuthorizationDenialCode.RESOURCE_GUARD_DENIED, + ], +) +def test_project_role_read_denials_share_one_public_concealment( + action_id: ActionId, + denial_code: AuthorizationDenialCode, +) -> None: + decision = AuthorizationDecision( + decision_id=uuid4(), + allowed=False, + action_id=action_id, + permission_id=ACTION_BY_ID[action_id].permission_id, + resource_type="project_role_grant_collection", + resource_id=uuid4(), + resource_context_digest=DIGEST, + denial_code=denial_code, + matched_authority_kind=None, + matched_grant_id=None, + matched_scope_project_id=None, + revalidated=False, + request_id=uuid4(), + correlation_id=uuid4(), + ) + translated = authorization_http_error(AuthorizationDenied(decision)) + assert translated.status_code == 404 + assert translated.error_code == "project_authorization_resource_not_found" + + +def test_project_role_read_permissions_separate_manager_and_auditor_authority() -> None: + assert PermissionId.PROJECT_ROLE_GRANT_READ in ADMIN_ROLE_PERMISSIONS[ + AdminRole.PROJECT_MANAGER + ] + assert PermissionId.PROJECT_ROLE_GRANT_MANAGE in ADMIN_ROLE_PERMISSIONS[ + AdminRole.PROJECT_MANAGER + ] + assert PermissionId.PROJECT_ROLE_GRANT_READ in ADMIN_ROLE_PERMISSIONS[ + AdminRole.AUDIT_AUTHORITY + ] + assert PermissionId.PROJECT_ROLE_GRANT_MANAGE not in ADMIN_ROLE_PERMISSIONS[ + AdminRole.AUDIT_AUTHORITY + ] + + @pytest.mark.asyncio async def test_prepared_actor_self_handle_is_exact_single_use_and_transaction_bound(): context = _runtime_context() diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index ce8628ca..7ed69748 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -17,7 +17,11 @@ from app.adapters.auth.flow import FlowAuthVerifier from app.api.deps.auth import get_application_auth_verifier from app.core.auth import clear_auth_verifier_cache, get_auth_verifier -from app.core.config import Settings, get_settings +from app.core.config import ( + Settings, + decode_pagination_cursor_hmac_secret, + get_settings, +) from app.core.s3_validation import ( canonical_minio_endpoint, is_canonical_s3_region, @@ -68,6 +72,35 @@ def test_rate_limit_secret_is_canonical_and_redacted() -> None: assert "api_rate_limit_key_secret" not in settings.model_dump() +def test_pagination_cursor_secret_is_exact_and_redacted() -> None: + encoded = base64.b64encode(bytes(range(32))).decode("ascii") + settings = Settings(pagination_cursor_hmac_secret=encoded) + + assert settings.pagination_cursor_hmac_secret is not None + assert decode_pagination_cursor_hmac_secret(settings.pagination_cursor_hmac_secret) == bytes( + range(32) + ) + assert encoded not in repr(settings) + assert "pagination_cursor_hmac_secret" not in settings.model_dump() + + +@pytest.mark.parametrize( + "value", + ["not-base64", base64.b64encode(bytes(31)).decode(), base64.b64encode(bytes(33)).decode()], +) +def test_pagination_cursor_secret_rejects_noncanonical_lengths(value: str) -> None: + with pytest.raises(ValueError, match="invalid pagination cursor HMAC secret"): + Settings(pagination_cursor_hmac_secret=value) + + +@pytest.mark.asyncio +async def test_pagination_cursor_secret_is_required_at_startup() -> None: + app = create_app(Settings(environment="test", pagination_cursor_hmac_secret=None)) + with pytest.raises(RuntimeError, match="pagination cursor HMAC secret is required"): + async with app.router.lifespan_context(app): + pass + + def test_rate_limit_secret_loads_from_environment_and_dotenv( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 1b8e7586..777f5203 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -13,7 +13,7 @@ import pytest # type: ignore[import-not-found] from httpx import ASGITransport, AsyncClient -from sqlalchemy import select, update +from sqlalchemy import event, select, update from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError from sqlalchemy.schema import CreateIndex @@ -50,12 +50,20 @@ PaymentPolicy, PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, + Project, ProjectGuide, ProjectSetupRun, RevisionPolicy, ReviewPolicy, SubmissionArtifactPolicy, ) +from app.modules.authorization.models import ( + AdminRoleGrant, + AuthorityControl, + ProjectRoleGrant, + ProjectRoleQualificationSnapshot, +) +from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.projects import service as project_service_module from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.projects.service import ( @@ -119,6 +127,170 @@ def auth_headers(token: str = "project-token") -> dict[str, str]: return {"Authorization": f"Bearer {token}"} +@pytest.mark.asyncio +async def test_project_role_grant_repository_filters_and_uses_strict_keyset( + project_database_env: str, +) -> None: + project_id = uuid4() + actor_id = uuid4() + grantor_id = uuid4() + admin_grant_id = uuid4() + granted_at = datetime(2026, 7, 22, tzinfo=UTC) + grant_ids = sorted((uuid4(), uuid4(), uuid4()), key=str) + async with db_session.get_session_factory()() as session: + session.add( + Project( + id=str(project_id), + name="Authorization read project", + slug=f"authorization-read-{project_id}", + status="archived", + ) + ) + session.add_all( + [ + ActorProfile( + id=str(profile_id), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=str(profile_id), + ) + for profile_id in (actor_id, grantor_id) + ] + ) + session.add_all( + [ + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=str(profile_id), + issuer="https://identity.test", + subject=f"project-role-read-{profile_id}", + subject_kind="human", + status="active", + linked_by=str(profile_id), + last_verified_at=granted_at, + ) + for profile_id in (actor_id, grantor_id) + ] + ) + await session.flush() + session.add( + AdminRoleGrant( + id=admin_grant_id, + target_actor_profile_id=str(grantor_id), + role="access_administrator", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_system_principal="workstream:system:bootstrap", + grant_reason="test bootstrap", + ) + ) + control = await session.get(AuthorityControl, 1) + assert control is not None + control.bootstrap_completed = True + control.bootstrap_grant_id = admin_grant_id + control.version = 1 + snapshots = [] + grants = [] + for index, grant_id in enumerate(grant_ids): + role = ("submitter", "reviewer", "adjudicator")[index] + snapshot_id = uuid4() + snapshots.append( + ProjectRoleQualificationSnapshot( + id=snapshot_id, + project_id=str(project_id), + actor_profile_id=str(actor_id), + requested_role=role, + skills_snapshot={ + "availability": "available", + "reference_ids": [f"skill:{index}"], + "unavailable_reason": None, + }, + reputation_snapshot={ + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + prior_project_work_refs=[], + external_expertise_refs=[], + captured_by_actor_profile_id=str(grantor_id), + captured_by_admin_role_grant_id=admin_grant_id, + captured_at=granted_at, + ) + ) + revoked = index == 2 + grants.append( + ProjectRoleGrant( + id=grant_id, + project_id=str(project_id), + actor_profile_id=str(actor_id), + role=role, + status="revoked" if revoked else "active", + version=2 if revoked else 1, + grant_method="manual", + qualification_snapshot_id=snapshot_id, + granted_by_actor_profile_id=str(grantor_id), + granted_by_admin_role_grant_id=admin_grant_id, + grant_reason="qualified", + granted_at=granted_at, + revoked_by_actor_profile_id=(str(grantor_id) if revoked else None), + revoked_by_admin_role_grant_id=(admin_grant_id if revoked else None), + revoked_reason=("test revoke" if revoked else None), + revoked_at=(granted_at if revoked else None), + ) + ) + session.add_all(snapshots) + await session.flush() + session.add_all(grants) + await session.commit() + + repository = AdminAuthorizationRepository(session) + statements: list[str] = [] + + def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany): + statements.append(statement) + + engine = db_session.get_engine().sync_engine + event.listen(engine, "before_cursor_execute", record_sql) + try: + first = await repository.list_project_role_grants( + project_id=project_id, + status=None, + role=None, + cursor=None, + limit=1, + ) + finally: + event.remove(engine, "before_cursor_execute", record_sql) + assert all("count(" not in statement.lower() for statement in statements) + assert [row[0].id for row in first] == grant_ids[:2] + second = await repository.list_project_role_grants( + project_id=project_id, + status=None, + role=None, + cursor=(first[0][0].granted_at, grant_ids[0]), + limit=1, + ) + assert [row[0].id for row in second] == grant_ids[1:] + revoked = await repository.list_project_role_grants( + project_id=project_id, + status="revoked", + role="adjudicator", + cursor=None, + limit=10, + ) + assert [row[0].id for row in revoked] == [grant_ids[2]] + assert ( + await repository.get_project_role_grant( + project_id=uuid4(), + grant_id=grant_ids[0], + ) + is None + ) + + class DeterministicTestProjectGuideAgentRuntime: """Test-only project setup runtime used to avoid network calls.""" diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index eb8ba4f5..705cbb76 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -703,8 +703,8 @@ complete. Counts and mappings remain unchanged. The ART transfer adds no migrati The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 74 PermissionIds, 70 ActionIds, 17 active actions, and -53 planned actions after AUTH-10A registers five unavailable project-role rows. +Catalogue totals are 74 PermissionIds, 70 ActionIds, 20 active actions, and +50 planned actions after AUTH-10B2 activates the three project-role read rows. Four later REV registrations add exactly four planned and zero active actions, while the review-evidence binding registration adds exactly one planned and zero active @@ -1085,6 +1085,53 @@ later route owners must preserve the same rule. There is currently no invalidation consumer or backlog processor; the durable event is foundation evidence for the owning later chunk. +## Project Role Read Operations + +AUTH-10B2 exposes exactly three human-only, durable-rate-controlled reads: + +```text +GET /api/v1/projects/{project_id}/contributor-candidates +GET /api/v1/projects/{project_id}/role-grants +GET /api/v1/projects/{project_id}/role-grants/{grant_id} +``` + +Contributor candidates require a covered Project Manager and are available only +for draft, active, or paused projects. Each item contains only +`actor_profile_id` and nullable `display_name`. Grant list/detail require a +covered Project Manager or Audit Authority and remain readable for every project +state. List responses contain exactly `items` and `next_cursor`; no count or +total is computed or returned. Candidate pages accept `limit` 1..100 (default +50) and a cursor of at most 512 characters. Grant pages add only optional +`status=active|revoked` and `role=submitter|reviewer|adjudicator` filters. + +Each grant contains exactly `id`, `project_id`, `actor_profile_id`, `role`, +`status`, `version`, `grant_method`, `qualification_snapshot`, +`granted_by_actor_profile_id`, `granted_by_admin_role_grant_id`, `granted_at`, +`grant_reason`, and nullable `revoked_by_actor_profile_id`, `revoked_at`, and +`revoked_reason`. Its qualification snapshot contains exactly `id`, +`requested_role`, `skills_snapshot`, `reputation_snapshot`, +`prior_project_work_refs`, `external_expertise_refs`, +`captured_by_actor_profile_id`, `captured_by_admin_role_grant_id`, and +`captured_at`. Skills and reputation objects contain only `availability`, +`reference_ids`, and nullable `unavailable_reason`. + +Permission/scope denial, an ineligible candidate +project, missing project or grant, and project/grant mismatch share the same +`project_authorization_resource_not_found` 404. + +Provision `WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET` as canonical Base64 for +exactly 32 random bytes. For example, generate it with +`openssl rand -base64 32`. Missing or malformed values stop application startup. +The key is independent from authentication and rate-control secrets. Rotation +must be coordinated across all instances; replacing it invalidates every +outstanding cursor, so drain or accept bounded client pagination restarts. Never +log the key, a cursor, or distinctions hidden by the shared 404. Authorization +read exhaustion returns 429 with `Retry-After`, and unavailable rate/evidence +persistence returns retryable 503 before private row lookup. + +This read activation adds no migration and changes no PREP, project-role grant +issue/revoke, or other mutation behavior. + ## Authority Audit Custody Authority decisions and invalidation requests use the shared `audit_events` diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 04b5ebad..cf566d2b 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -238,8 +238,8 @@ approved Operator recovery identifiers, 21 artifact identifiers, and `review.queue.override` are the exact 25 post-`0020` permissions. AUTH-07A adds their matching typed/SQL audit parity without making them executable. -The closed action registry contains 70 rows after AUTH-10A: 17 active actions -and 53 planned rows. AUTH-10A adds five planned project-role read/manage rows, +The closed action registry contains 70 rows after AUTH-10B2: 20 active actions +and 50 planned rows. AUTH-10A added five project-role read/manage rows, owned by AUTH-10B and AUTH-10C. AUTH-08 adds seven active administrative definition, grant-history, issue, revoke, and local-bootstrap actions without adding a permission. AUTH-09A adds eight planned actor, identity-link, and service @@ -884,6 +884,34 @@ unattached and activates no action until AUTH-10B2. The dedicated default is 120 requests per 60 seconds per verified issuer/subject digest, independently configurable within the existing bounded limit and window ranges. +AUTH-10B2 activates only `project.contributor_candidate.list`, +`project_role_grant.list`, and `project_role_grant.read`. Their canonical targets +are the server-loaded project and, for detail, the grant joined through that +project. Candidate discovery is Project-Manager-only and permits draft, active, +and paused projects; grant history permits covered Project Manager or Audit +Authority access in every project state. Services and unsupported agent/Space +subjects are concealed before project lookup. Authorization denials, missing +resources, project/grant mismatch, and candidate lifecycle denial use one public +404 shape while bounded kernel denials retain their established audit evidence. + +Candidate pages expose exactly actor profile ID plus nullable display name. +Grant pages accept only optional active/revoked status and +submitter/reviewer/adjudicator role filters, a 1..100 limit (default 50), and a +cursor bounded to 512 characters. Each grant exposes exactly `id`, `project_id`, +`actor_profile_id`, `role`, `status`, `version`, `grant_method`, +`qualification_snapshot`, both granting actor/admin-grant identifiers, +`granted_at`, `grant_reason`, and the three present-but-nullable revocation +fields. The nested snapshot exposes exactly its ID, requested role, bounded +skills and reputation availability/reference objects, prior-project and +external-expertise references, both capturing actor/admin-grant identifiers, +and capture time. Both page +envelopes are exactly `items` and `next_cursor`, without totals. A strict signed +keyset cursor binds action, project, normalized filters, limit, ordering, +timestamp, and UUID. The required independent 32-byte Base64 cursor HMAC key +fails startup when absent or invalid; coordinated rotation invalidates all +outstanding cursors. AUTH-10B2 adds no migration and does not change PREP or +grant mutation behavior. + ## Conformance Requirements Each owning chunk must prove: