From e832d9905b39dc884db89f1306b95fd3ac6a61f4 Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Sun, 26 Jul 2026 21:10:19 +0300 Subject: [PATCH 01/14] Specify Phase 4: Provider Keys --- .opencode/commands/speckit.converge.md | 11 +- .opencode/commands/speckit.specify.md | 4 +- .opencode/commands/speckit.tasks.md | 2 +- .specify/feature.json | 2 +- .specify/integrations/opencode.manifest.json | 2 +- .specify/integrations/speckit.manifest.json | 10 +- .specify/memory/constitution.md | 80 +++-- .specify/scripts/bash/check-prerequisites.sh | 6 +- .specify/scripts/bash/setup-tasks.sh | 4 +- .specify/templates/checklist-template.md | 4 +- .specify/templates/plan-template.md | 19 +- .specify/templates/spec-template.md | 1 + .specify/templates/tasks-template.md | 12 +- CLAUDE.md | 9 +- README.md | 8 +- docs/implementation-plan.md | 33 +- .../checklists/requirements.md | 37 ++ .../contracts/provider-keys.md | 268 +++++++++++++++ specs/004-provider-keys/data-model.md | 249 ++++++++++++++ specs/004-provider-keys/plan.md | 117 +++++++ specs/004-provider-keys/quickstart.md | 125 +++++++ specs/004-provider-keys/research.md | 146 ++++++++ specs/004-provider-keys/spec.md | 200 +++++++++++ specs/004-provider-keys/tasks.md | 324 ++++++++++++++++++ 24 files changed, 1586 insertions(+), 87 deletions(-) create mode 100644 specs/004-provider-keys/checklists/requirements.md create mode 100644 specs/004-provider-keys/contracts/provider-keys.md create mode 100644 specs/004-provider-keys/data-model.md create mode 100644 specs/004-provider-keys/plan.md create mode 100644 specs/004-provider-keys/quickstart.md create mode 100644 specs/004-provider-keys/research.md create mode 100644 specs/004-provider-keys/spec.md create mode 100644 specs/004-provider-keys/tasks.md diff --git a/.opencode/commands/speckit.converge.md b/.opencode/commands/speckit.converge.md index 8d882c6..7a7eae2 100644 --- a/.opencode/commands/speckit.converge.md +++ b/.opencode/commands/speckit.converge.md @@ -199,19 +199,12 @@ Append to the **end** of `tasks.md`, per the append contract: (highest existing phase + 1). 2. Write a single new section header `## Phase N: Convergence`. 3. Emit one checklist item per actionable finding, ordered CRITICAL/HIGH first, assigning - zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`, following the same checkbox/ID/marker - format as `/speckit.tasks` (`- [ ] [TaskID] [P?] [Story?] Description with file path`): + zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`: ```markdown - - [ ] T042 [P?] [Story?] per () + - [ ] T042 per () ``` - Include `[P]` only when the task is parallelizable (different file, no dependency on an - incomplete task). Include `[Story]` (e.g. `[US1]`) only when `` identifies a - specific user story (e.g. `US1/AC2`); omit it for plan-level or constitution-level - findings, matching `/speckit.tasks`'s rule that Setup/Foundational/Polish tasks carry no - story label. - `` traces the task to its origin: e.g. `FR-003`, `SC-002`, `US1/AC2`, `plan: storage decision`, `Constitution II`. diff --git a/.opencode/commands/speckit.specify.md b/.opencode/commands/speckit.specify.md index 98e0bea..e5b3663 100644 --- a/.opencode/commands/speckit.specify.md +++ b/.opencode/commands/speckit.specify.md @@ -139,9 +139,9 @@ Given that feature description, do this: 7. Identify Key Entities (if data involved) 8. Return: SUCCESS (spec ready for planning) -7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. +6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. -8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: +7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items: diff --git a/.opencode/commands/speckit.tasks.md b/.opencode/commands/speckit.tasks.md index 087dc8c..5d637d4 100644 --- a/.opencode/commands/speckit.tasks.md +++ b/.opencode/commands/speckit.tasks.md @@ -165,7 +165,7 @@ Every task MUST strictly follow this format: **Examples**: -- ✅ CORRECT: `- [ ] T001 Create project structure (src/, tests/) per implementation plan` +- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan` - ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py` - ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py` - ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py` diff --git a/.specify/feature.json b/.specify/feature.json index a503348..388001e 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/003-brand-crud" + "feature_directory": "specs/004-provider-keys" } diff --git a/.specify/integrations/opencode.manifest.json b/.specify/integrations/opencode.manifest.json index afc644f..c6a082e 100644 --- a/.specify/integrations/opencode.manifest.json +++ b/.specify/integrations/opencode.manifest.json @@ -1,7 +1,7 @@ { "integration": "opencode", "version": "0.12.14", - "installed_at": "2026-07-19T16:37:19.006397+00:00", + "installed_at": "2026-07-26T16:19:25.218431+00:00", "files": { ".opencode/commands/speckit.analyze.md": "07e1e9f997bce9e06d3823ecf1ed315bb62381b81e7d6a7aef19c5087c320e70", ".opencode/commands/speckit.clarify.md": "595ae8988179a91780ce10e258afcccaa313b0924a79e720c66226e31af7ec00", diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json index 9b649cd..24d9156 100644 --- a/.specify/integrations/speckit.manifest.json +++ b/.specify/integrations/speckit.manifest.json @@ -5,13 +5,13 @@ "files": { ".specify/scripts/bash/common.sh": "6ff86bf39f6b4684b0f80927dc7a1dadec26b4671988a3fe4d6c2523cbd3aa22", ".specify/scripts/bash/setup-plan.sh": "4469b22960f43c07c33dca00de6dedb252145e9a9ce8fbb0e63be82e02b082ab", - ".specify/scripts/bash/setup-tasks.sh": "cf21ba2212b4dd5b435c5ea8527500cfd27768b86c0bbc7ebc3207759f118d27", - ".specify/scripts/bash/check-prerequisites.sh": "a7d8a14ecf87332b600cd966b5d0e7cb9d594abce7e4d1ee4372b2b5b3efff06", + ".specify/scripts/bash/setup-tasks.sh": "1d4bcebe93f3e4e778964978cfe9bfb67ee94e7dc688f2ff3be224f647f61f1f", + ".specify/scripts/bash/check-prerequisites.sh": "ac3e96258a05d029d048076393a03aadff5c7c3a55a26d0a9f5c17886a1c659d", ".specify/scripts/bash/create-new-feature.sh": "dd531f9ba47c9ce9975b597947377be9542b7236681d6dc033513c4e3cfc50f2", ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", - ".specify/templates/checklist-template.md": "709d8ab8384a3a49f5e0f64479f71553ef6d6f8bb4f00281b05f47837993b536", - ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842", + ".specify/templates/checklist-template.md": "0ad704b60af2df817aee1c0a2ecc0e0304b271d2de34047df1c891735967033e", + ".specify/templates/tasks-template.md": "c731575d8099b3f871861186fbd1a592b51b2ba57fb99e1a0dab439ff6d5608f", ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", - ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4" + ".specify/templates/plan-template.md": "5ef0e4c97b36e9f91372dc6eb8e5a7e515af8958cf1a9286e43a1ebd9bd48540" } } diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 4f8f5ec..387cee6 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,29 +1,30 @@ @@ -61,7 +62,7 @@ The technology stack is fixed for MVP: | Layer | Technology | |-------|------------| -| Frontend | Next.js 14 monolith | +| Frontend | Next.js 15 monolith | | Backend | FastAPI | | Auth, DB, Vault, Storage | Supabase | | Hosting | Bunny Magic Containers | @@ -98,14 +99,39 @@ Security implementation MUST follow these requirements: ### VII. Definition of Done -A feature is complete only when ALL of the following are verified: - -- [ ] Works correctly for a brand with no brand kit (0 answers) -- [ ] Works correctly for a brand with a completed brand kit -- [ ] Works with OpenAI provider -- [ ] Works with Gemini provider -- [ ] RLS policies tested OR explicit integration checks documented -- [ ] Hard delete verified: database rows removed AND storage assets removed +A feature is complete only when all universal checks and every capability check +applicable to the current implementation phase are verified. + +Universal checks: + +- [ ] Acceptance scenarios for the feature pass at the API, data, and user-facing + layers it changes +- [ ] Every table introduced or changed has RLS, forced RLS, policies, and required + privileges verified by direct integration tests; backend-only tables also require + tests proving client roles are denied +- [ ] Server-side ownership and secret/logging rules are tested for every affected + read and write operation +- [ ] Every deletable database row, secret, or stored asset affected by the feature + is physically removed on successful deletion; soft delete is forbidden + +Capability checks become mandatory only when their prerequisite capability exists: + +- [ ] Brand-kit zero-answer and completed-kit scenarios are required beginning with + the feature that implements Brand Kit, and for later brand-scoped features whose + behavior reads or depends on Brand Kit. Before Brand Kit exists, these checks are + not applicable. +- [ ] OpenAI behavior is required beginning with the feature that first integrates + OpenAI, and for every later feature that calls or changes that integration. +- [ ] Gemini behavior is required beginning with the feature that first integrates + Gemini, and for every later feature that calls or changes that integration. +- [ ] Generation lifecycle, platform preset, and PNG-output checks are required + beginning with the feature that implements image generation, and for later + features that change generation behavior. + +A plan MAY mark a capability check not applicable only when its prerequisite has not +been implemented or the feature cannot affect that capability. The plan MUST record +that rationale. Once a prerequisite capability exists, applicable checks MUST NOT be +deferred merely because they belong to an earlier or later roadmap phase. ## Governance @@ -128,4 +154,4 @@ A feature is complete only when ALL of the following are verified: - Plan documents MUST include a Constitution Check section - Definition of Done checklist MUST be completed before feature merge -**Version**: 1.1.0 | **Ratified**: 2025-01-28 | **Last Amended**: 2026-07-19 \ No newline at end of file +**Version**: 2.0.0 | **Ratified**: 2025-01-28 | **Last Amended**: 2026-07-26 diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh index bf75140..55575db 100755 --- a/.specify/scripts/bash/check-prerequisites.sh +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -120,20 +120,20 @@ fi # Validate required directories and files if [[ ! -d "$FEATURE_DIR" ]]; then echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 - echo "Run /speckit-specify first to create the feature structure." >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 exit 1 fi if [[ ! -f "$IMPL_PLAN" ]]; then echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 - echo "Run /speckit-plan first to create the implementation plan." >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 exit 1 fi # Check for tasks.md if required if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 - echo "Run /speckit-tasks first to create the task list." >&2 + echo "Run /speckit.tasks first to create the task list." >&2 exit 1 fi diff --git a/.specify/scripts/bash/setup-tasks.sh b/.specify/scripts/bash/setup-tasks.sh index ae0d7bd..15cc618 100755 --- a/.specify/scripts/bash/setup-tasks.sh +++ b/.specify/scripts/bash/setup-tasks.sh @@ -30,13 +30,13 @@ unset _paths_output # Validate required files if [[ ! -f "$IMPL_PLAN" ]]; then echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 - echo "Run /speckit-plan first to create the implementation plan." >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 exit 1 fi if [[ ! -f "$FEATURE_SPEC" ]]; then echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 - echo "Run /speckit-specify first to create the feature structure." >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 exit 1 fi diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md index e64065d..0caeacf 100644 --- a/.specify/templates/checklist-template.md +++ b/.specify/templates/checklist-template.md @@ -4,13 +4,13 @@ **Created**: [DATE] **Feature**: [Link to spec.md or relevant documentation] -**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements. +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. US2 Validate ----+ + | | + +------> US3 Activate ----+----> US4 Delete/Cleanup + | + v + Polish and full DoD +``` + +### Within Each User Story + +- Write the listed tests first and confirm they fail for the missing behavior +- Add boundary models before stores, stores before routes, and routes before frontend integration +- Never hold a database connection or row lock across provider or Storage I/O +- Always acquire the brand lock before a provider-key lock +- Preserve opaque ownership resolution before lifecycle/conflict errors +- Stop at each checkpoint and run that story's independent tests before continuing + +### Parallel Opportunities + +- T001, T002, and T005 can be authored in parallel before T003 because they touch separate test concerns +- T006 and T008 can proceed in parallel after the migration contract is understood +- T014-T016 can be authored in parallel; T017 can proceed separately before T018 consumes its models +- T024-T026 can be authored in parallel; T027 can proceed independently of the database lease implementation in T028 +- T035 and T036 can be authored in parallel after US1 +- T041-T044 can be authored in parallel before deletion/storage implementation starts +- T051 and T053 can proceed in parallel because they modify different frontend pages +- T057, T058, T060, and T061 can run in parallel after implementation stabilizes + +--- + +## Parallel Examples + +### User Story 1 + +```bash +Task: "Write list/add contract tests in backend/tests/contract/test_provider_keys.py" +Task: "Write Vault/idempotency integration tests in backend/tests/integration/test_provider_keys.py" +Task: "Write owner/RLS/Vault denial tests in backend/tests/integration/test_provider_key_rls.py" +``` + +### User Story 2 + +```bash +Task: "Write provider classification tests in backend/tests/unit/test_provider_validation.py" +Task: "Write validation API contract tests in backend/tests/contract/test_provider_keys.py" +Task: "Write validation lease tests in backend/tests/integration/test_provider_keys.py" +``` + +### User Story 3 + +```bash +Task: "Write activation API tests in backend/tests/contract/test_provider_keys.py" +Task: "Write activation concurrency tests in backend/tests/integration/test_provider_key_cleanup.py" +``` + +### User Story 4 + +```bash +Task: "Write individual cleanup contract tests in backend/tests/contract/test_provider_keys.py" +Task: "Write brand cleanup contract tests in backend/tests/contract/test_brands.py" +Task: "Write cleanup concurrency tests in backend/tests/integration/test_provider_key_cleanup.py" +``` + +--- + +## Implementation Strategy + +### MVP First + +1. Complete T001-T013 to establish the security and lifecycle foundation. +2. Complete T014-T023 for User Story 1. +3. Stop and independently validate atomic Vault storage, idempotent add, safe listing, + RLS, Vault denial, and browser opacity. +4. Deploy only if a brand owner can configure both providers without any reveal path. + +### Incremental Delivery + +1. Foundational: schema, privilege boundary, brand fence, deadline, and safe logs. +2. US1: add/list keys as the minimum useful BYOK setup. +3. US2: validate both providers without generation or false invalidation. +4. US3: rotate active credentials atomically under concurrency. +5. US4: complete individual and brand-wide retryable physical cleanup. +6. Polish: real-provider verification, full regression, secrecy audit, and DoD. + +## Notes + +- `[P]` means separate files or non-overlapping work with no dependency on an incomplete task. +- Raw keys may exist only in the add request, request-local backend memory/SQL bind, + Supabase Vault, and provider authentication header; never add them to fixtures that + can print on assertion failure. +- A cleanup-required row is a failed-operation retry anchor, not a successful soft delete. +- Missing Vault secrets are idempotent cleanup success; unknown Storage writes remain + blocked until definitive reconciliation and never expire based only on time. +- No Brand Kit, image generation, provider SDK, key reveal/edit, background cleanup + worker, pagination for key lists, or new runtime dependency belongs in this feature. From 7d49c894ab4bc71a982535d93bc7be32aea4778e Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Mon, 27 Jul 2026 20:17:43 +0300 Subject: [PATCH 02/14] Address Provider Keys spec review findings --- .opencode/commands/speckit.converge.md | 3 +- .opencode/commands/speckit.tasks.md | 2 +- .specify/integrations/opencode.manifest.json | 4 +-- .specify/integrations/speckit.manifest.json | 6 ++-- .specify/memory/constitution.md | 2 +- .specify/templates/plan-template.md | 10 ++++--- specs/004-provider-keys/data-model.md | 31 ++++++++++++++------ specs/004-provider-keys/quickstart.md | 4 +-- specs/004-provider-keys/research.md | 2 +- specs/004-provider-keys/tasks.md | 6 ++-- 10 files changed, 43 insertions(+), 27 deletions(-) diff --git a/.opencode/commands/speckit.converge.md b/.opencode/commands/speckit.converge.md index 7a7eae2..fac7ec8 100644 --- a/.opencode/commands/speckit.converge.md +++ b/.opencode/commands/speckit.converge.md @@ -202,9 +202,10 @@ Append to the **end** of `tasks.md`, per the append contract: zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`: ```markdown - - [ ] T042 per () + - [ ] T042 per () ``` + The imperative description MUST name the exact affected file path. `` traces the task to its origin: e.g. `FR-003`, `SC-002`, `US1/AC2`, `plan: storage decision`, `Constitution II`. diff --git a/.opencode/commands/speckit.tasks.md b/.opencode/commands/speckit.tasks.md index 5d637d4..01f50aa 100644 --- a/.opencode/commands/speckit.tasks.md +++ b/.opencode/commands/speckit.tasks.md @@ -165,7 +165,7 @@ Every task MUST strictly follow this format: **Examples**: -- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan` +- ✅ CORRECT: `- [ ] T001 Create project entry points in src/main.py and tests/conftest.py per implementation plan` - ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py` - ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py` - ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py` diff --git a/.specify/integrations/opencode.manifest.json b/.specify/integrations/opencode.manifest.json index c6a082e..a76bfad 100644 --- a/.specify/integrations/opencode.manifest.json +++ b/.specify/integrations/opencode.manifest.json @@ -7,11 +7,11 @@ ".opencode/commands/speckit.clarify.md": "595ae8988179a91780ce10e258afcccaa313b0924a79e720c66226e31af7ec00", ".opencode/commands/speckit.constitution.md": "e0d1d7c237d4657914dcccb6ea0c3a3a10d8d1d9a36b198282e22b3d0b9a55e5", ".opencode/commands/speckit.implement.md": "6a5713a4db8d48dd7c93e38b9957c6e54b6b7dc899ebab66f9ccb3bce58fdfeb", - ".opencode/commands/speckit.converge.md": "e585bc94a3f01cb45eb4f4f6003268a7bc7e1f1685d343c25ad5dc3c484b4816", + ".opencode/commands/speckit.converge.md": "fb8dcfb3e9c5df97793081825c9be11d8fe1b0d4bdc5478629c73608f3e5f60b", ".opencode/commands/speckit.plan.md": "ff1809d5d72b399ec364414a150c1a20e782677972d11f0bdf8c65c73f898a71", ".opencode/commands/speckit.checklist.md": "2124a817bddbff7f469fd47b08e43dd9a39b769499d367f6888a74e1e821412d", ".opencode/commands/speckit.specify.md": "e24c01f27d269dab73c4178ce5bb1c31198dbfc36b9198cc6e407ef1122f4745", - ".opencode/commands/speckit.tasks.md": "e3569f2fbdb99704b81e3647ce9e52fb812764d6b2590fc497a2c2130b43ba72", + ".opencode/commands/speckit.tasks.md": "201668645aa013b8e4dab7ef89325559071e6f57e99500112fdba837780ea81a", ".opencode/commands/speckit.taskstoissues.md": "0a436a42ceca2db309df88985d1939ee7f176c4a7a2d7c7a8d2253006cfe6870" } } diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json index 24d9156..10d3a14 100644 --- a/.specify/integrations/speckit.manifest.json +++ b/.specify/integrations/speckit.manifest.json @@ -10,8 +10,8 @@ ".specify/scripts/bash/create-new-feature.sh": "dd531f9ba47c9ce9975b597947377be9542b7236681d6dc033513c4e3cfc50f2", ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", ".specify/templates/checklist-template.md": "0ad704b60af2df817aee1c0a2ecc0e0304b271d2de34047df1c891735967033e", - ".specify/templates/tasks-template.md": "c731575d8099b3f871861186fbd1a592b51b2ba57fb99e1a0dab439ff6d5608f", - ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", - ".specify/templates/plan-template.md": "5ef0e4c97b36e9f91372dc6eb8e5a7e515af8958cf1a9286e43a1ebd9bd48540" + ".specify/templates/tasks-template.md": "61f9bf0d34c75f97e91486037ff35cdde35915d8d9f9b2fa755882c35eb497f9", + ".specify/templates/spec-template.md": "8bc198906589c75c3490d1e1edd09194bc94348b3ae8ee46657a62a7f9ebd4d8", + ".specify/templates/plan-template.md": "bbe9f9504a26f7400b2d92015b858a30dbc92e5069cf02840cec19c45b6abc95" } } diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 387cee6..3c3953e 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -18,7 +18,7 @@ Templates and guidance status: - .specify/templates/plan-template.md ✅ Updated phase-aware Constitution Check guidance - .specify/templates/spec-template.md ✅ Updated phase dependency/applicability guidance - .specify/templates/tasks-template.md ✅ Updated phase-aware DoD task guidance - - .specify/templates/checklist-template.md ✅ Reviewed; generic template needs no change + - .specify/templates/checklist-template.md ✅ Updated /speckit.checklist command references - .specify/templates/agent-file-template.md ✅ Not present in this initialized template set - .opencode/commands/speckit.*.md ✅ Reviewed; no version-specific or unconditional DoD rules - README.md ✅ Updated runtime version and implemented-feature status diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index bdf033f..6ec0c8b 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -40,10 +40,12 @@ *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -[Gates determined based on constitution file. Evaluate all universal Definition of -Done checks and each capability check whose prerequisite exists in the current -implementation phase. Mark a capability check N/A only with a concrete rationale -that its prerequisite is not implemented or this feature cannot affect it.] +[Determine gates from the constitution file. Before Phase 0, identify all universal +Definition of Done checks and each capability check whose prerequisite is already +implemented, regardless of roadmap phase. For every applicable check, document how it +will be verified during implementation or runtime validation; do not claim it has +already passed. Mark a check N/A only with a concrete rationale that its prerequisite +is not implemented or the feature's scope cannot affect that capability.] ## Project Structure diff --git a/specs/004-provider-keys/data-model.md b/specs/004-provider-keys/data-model.md index 60ad3ba..91eb1cd 100644 --- a/specs/004-provider-keys/data-model.md +++ b/specs/004-provider-keys/data-model.md @@ -1,6 +1,6 @@ # Phase 1 Data Model: Provider Keys -## Supporting Types and Ownership Helper +## Supporting Types and Private Ownership Helper The migration first ensures `supabase_vault` is available (`CREATE EXTENSION IF NOT EXISTS supabase_vault CASCADE`) and introduces types shared by provider-key records and future generation records: @@ -10,10 +10,15 @@ CREATE TYPE provider_key_lifecycle_t AS ENUM ('normal', 'cleanup_required'); CREATE TYPE brand_deletion_state_t AS ENUM ('active', 'cleanup_required'); ``` -It also introduces the deferred ownership helper anticipated by the Brand CRUD research: +It also introduces the deferred ownership helper anticipated by the Brand CRUD research +in a schema that is not listed in Supabase's exposed API schemas: ```sql -CREATE FUNCTION is_brand_owner(p_brand_id UUID) +CREATE SCHEMA IF NOT EXISTS private; +REVOKE ALL ON SCHEMA private FROM PUBLIC, anon, authenticated; +GRANT USAGE ON SCHEMA private TO authenticated, service_role; + +CREATE FUNCTION private.is_brand_owner(p_brand_id UUID) RETURNS BOOLEAN LANGUAGE sql STABLE @@ -27,9 +32,17 @@ AS $$ AND owner_user_id = (SELECT auth.uid()) ); $$; + +REVOKE ALL ON FUNCTION private.is_brand_owner(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION private.is_brand_owner(UUID) TO authenticated, service_role; ``` -Execution is revoked from `PUBLIC` and granted only to `authenticated` and `service_role`. The empty search path and fully qualified names prevent object-shadowing attacks in a `SECURITY DEFINER` function. +The `private` schema is deliberately absent from `supabase/config.toml`'s exposed API +schemas, so PostgREST cannot publish the helper as an authenticated RPC. `authenticated` +and `service_role` receive schema `USAGE` plus exact function `EXECUTE` because RLS +policies run under the querying role; they receive no schema `CREATE`. Execution remains +revoked from `PUBLIC`. The empty search path and fully qualified names prevent +object-shadowing attacks in a `SECURITY DEFINER` function. ## Existing Entity Change: Brand @@ -206,18 +219,18 @@ ALTER TABLE provider_keys ENABLE ROW LEVEL SECURITY; ALTER TABLE provider_keys FORCE ROW LEVEL SECURITY; CREATE POLICY provider_keys_select ON provider_keys - FOR SELECT USING (is_brand_owner(brand_id)); + FOR SELECT USING (private.is_brand_owner(brand_id)); CREATE POLICY provider_keys_insert ON provider_keys - FOR INSERT WITH CHECK (is_brand_owner(brand_id)); + FOR INSERT WITH CHECK (private.is_brand_owner(brand_id)); CREATE POLICY provider_keys_update ON provider_keys FOR UPDATE - USING (is_brand_owner(brand_id)) - WITH CHECK (is_brand_owner(brand_id)); + USING (private.is_brand_owner(brand_id)) + WITH CHECK (private.is_brand_owner(brand_id)); CREATE POLICY provider_keys_delete ON provider_keys - FOR DELETE USING (is_brand_owner(brand_id)); + FOR DELETE USING (private.is_brand_owner(brand_id)); GRANT SELECT, INSERT, UPDATE, DELETE ON provider_keys TO service_role; diff --git a/specs/004-provider-keys/quickstart.md b/specs/004-provider-keys/quickstart.md index bedf8d7..88c34c6 100644 --- a/specs/004-provider-keys/quickstart.md +++ b/specs/004-provider-keys/quickstart.md @@ -77,10 +77,10 @@ Then validate real disposable keys through the UI: 2. Repeat each operation with nonexistent UUIDs. Confirm status, error code, and message match the non-owned case; only request IDs differ. 3. Execute direct SQL as User A's `authenticated` role. Confirm safe-column SELECT can see only User A's records, internal Vault/lease columns are permission-denied, and INSERT/UPDATE/DELETE are permission-denied even for owned rows. 4. Repeat as User B. Confirm safe-column SELECT returns no User A rows and all writes remain permission-denied. -5. Verify catalog privileges show no Vault schema/table/view/function access for `anon` or `authenticated`, and verify the configured backend database role has exactly the required Vault/application privileges. +5. Verify catalog privileges show no Vault schema/table/view/function access for `anon` or `authenticated`. In hosted deployments, verify the configured backend database role has exactly the required Vault/application privileges. In local development, the documented `postgres`/superuser exception may have broader privileges, but it must still provide every required Vault/application capability. 6. As `authenticated`, attempt direct reads from `vault.secrets` and `vault.decrypted_secrets`, secret creation, update, and deletion. Require SQLSTATE `42501` rather than an empty result. 7. As both `anon` and `authenticated`, attempt direct SELECT/INSERT/UPDATE/DELETE against `provider_key_idempotency` and `brand_asset_operations`. Require permission denial for every operation, including when the referenced brand is owned by the authenticated user. -8. With a real authenticated JWT, attempt Data API access to both backend-only tables, a `vault` profile, and attempted Vault RPCs. Confirm all are unavailable for both User A and User B. +8. With a real authenticated JWT, attempt Data API access to both backend-only tables, a `vault` profile, Vault RPCs, and the `private.is_brand_owner` helper RPC. Confirm all are unavailable for both User A and User B while normal owner-scoped metadata reads still exercise the helper through RLS. Expected: clients cannot retrieve even their own raw key; all Vault access is backend-only, and provider-key metadata remains brand-isolated at API and database layers. diff --git a/specs/004-provider-keys/research.md b/specs/004-provider-keys/research.md index dd6e3b0..f875a6a 100644 --- a/specs/004-provider-keys/research.md +++ b/specs/004-provider-keys/research.md @@ -18,7 +18,7 @@ An already-absent Vault row counts as successful idempotent cleanup. After any a ## Decision 2: Deny all client access to Vault; apply RLS to application records -**Decision**: Keep the `vault` schema outside PostgREST's exposed schemas and explicitly revoke schema, table/view, and exact-signature function privileges from `PUBLIC`, `anon`, and `authenticated`. Only the backend's privileged database role may create, decrypt, or delete Vault secrets. Apply `ENABLE` and `FORCE ROW LEVEL SECURITY` to `provider_keys`; grant authenticated users safe-column SELECT only and no direct DML. The actual `DATABASE_URL` role must pass startup/integration privilege assertions for required Vault/application operations. +**Decision**: Keep the `vault` schema outside PostgREST's exposed schemas and explicitly revoke schema, table/view, and exact-signature function privileges from `PUBLIC`, `anon`, and `authenticated`. Place the `SECURITY DEFINER` ownership helper in a separate non-exposed `private` schema; grant authenticated callers only schema `USAGE` and exact helper `EXECUTE` so RLS can invoke it without publishing a Data API RPC. Only the backend's privileged database role may create, decrypt, or delete Vault secrets. Apply `ENABLE` and `FORCE ROW LEVEL SECURITY` to `provider_keys`; grant authenticated users safe-column SELECT only and no direct DML. The actual `DATABASE_URL` role must pass startup/integration privilege assertions for required Vault/application operations. **Rationale**: Vault's decrypted view is global and is protected by object privileges rather than tenant RLS. Granting an authenticated client access would expose every secret. Full table DML would also let an owner read `vault_secret_id`, forge lifecycle state, or delete the only retry reference without Vault cleanup. Safe owner metadata reads exercise RLS, while all writes remain backend-mediated. Integration tests prove safe-column visibility, same-owner and cross-owner write denial, SQLSTATE `42501` for Vault, and Data API rejection. diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md index 44f01fd..488c1c3 100644 --- a/specs/004-provider-keys/tasks.md +++ b/specs/004-provider-keys/tasks.md @@ -48,9 +48,9 @@ brand cleanup state, request deadline, and safe logging rules required by every **CRITICAL**: No user story implementation starts until this phase passes against a real local Supabase instance. -- [ ] T001 Create failing schema/security integration tests in `backend/tests/integration/test_provider_key_rls.py` that assert the planned enums, helper, constraints, indexes, restrictive foreign keys, `ENABLE`/`FORCE RLS`, owner-only safe-column reads, denied authenticated DML and internal-column reads, complete denial of `provider_key_idempotency` and `brand_asset_operations`, exact Vault denial with SQLSTATE `42501`, and required backend-role `BYPASSRLS`/application/Vault privileges from `data-model.md` +- [ ] T001 Create failing schema/security integration tests in `backend/tests/integration/test_provider_key_rls.py` that assert the planned enums, non-exposed `private.is_brand_owner(uuid)` helper and denied authenticated RPC, constraints, indexes, restrictive foreign keys, `ENABLE`/`FORCE RLS`, owner-only safe-column reads, denied authenticated DML and internal-column reads, complete denial of `provider_key_idempotency` and `brand_asset_operations`, exact Vault denial with SQLSTATE `42501`, and required backend-role `BYPASSRLS`/application/Vault privileges from `data-model.md` - [ ] T002 Update failing Brand CRUD/RLS regressions in `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` so test cleanup hard-deletes brands before auth users, and direct authenticated brand `INSERT`/`UPDATE`/`DELETE` expects permission denial for owned and non-owned rows after authenticated DML is revoked -- [ ] T003 Create `supabase/migrations/00016_create_provider_keys.sql` with `supabase_vault`, `provider_t`, `provider_key_lifecycle_t`, `brand_deletion_state_t`, hardened `is_brand_owner(uuid)`, irreversible cleanup triggers, the `brands` lifecycle/logo-path/FK/grant changes, `provider_keys`, `provider_key_idempotency`, `brand_asset_operations`, all checks/indexes/triggers/restrictive FKs, `ENABLE`/`FORCE RLS`, safe-column grants, backend-only grants, and exact Vault privilege revocations/grants specified in `data-model.md` +- [ ] T003 Create `supabase/migrations/00016_create_provider_keys.sql` with `supabase_vault`, `provider_t`, `provider_key_lifecycle_t`, `brand_deletion_state_t`, hardened `private.is_brand_owner(uuid)` in a non-exposed schema with RLS-only caller privileges, irreversible cleanup triggers, the `brands` lifecycle/logo-path/FK/grant changes, `provider_keys`, `provider_key_idempotency`, `brand_asset_operations`, all checks/indexes/triggers/restrictive FKs, `ENABLE`/`FORCE RLS`, safe-column grants, backend-only grants, and exact Vault privilege revocations/grants specified in `data-model.md` - [ ] T004 Apply and statically validate `supabase/migrations/00016_create_provider_keys.sql` with `supabase migration up` and `supabase db lint --level warning`, then run `backend/tests/integration/test_provider_key_rls.py` and confirm its real-Supabase tests execute rather than skip - [ ] T005 [P] Add failing shared contract tests in `backend/tests/contract/test_provider_keys.py` for the 15-second validation deadline context, fixed provider-key/brand cleanup errors, `X-Request-Id` parity, and JSON log allowlisting that excludes submitted bodies, labels, hints, Vault UUIDs, SQL parameters, provider bodies, authorization headers, exception strings, user IDs, and email addresses - [ ] T006 [P] Extend `backend/app/models/brand.py` so every Brand response maps database `deletion_state` to contract field `cleanup_state: normal | cleanup_required` without exposing internal lifecycle fields @@ -198,7 +198,7 @@ secrecy review, and the phase-aware constitutional Definition of Done. - [ ] T057 [P] Document in `backend/.env.example` that `DATABASE_URL` must use a private non-client role with forced-RLS bypass and only the required application/Vault privileges, without placing credentials or full example secrets in the file - [ ] T058 [P] Update `docs/docker.md` with hosted backend-role creation/verification expectations, startup failure troubleshooting, official provider outbound-host requirements, and safe handling of disposable validation keys - [ ] T059 Run the complete backend suite with `backend/.venv/bin/python -m pytest backend/tests -q` and use `specs/004-provider-keys/quickstart.md` to confirm all provider-key real-Supabase integration files execute rather than skip -- [ ] T060 [P] Run `npm run lint`, `npx tsc --noEmit`, `npm run build`, and `npx playwright test frontend/tests/e2e/provider-keys.spec.ts` from `frontend/`; resolve feature-related failures in the changed frontend files +- [ ] T060 [P] Run `npm run lint`, `npx tsc --noEmit`, `npm run build`, and `npx playwright test tests/e2e/provider-keys.spec.ts` from `frontend/`; resolve feature-related failures in the changed frontend files - [ ] T061 [P] Run `supabase db lint --level warning` against `supabase/migrations/00016_create_provider_keys.sql` and re-run the catalog, SQLSTATE, RLS, backend-only table, Data API, and Vault privilege assertions in `backend/tests/integration/test_provider_key_rls.py` - [ ] T062 Perform the real disposable OpenAI and Gemini checks in Quickstart Scenario 2 steps 8-10 from `specs/004-provider-keys/quickstart.md`; require `valid` from each official model-list endpoint, confirm no generation occurs, and treat provider outage/temporary classification as blocked rather than passed - [ ] T063 Audit `backend/app`, `backend/tests`, and captured logs using Quickstart Scenario 6 in `specs/004-provider-keys/quickstart.md`; confirm no raw key, label, hint, Vault UUID, decrypted value, authorization header, provider body, SQL bind, token, PII, or secret-bearing exception can cross response/log/test-output boundaries From 9dfc05c109351f4fd27c0cb35a9c50a0b848b1ae Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Mon, 27 Jul 2026 21:04:49 +0300 Subject: [PATCH 03/14] Complete Phase 1 setup verification --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c13206a..b1debe0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ coverage/ .nyc_output/ # Logs +*.log npm-debug.log* yarn-debug.log* yarn-error.log* @@ -109,6 +110,7 @@ Thumbs.db # --------------------------------------------------------------------------- # Misc # --------------------------------------------------------------------------- +*.tmp *.tsbuildinfo .cache/ tmp/ From 820a1bf797f35e87638806fa30c1caecefb0d06c Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Tue, 28 Jul 2026 00:01:29 +0300 Subject: [PATCH 04/14] Complete provider keys foundation --- backend/app/auth.py | 77 +- backend/app/config.py | 98 ++- backend/app/main.py | 43 +- backend/app/models/brand.py | 2 + backend/app/routes/brands.py | 100 ++- backend/app/services/brand_store.py | 175 ++++- backend/tests/contract/conftest.py | 15 + backend/tests/contract/test_brands.py | 214 +++++- backend/tests/contract/test_provider_keys.py | 400 +++++++++++ backend/tests/integration/test_brand_crud.py | 279 ++++++++ backend/tests/integration/test_brand_rls.py | 91 +-- .../integration/test_provider_key_rls.py | 666 ++++++++++++++++++ specs/004-provider-keys/data-model.md | 2 +- specs/004-provider-keys/tasks.md | 26 +- .../migrations/00016_create_provider_keys.sql | 237 +++++++ 15 files changed, 2289 insertions(+), 136 deletions(-) create mode 100644 backend/tests/contract/conftest.py create mode 100644 backend/tests/contract/test_provider_keys.py create mode 100644 backend/tests/integration/test_provider_key_rls.py create mode 100644 supabase/migrations/00016_create_provider_keys.sql diff --git a/backend/app/auth.py b/backend/app/auth.py index fa43cae..1c825ab 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,9 +1,11 @@ from dataclasses import dataclass from functools import lru_cache from typing import Annotated +import asyncio +import time import jwt -from fastapi import Depends, Header, HTTPException, status +from fastapi import Depends, Header, HTTPException, Request, status from jwt import PyJWKClient from .config import load_settings @@ -24,12 +26,51 @@ def _unauthorized(message: str = "Sign in required.") -> HTTPException: @lru_cache(maxsize=1) -def _get_jwks_client() -> PyJWKClient: +def _get_cached_jwks_client() -> PyJWKClient: settings = load_settings() return PyJWKClient(f"{settings.supabase_url}/auth/v1/.well-known/jwks.json") +def _get_jwks_client(request: Request) -> PyJWKClient: + deadline = getattr(request.state, "validation_deadline", None) + if deadline is None: + return _get_cached_jwks_client() + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise _unauthorized() + settings = load_settings() + return PyJWKClient( + f"{settings.supabase_url}/auth/v1/.well-known/jwks.json", + timeout=min(2, remaining), + ) + + +def _decode_access_token(request: Request, token: str, settings) -> dict: + algorithm = jwt.get_unverified_header(token).get("alg", "HS256") + + if algorithm == "HS256": + # Legacy shared-secret signing, still used by some projects. + return jwt.decode( + token, + settings.supabase_jwt_secret, + algorithms=["HS256"], + audience="authenticated", + ) + + # Newer Supabase projects sign access tokens with an asymmetric + # key (e.g. ES256), published via the project's JWKS endpoint. + signing_key = _get_jwks_client(request).get_signing_key_from_jwt(token) + return jwt.decode( + token, + signing_key.key, + algorithms=[algorithm], + audience="authenticated", + ) + + async def get_current_user( + request: Request, authorization: str | None = Header(default=None, alias="Authorization"), ) -> CurrentUser: if not authorization or not authorization.startswith("Bearer "): @@ -42,26 +83,20 @@ async def get_current_user( settings = load_settings() try: - algorithm = jwt.get_unverified_header(token).get("alg", "HS256") - - if algorithm == "HS256": - # Legacy shared-secret signing, still used by some projects. - payload = jwt.decode( - token, - settings.supabase_jwt_secret, - algorithms=["HS256"], - audience="authenticated", - ) + deadline = getattr(request.state, "validation_deadline", None) + if deadline is None: + payload = _decode_access_token(request, token, settings) else: - # Newer Supabase projects sign access tokens with an asymmetric - # key (e.g. ES256), published via the project's JWKS endpoint. - signing_key = _get_jwks_client().get_signing_key_from_jwt(token) - payload = jwt.decode( - token, - signing_key.key, - algorithms=[algorithm], - audience="authenticated", - ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise _unauthorized() + try: + payload = await asyncio.wait_for( + asyncio.to_thread(_decode_access_token, request, token, settings), + timeout=remaining, + ) + except TimeoutError: + raise _unauthorized() from None except jwt.PyJWTError: raise _unauthorized() from None diff --git a/backend/app/config.py b/backend/app/config.py index a722a42..791c7a4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,12 +1,15 @@ from dataclasses import dataclass from functools import lru_cache import os +from urllib.parse import urlparse -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine _DEFAULT_ALLOWED_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000" +_DATABASE_TIMEOUT_SECONDS = 2 +_DATABASE_TIMEOUT_MILLISECONDS = _DATABASE_TIMEOUT_SECONDS * 1000 @dataclass(frozen=True, slots=True) @@ -35,4 +38,95 @@ def get_engine() -> Engine: if not settings.database_url: raise RuntimeError("DATABASE_URL is required") - return create_engine(settings.database_url, future=True, pool_pre_ping=True) + return create_engine( + settings.database_url, + future=True, + pool_pre_ping=True, + hide_parameters=True, + pool_timeout=_DATABASE_TIMEOUT_SECONDS, + connect_args={ + "connect_timeout": _DATABASE_TIMEOUT_SECONDS, + "options": ( + f"-c statement_timeout={_DATABASE_TIMEOUT_MILLISECONDS} " + f"-c lock_timeout={_DATABASE_TIMEOUT_MILLISECONDS}" + ), + }, + ) + + +_DATABASE_ROLE_PRIVILEGES = text( + """ + SELECT + current_user::text AS role_name, + roles.rolsuper AS is_superuser, + current_user NOT IN ('anon', 'authenticated', 'service_role') AS role_is_private, + roles.rolsuper OR roles.rolbypassrls AS can_bypass_forced_rls, + has_table_privilege(current_user, 'public.brands', 'SELECT') + AND has_table_privilege(current_user, 'public.brands', 'INSERT') + AND has_table_privilege(current_user, 'public.brands', 'UPDATE') + AND has_table_privilege(current_user, 'public.brands', 'DELETE') + AND has_table_privilege(current_user, 'public.provider_keys', 'SELECT') + AND has_table_privilege(current_user, 'public.provider_keys', 'INSERT') + AND has_table_privilege(current_user, 'public.provider_keys', 'UPDATE') + AND has_table_privilege(current_user, 'public.provider_keys', 'DELETE') + AND has_table_privilege(current_user, 'public.provider_key_idempotency', 'SELECT') + AND has_table_privilege(current_user, 'public.provider_key_idempotency', 'INSERT') + AND has_table_privilege(current_user, 'public.provider_key_idempotency', 'UPDATE') + AND has_table_privilege(current_user, 'public.provider_key_idempotency', 'DELETE') + AND has_table_privilege(current_user, 'public.brand_asset_operations', 'SELECT') + AND has_table_privilege(current_user, 'public.brand_asset_operations', 'INSERT') + AND has_table_privilege(current_user, 'public.brand_asset_operations', 'UPDATE') + AND has_table_privilege(current_user, 'public.brand_asset_operations', 'DELETE') + AS application_dml, + has_schema_privilege(current_user, 'vault', 'USAGE') AS vault_schema_usage, + has_function_privilege( + current_user, + to_regprocedure('vault.create_secret(text,text,text,uuid)'), + 'EXECUTE' + ) AS vault_create, + has_column_privilege(current_user, 'vault.decrypted_secrets', 'id', 'SELECT') + AND has_column_privilege( + current_user, 'vault.decrypted_secrets', 'decrypted_secret', 'SELECT' + ) AS vault_decrypt, + has_column_privilege(current_user, 'vault.secrets', 'id', 'SELECT') + AS vault_secret_select, + has_table_privilege(current_user, 'vault.secrets', 'DELETE') + AS vault_secret_delete, + ( + NOT has_schema_privilege(current_user, 'vault', 'CREATE') + AND NOT has_function_privilege( + current_user, + to_regprocedure('vault.update_secret(uuid,text,text,text,uuid)'), + 'EXECUTE' + ) + AND NOT has_table_privilege( + current_user, 'vault.decrypted_secrets', 'SELECT' + ) + AND NOT has_table_privilege(current_user, 'vault.secrets', 'SELECT') + ) AS vault_least_privilege + FROM pg_roles AS roles + WHERE roles.rolname = current_user + """ +) + + +def assert_database_role_privileges() -> None: + settings = load_settings() + with get_engine().connect() as connection: + privileges = dict(connection.execute(_DATABASE_ROLE_PRIVILEGES).mappings().one()) + + role_name = privileges.pop("role_name") + is_superuser = privileges.pop("is_superuser") + vault_least_privilege = privileges.pop("vault_least_privilege") + hostname = urlparse(settings.supabase_url).hostname + local_postgres = role_name == "postgres" and hostname in { + "127.0.0.1", + "localhost", + "::1", + } + role_is_acceptable = local_postgres or ( + not is_superuser and vault_least_privilege + ) + + if not role_is_acceptable or not all(privileges.values()): + raise RuntimeError("database role lacks required backend privileges") diff --git a/backend/app/main.py b/backend/app/main.py index a2188f1..bc66787 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,7 @@ import json import logging +import re +import time from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from uuid import uuid4 @@ -10,21 +12,39 @@ from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException -from .config import load_settings +from .config import assert_database_role_privileges, load_settings from .routes.auth import router as auth_router from .routes.brands import router as brands_router from .routes.health import router as health_router from .routes.me import router as me_router +_VALIDATION_DEADLINE_SECONDS = 15 +_VALIDATION_PATH = re.compile( + r"^/api/v1/brands/[^/]+/keys/[^/]+/validate/?$" +) +_SAFE_ERROR_MESSAGES = { + (409, "KEY_CLEANUP_REQUIRED"): "Key cleanup is required. Retry deletion.", + (409, "BRAND_CLEANUP_REQUIRED"): "Brand cleanup is required. Retry deletion.", + (503, "KEY_CLEANUP_REQUIRED"): "Key cleanup did not complete. Retry deletion.", + (503, "BRAND_CLEANUP_REQUIRED"): "Brand cleanup did not complete. Retry deletion.", +} + + class _JsonLogFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: payload = { "level": record.levelname, "logger": record.name, - "message": record.getMessage(), } - for key in ("event", "request_id"): + for key in ( + "event", + "request_id", + "provider", + "code", + "duration_ms", + "provider_request_id", + ): value = getattr(record, key, None) if value is not None: payload[key] = value @@ -42,6 +62,7 @@ def _configure_logging() -> None: @asynccontextmanager async def _lifespan(_: FastAPI) -> AsyncIterator[None]: _configure_logging() + assert_database_role_privileges() yield @@ -70,10 +91,24 @@ def _error_response(request_id: str, code: str, message: str, status_code: int) ) +def safe_error_response(request: Request, status_code: int, code: str) -> JSONResponse: + message = _SAFE_ERROR_MESSAGES[(status_code, code)] + request_id = getattr(request.state, "request_id", str(uuid4())) + return _error_response(request_id, code, message, status_code) + + +def get_validation_deadline(request: Request) -> float | None: + return getattr(request.state, "validation_deadline", None) + + @app.middleware("http") -async def request_id_middleware(request: Request, call_next: Callable): +async def request_context_middleware(request: Request, call_next: Callable): request_id = str(uuid4()) request.state.request_id = request_id + if _VALIDATION_PATH.fullmatch(request.url.path): + request.state.validation_deadline = ( + time.monotonic() + _VALIDATION_DEADLINE_SECONDS + ) response = await call_next(request) response.headers["X-Request-Id"] = request_id return response diff --git a/backend/app/models/brand.py b/backend/app/models/brand.py index 7677deb..cce3e93 100644 --- a/backend/app/models/brand.py +++ b/backend/app/models/brand.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from typing import Literal from uuid import UUID from pydantic import BaseModel, field_validator @@ -26,6 +27,7 @@ class Brand(BaseModel): id: UUID name: str logo_url: str | None + cleanup_state: Literal["normal", "cleanup_required"] = "normal" created_at: datetime diff --git a/backend/app/routes/brands.py b/backend/app/routes/brands.py index 0efd9c4..8d30e45 100644 --- a/backend/app/routes/brands.py +++ b/backend/app/routes/brands.py @@ -13,7 +13,13 @@ BrandStorageError, get_brand_storage, ) -from ..services.brand_store import BrandNameTakenError, BrandStore, get_brand_store +from ..services.brand_store import ( + BrandCleanupRequiredError, + BrandMutationInProgressError, + BrandNameTakenError, + BrandStore, + get_brand_store, +) router = APIRouter(prefix="/api/v1/brands", tags=["brands"]) @@ -57,6 +63,44 @@ def _confirmation_mismatch() -> HTTPException: ) +def _cleanup_required() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "BRAND_CLEANUP_REQUIRED", + "message": "Brand cleanup is required. Retry deletion.", + }, + ) + + +def _cleanup_failed() -> HTTPException: + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "BRAND_CLEANUP_REQUIRED", + "message": "Brand cleanup did not complete. Retry deletion.", + }, + ) + + +def _mutation_in_progress() -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "BRAND_MUTATION_IN_PROGRESS", + "message": "A brand update is in progress. Retry shortly.", + }, + ) + + +def _mutation_error(exc: Exception) -> HTTPException: + if isinstance(exc, BrandCleanupRequiredError): + return _cleanup_required() + if isinstance(exc, BrandMutationInProgressError): + return _mutation_in_progress() + return _not_found() + + def _unsupported_media_type() -> HTTPException: return HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -176,14 +220,18 @@ async def delete_brand( raise _confirmation_mismatch() try: - logo_path = brand_store.get_logo_path(current_user.user_id, brand_id) - except LookupError as exc: - raise _not_found() from exc + logo_path = brand_store.begin_brand_cleanup(current_user.user_id, brand_id) + except ( + LookupError, + BrandCleanupRequiredError, + BrandMutationInProgressError, + ) as exc: + raise _mutation_error(exc) from exc if logo_path: try: await brand_storage.delete_logo(logo_path) - except BrandStorageError: + except BrandStorageError as exc: logger.warning( "brands.delete_logo_cleanup_failed", extra={ @@ -191,11 +239,16 @@ async def delete_brand( "request_id": getattr(request.state, "request_id", "unknown"), }, ) + raise _cleanup_failed() from exc try: - brand_store.delete_brand(current_user.user_id, brand_id) - except LookupError as exc: - raise _not_found() from exc + brand_store.delete_brand_after_cleanup(current_user.user_id, brand_id) + except ( + LookupError, + BrandCleanupRequiredError, + BrandMutationInProgressError, + ) as exc: + raise _mutation_error(exc) from exc logger.info( "brands.delete_success", @@ -218,8 +271,12 @@ async def upload_brand_logo( ) -> Brand: try: old_path = brand_store.get_logo_path(current_user.user_id, brand_id) - except LookupError as exc: - raise _not_found() from exc + except ( + LookupError, + BrandCleanupRequiredError, + BrandMutationInProgressError, + ) as exc: + raise _mutation_error(exc) from exc content_type = file.content_type or "" extension = LOGO_TYPES.get(content_type) @@ -261,8 +318,11 @@ async def upload_brand_logo( "request_id": getattr(request.state, "request_id", "unknown"), }, ) - if isinstance(exc, LookupError): - raise _not_found() from exc + if isinstance( + exc, + (LookupError, BrandCleanupRequiredError, BrandMutationInProgressError), + ): + raise _mutation_error(exc) from exc raise if old_path and old_path != new_path: @@ -297,16 +357,24 @@ async def delete_brand_logo( ) -> Response: try: old_path = brand_store.get_logo_path(current_user.user_id, brand_id) - except LookupError as exc: - raise _not_found() from exc + except ( + LookupError, + BrandCleanupRequiredError, + BrandMutationInProgressError, + ) as exc: + raise _mutation_error(exc) from exc if old_path is None: return Response(status_code=status.HTTP_204_NO_CONTENT) try: brand_store.update_logo_path(current_user.user_id, brand_id, None) - except LookupError as exc: - raise _not_found() from exc + except ( + LookupError, + BrandCleanupRequiredError, + BrandMutationInProgressError, + ) as exc: + raise _mutation_error(exc) from exc try: await brand_storage.delete_logo(old_path) except BrandStorageError: diff --git a/backend/app/services/brand_store.py b/backend/app/services/brand_store.py index faf31f1..c9419dd 100644 --- a/backend/app/services/brand_store.py +++ b/backend/app/services/brand_store.py @@ -7,7 +7,7 @@ from uuid import UUID from sqlalchemy import text -from sqlalchemy.engine import Engine +from sqlalchemy.engine import Connection, Engine from sqlalchemy.exc import IntegrityError from ..config import get_engine, load_settings @@ -18,6 +18,14 @@ class BrandNameTakenError(Exception): pass +class BrandCleanupRequiredError(Exception): + pass + + +class BrandMutationInProgressError(Exception): + pass + + @dataclass(frozen=True, slots=True) class BrandStore: engine: Engine @@ -29,7 +37,81 @@ def _to_brand(row: Mapping[str, Any]) -> Brand: if logo_path: base_url = load_settings().supabase_url.rstrip("/") logo_url = f"{base_url}/storage/v1/object/public/brand-assets/{logo_path}" - return Brand.model_validate({"logo_url": logo_url, **row}) + cleanup_state = { + "active": "normal", + "cleanup_required": "cleanup_required", + }[row["deletion_state"]] + return Brand.model_validate( + {"logo_url": logo_url, "cleanup_state": cleanup_state, **row} + ) + + @staticmethod + def lock_owned_brand( + connection: Connection, + user_id: str, + brand_id: UUID, + ) -> Mapping[str, Any]: + row = connection.execute( + text( + """ + SELECT id, name, logo_path, deletion_state, created_at + FROM brands + WHERE id = :brand_id AND owner_user_id = :owner_user_id + FOR UPDATE + """ + ), + {"brand_id": brand_id, "owner_user_id": user_id}, + ).mappings().one_or_none() + if row is None: + raise LookupError("Brand not found.") + return row + + @staticmethod + def require_normal_brand(row: Mapping[str, Any]) -> None: + if row["deletion_state"] != "active": + raise BrandCleanupRequiredError + + @staticmethod + def require_no_asset_operation(connection: Connection, brand_id: UUID) -> None: + operation_exists = connection.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 FROM brand_asset_operations WHERE brand_id = :brand_id + ) + """ + ), + {"brand_id": brand_id}, + ).scalar_one() + if operation_exists: + raise BrandMutationInProgressError + + def lock_owned_brand_for_mutation( + self, + connection: Connection, + user_id: str, + brand_id: UUID, + ) -> Mapping[str, Any]: + row = self.lock_owned_brand(connection, user_id, brand_id) + self.require_normal_brand(row) + return row + + def lock_owned_brand_without_asset_operation( + self, + connection: Connection, + user_id: str, + brand_id: UUID, + ) -> Mapping[str, Any]: + row = self.lock_owned_brand_for_mutation(connection, user_id, brand_id) + self.require_no_asset_operation(connection, brand_id) + return row + + def get_brand_for_mutation(self, user_id: str, brand_id: UUID) -> Brand: + with self.engine.begin() as connection: + row = self.lock_owned_brand_without_asset_operation( + connection, user_id, brand_id + ) + return self._to_brand(row) def create_brand(self, user_id: str, payload: BrandCreate) -> Brand: try: @@ -39,7 +121,7 @@ def create_brand(self, user_id: str, payload: BrandCreate) -> Brand: """ INSERT INTO brands (owner_user_id, name) VALUES (:owner_user_id, :name) - RETURNING id, name, logo_path, created_at + RETURNING id, name, logo_path, deletion_state, created_at """ ), {"owner_user_id": user_id, "name": payload.name}, @@ -58,7 +140,7 @@ def list_brands(self, user_id: str) -> list[Brand]: rows = connection.execute( text( """ - SELECT id, name, logo_path, created_at + SELECT id, name, logo_path, deletion_state, created_at FROM brands WHERE owner_user_id = :owner_user_id ORDER BY created_at DESC, id DESC @@ -74,7 +156,7 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand: row = connection.execute( text( """ - SELECT id, name, logo_path, created_at + SELECT id, name, logo_path, deletion_state, created_at FROM brands WHERE id = :brand_id AND owner_user_id = :owner_user_id """ @@ -88,21 +170,32 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand: return self._to_brand(row) def get_logo_path(self, user_id: str, brand_id: UUID) -> str | None: - with self.engine.connect() as connection: - logo_path = connection.execute( - text( - """ - SELECT logo_path - FROM brands - WHERE id = :brand_id AND owner_user_id = :owner_user_id - """ - ), - {"brand_id": brand_id, "owner_user_id": user_id}, - ).scalar_one_or_none() + with self.engine.begin() as connection: + row = self.lock_owned_brand_without_asset_operation( + connection, user_id, brand_id + ) + return row["logo_path"] + + def begin_brand_cleanup(self, user_id: str, brand_id: UUID) -> str | None: + with self.engine.begin() as connection: + row = self.lock_owned_brand(connection, user_id, brand_id) + self.require_no_asset_operation(connection, brand_id) + if row["deletion_state"] == "active": + row = connection.execute( + text( + """ + UPDATE brands + SET deletion_state = 'cleanup_required' + WHERE id = :brand_id AND owner_user_id = :owner_user_id + RETURNING id, name, logo_path, deletion_state, created_at + """ + ), + {"brand_id": brand_id, "owner_user_id": user_id}, + ).mappings().one_or_none() - if logo_path is None: - self.get_brand(user_id, brand_id) - return logo_path + if row is None: + raise LookupError("Brand not found.") + return row["logo_path"] def update_logo_path( self, @@ -111,13 +204,16 @@ def update_logo_path( logo_path: str | None, ) -> Brand: with self.engine.begin() as connection: + self.lock_owned_brand_without_asset_operation( + connection, user_id, brand_id + ) row = connection.execute( text( """ UPDATE brands SET logo_path = :logo_path WHERE id = :brand_id AND owner_user_id = :owner_user_id - RETURNING id, name, logo_path, created_at + RETURNING id, name, logo_path, deletion_state, created_at """ ), { @@ -131,8 +227,44 @@ def update_logo_path( raise LookupError("Brand not found.") return self._to_brand(row) - def delete_brand(self, user_id: str, brand_id: UUID) -> None: + def mark_cleanup_required(self, user_id: str, brand_id: UUID) -> Brand: with self.engine.begin() as connection: + current = self.lock_owned_brand(connection, user_id, brand_id) + self.require_no_asset_operation(connection, brand_id) + if current["deletion_state"] == "cleanup_required": + return self._to_brand(current) + row = connection.execute( + text( + """ + UPDATE brands + SET deletion_state = 'cleanup_required' + WHERE id = :brand_id AND owner_user_id = :owner_user_id + RETURNING id, name, logo_path, deletion_state, created_at + """ + ), + {"brand_id": brand_id, "owner_user_id": user_id}, + ).mappings().one_or_none() + + if row is None: + raise LookupError("Brand not found.") + return self._to_brand(row) + + def delete_brand_after_cleanup(self, user_id: str, brand_id: UUID) -> None: + with self.engine.begin() as connection: + current = self.lock_owned_brand(connection, user_id, brand_id) + self.require_no_asset_operation(connection, brand_id) + if current["deletion_state"] != "cleanup_required": + raise BrandCleanupRequiredError + has_provider_keys = connection.execute( + text( + "SELECT EXISTS (" + "SELECT 1 FROM provider_keys WHERE brand_id = :brand_id" + ")" + ), + {"brand_id": brand_id}, + ).scalar_one() + if has_provider_keys: + raise BrandCleanupRequiredError deleted_id = connection.execute( text( """ @@ -147,7 +279,6 @@ def delete_brand(self, user_id: str, brand_id: UUID) -> None: if deleted_id is None: raise LookupError("Brand not found.") - @lru_cache(maxsize=1) def get_brand_store() -> BrandStore: return BrandStore(get_engine()) diff --git a/backend/tests/contract/conftest.py b/backend/tests/contract/conftest.py new file mode 100644 index 0000000..928673f --- /dev/null +++ b/backend/tests/contract/conftest.py @@ -0,0 +1,15 @@ +import os + +import pytest + + +os.environ.setdefault("SUPABASE_URL", "https://example.supabase.co") +os.environ.setdefault("SUPABASE_SECRET_KEY", "test-secret-key") +os.environ.setdefault("SUPABASE_JWT_SECRET", "test-jwt-secret") + + +@pytest.fixture(autouse=True) +def bypass_database_privilege_check_for_contract_tests(monkeypatch: pytest.MonkeyPatch): + from backend.app import main + + monkeypatch.setattr(main, "assert_database_role_privileges", lambda: None) diff --git a/backend/tests/contract/test_brands.py b/backend/tests/contract/test_brands.py index 63a6062..f17f9a6 100644 --- a/backend/tests/contract/test_brands.py +++ b/backend/tests/contract/test_brands.py @@ -11,7 +11,11 @@ from backend.app.main import app from backend.app.models.brand import Brand, BrandCreate from backend.app.routes.brands import get_brand_storage, get_brand_store -from backend.app.services.brand_store import BrandNameTakenError +from backend.app.services.brand_store import ( + BrandCleanupRequiredError, + BrandMutationInProgressError, + BrandNameTakenError, +) @dataclass @@ -20,6 +24,8 @@ class FakeBrandStore: brands: list[Brand] = field(default_factory=list) logo_paths: dict[UUID, str | None] = field(default_factory=dict) owners: dict[UUID, str] = field(default_factory=dict) + cleanup_required: set[UUID] = field(default_factory=set) + asset_operations: set[UUID] = field(default_factory=set) def create_brand(self, user_id: str, payload: BrandCreate) -> Brand: normalized_name = payload.name.casefold() @@ -48,8 +54,25 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand: def get_logo_path(self, user_id: str, brand_id: UUID) -> str | None: self.get_brand(user_id, brand_id) + self._check_mutation(brand_id) return self.logo_paths.get(brand_id) + def begin_brand_cleanup(self, user_id: str, brand_id: UUID) -> str | None: + self.get_brand(user_id, brand_id) + if brand_id in self.asset_operations: + raise BrandMutationInProgressError + self.cleanup_required.add(brand_id) + brand = next(brand for brand in self.brands if brand.id == brand_id) + updated_brand = brand.model_copy(update={"cleanup_state": "cleanup_required"}) + self.brands = [updated_brand if item.id == brand_id else item for item in self.brands] + return self.logo_paths.get(brand_id) + + def _check_mutation(self, brand_id: UUID) -> None: + if brand_id in self.cleanup_required: + raise BrandCleanupRequiredError + if brand_id in self.asset_operations: + raise BrandMutationInProgressError + def update_logo_path( self, user_id: str, @@ -57,6 +80,7 @@ def update_logo_path( logo_path: str | None, ) -> Brand: brand = self.get_brand(user_id, brand_id) + self._check_mutation(brand_id) self.logo_paths[brand_id] = logo_path logo_url = ( f"https://example.supabase.co/storage/v1/object/public/brand-assets/{logo_path}" @@ -69,21 +93,44 @@ def update_logo_path( def delete_brand(self, user_id: str, brand_id: UUID) -> None: self.get_brand(user_id, brand_id) + self._check_mutation(brand_id) + self.brands = [brand for brand in self.brands if brand.id != brand_id] + self.logo_paths.pop(brand_id, None) + self.owners.pop(brand_id, None) + + def delete_brand_after_cleanup(self, user_id: str, brand_id: UUID) -> None: + self.get_brand(user_id, brand_id) + if brand_id in self.asset_operations: + raise BrandMutationInProgressError + if brand_id not in self.cleanup_required: + raise BrandCleanupRequiredError self.brands = [brand for brand in self.brands if brand.id != brand_id] self.logo_paths.pop(brand_id, None) self.owners.pop(brand_id, None) + def mark_cleanup_required(self, user_id: str, brand_id: UUID) -> Brand: + brand = self.get_brand(user_id, brand_id) + self.cleanup_required.add(brand_id) + updated_brand = brand.model_copy(update={"cleanup_state": "cleanup_required"}) + self.brands = [updated_brand if item.id == brand_id else item for item in self.brands] + return updated_brand + @dataclass class FakeBrandStorage: uploads: list[tuple[str, bytes, str]] = field(default_factory=list) deletes: list[str] = field(default_factory=list) + fail_delete: bool = False async def upload_logo(self, path: str, data: bytes, content_type: str) -> None: self.uploads.append((path, data, content_type)) async def delete_logo(self, path: str) -> None: self.deletes.append(path) + if self.fail_delete: + from backend.app.services.brand_storage import BrandStorageError + + raise BrandStorageError @pytest.mark.parametrize("name", ["", " ", "A", "A" * 121]) @@ -149,6 +196,7 @@ def test_create_brand_returns_public_contract_shape(): "id": "22222222-2222-2222-2222-222222222222", "name": "Acme Coffee", "logo_url": None, + "cleanup_state": "normal", "created_at": "2026-07-25T00:00:00Z", } finally: @@ -193,12 +241,14 @@ def test_list_brands_returns_empty_and_populated_contract_shapes(): "id": "33333333-3333-3333-3333-333333333333", "name": "New Brand", "logo_url": None, + "cleanup_state": "normal", "created_at": "2026-07-26T00:00:00Z", }, { "id": "22222222-2222-2222-2222-222222222222", "name": "First Brand", "logo_url": None, + "cleanup_state": "normal", "created_at": "2026-07-25T00:00:00Z", }, ] @@ -250,6 +300,92 @@ def test_get_brand_returns_owned_brand_and_opaque_not_found_errors(): app.dependency_overrides.clear() +def test_cleanup_required_brand_remains_visible_in_list_and_detail(): + brand = Brand( + id=UUID("22222222-2222-2222-2222-222222222222"), + name="Cleanup Brand", + logo_url=None, + cleanup_state="cleanup_required", + created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + ) + store = FakeBrandStore(brands=[brand], cleanup_required={brand.id}) + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + user_id="11111111-1111-1111-1111-111111111111", + email="owner@example.com", + access_token="eyJ...", + ) + app.dependency_overrides[get_brand_store] = lambda: store + + try: + with TestClient(app) as client: + list_response = client.get("/api/v1/brands") + detail_response = client.get(f"/api/v1/brands/{brand.id}") + + assert list_response.status_code == 200 + assert list_response.json()["brands"][0]["cleanup_state"] == "cleanup_required" + assert detail_response.status_code == 200 + assert detail_response.json()["cleanup_state"] == "cleanup_required" + assert "deletion_state" not in detail_response.json() + finally: + app.dependency_overrides.clear() + + +@pytest.mark.parametrize( + ("fence_field", "expected_code"), + [ + ("cleanup_required", "BRAND_CLEANUP_REQUIRED"), + ("asset_operations", "BRAND_MUTATION_IN_PROGRESS"), + ], +) +def test_logo_and_brand_mutations_respect_brand_fences( + fence_field: str, + expected_code: str, +): + brand = Brand( + id=UUID("22222222-2222-2222-2222-222222222222"), + name="Acme Coffee", + logo_url=None, + created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + ) + store = FakeBrandStore(brands=[brand]) + getattr(store, fence_field).add(brand.id) + storage = FakeBrandStorage() + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + user_id="11111111-1111-1111-1111-111111111111", + email="owner@example.com", + access_token="eyJ...", + ) + app.dependency_overrides[get_brand_store] = lambda: store + app.dependency_overrides[get_brand_storage] = lambda: storage + + try: + with TestClient(app) as client: + upload_response = client.post( + f"/api/v1/brands/{brand.id}/logo", + files={"file": ("logo.png", b"\x89PNG\r\n\x1a\nvalid", "image/png")}, + ) + remove_response = client.delete(f"/api/v1/brands/{brand.id}/logo") + delete_response = client.request( + "DELETE", + f"/api/v1/brands/{brand.id}", + json={"confirm_name": brand.name}, + ) + + for response in (upload_response, remove_response): + assert response.status_code == 409 + assert response.json()["error"]["code"] == expected_code + if fence_field == "cleanup_required": + assert delete_response.status_code == 204 + else: + assert delete_response.status_code == 409 + assert delete_response.json()["error"]["code"] == expected_code + assert storage.uploads == [] + assert storage.deletes == [] + assert store.brands == ([] if fence_field == "cleanup_required" else [brand]) + finally: + app.dependency_overrides.clear() + + @pytest.mark.parametrize( ("content", "content_type"), [ @@ -423,6 +559,59 @@ def test_delete_brand_with_exact_confirmation_removes_brand_and_logo(): app.dependency_overrides.clear() +def test_delete_brand_storage_failure_retains_cleanup_required_brand(): + brand = Brand( + id=UUID("22222222-2222-2222-2222-222222222222"), + name="Acme Coffee", + logo_url="https://example.supabase.co/logo.png", + created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + ) + logo_path = f"brands/{brand.id}/logo.png" + store = FakeBrandStore(brands=[brand], logo_paths={brand.id: logo_path}) + storage = FakeBrandStorage(fail_delete=True) + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + user_id="11111111-1111-1111-1111-111111111111", + email="owner@example.com", + access_token="eyJ...", + ) + app.dependency_overrides[get_brand_store] = lambda: store + app.dependency_overrides[get_brand_storage] = lambda: storage + + try: + with TestClient(app) as client: + response = client.request( + "DELETE", + f"/api/v1/brands/{brand.id}", + json={"confirm_name": brand.name}, + ) + + assert response.status_code == 503 + assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED" + assert response.json()["error"]["message"] == ( + "Brand cleanup did not complete. Retry deletion." + ) + assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"] + assert len(store.brands) == 1 + assert store.brands[0].cleanup_state == "cleanup_required" + assert store.logo_paths[brand.id] == logo_path + assert storage.deletes == [logo_path] + + storage.fail_delete = False + with TestClient(app) as client: + retry = client.request( + "DELETE", + f"/api/v1/brands/{brand.id}", + json={"confirm_name": brand.name}, + ) + + assert retry.status_code == 204 + assert retry.content == b"" + assert store.brands == [] + assert storage.deletes == [logo_path, logo_path] + finally: + app.dependency_overrides.clear() + + @pytest.mark.parametrize("payload", [{}, {"confirm_name": "acme coffee"}]) def test_delete_brand_rejects_missing_or_wrong_confirmation_without_mutation( payload: dict[str, str], @@ -470,6 +659,8 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra store = FakeBrandStore( brands=[brand], owners={brand.id: "99999999-9999-9999-9999-999999999999"}, + cleanup_required={brand.id}, + asset_operations={brand.id}, ) storage = FakeBrandStorage() app.dependency_overrides[get_current_user] = lambda: CurrentUser( @@ -482,6 +673,19 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra try: with TestClient(app) as client: + non_owner_upload_response = client.post( + f"/api/v1/brands/{brand.id}/logo", + files={ + "file": ( + "logo.png", + b"\x89PNG\r\n\x1a\nvalid", + "image/png", + ) + }, + ) + non_owner_remove_response = client.delete( + f"/api/v1/brands/{brand.id}/logo" + ) non_owner_response = client.request( "DELETE", f"/api/v1/brands/{brand.id}", @@ -494,7 +698,13 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra ) assert non_owner_response.status_code == nonexistent_response.status_code == 404 - for response in (non_owner_response, nonexistent_response): + for response in ( + non_owner_upload_response, + non_owner_remove_response, + non_owner_response, + nonexistent_response, + ): + assert response.status_code == 404 error = response.json()["error"] assert error["code"] == "BRAND_NOT_FOUND" assert error["message"] == "Brand not found." diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py new file mode 100644 index 0000000..84d0c2c --- /dev/null +++ b/backend/tests/contract/test_provider_keys.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import time +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock + +import pytest +from fastapi import HTTPException, Request +from starlette.responses import Response + +from backend.app import auth, config, main + + +def _request(path: str = "/api/v1/brands/brand-id/keys/key-id/validate") -> Request: + return Request({"type": "http", "method": "POST", "path": path, "headers": []}) + + +def test_validation_route_gets_absolute_deadline_before_request_handling(): + request = _request() + observed: dict[str, float | None] = {} + before = time.monotonic() + + async def call_next(incoming: Request) -> Response: + observed["deadline"] = main.get_validation_deadline(incoming) + return Response(status_code=204) + + response = asyncio.run(main.request_context_middleware(request, call_next)) + + assert response.status_code == 204 + assert observed["deadline"] is not None + assert before + 14.9 <= observed["deadline"] <= before + 15.1 + + +def test_non_validation_route_has_no_validation_deadline(): + request = _request("/api/v1/brands/brand-id/keys") + + async def call_next(incoming: Request) -> Response: + assert main.get_validation_deadline(incoming) is None + return Response(status_code=204) + + asyncio.run(main.request_context_middleware(request, call_next)) + + +@pytest.mark.parametrize( + ("status_code", "code", "message"), + [ + (409, "KEY_CLEANUP_REQUIRED", "Key cleanup is required. Retry deletion."), + (409, "BRAND_CLEANUP_REQUIRED", "Brand cleanup is required. Retry deletion."), + (503, "KEY_CLEANUP_REQUIRED", "Key cleanup did not complete. Retry deletion."), + (503, "BRAND_CLEANUP_REQUIRED", "Brand cleanup did not complete. Retry deletion."), + ], +) +def test_fixed_cleanup_errors_have_safe_message_and_request_id_parity( + status_code: int, + code: str, + message: str, +): + request = _request() + request.state.request_id = "11111111-1111-1111-1111-111111111111" + + response = main.safe_error_response(request, status_code, code) + body = json.loads(response.body) + + assert response.status_code == status_code + assert body == { + "error": { + "code": code, + "message": message, + "request_id": "11111111-1111-1111-1111-111111111111", + } + } + assert response.headers["X-Request-Id"] == body["error"]["request_id"] + + +def test_json_log_formatter_allows_only_audited_safe_fields(): + raw_key = "provider-secret-A1B2" + record = logging.LogRecord( + "provider_keys", + logging.INFO, + __file__, + 1, + "provider validation failed: %s", + (raw_key,), + None, + ) + record.event = "provider_key_validation" + record.request_id = "request-id" + record.provider = "openai" + record.code = "VALID" + record.duration_ms = 42 + record.provider_request_id = "provider-request-id" + record.body = {"key": raw_key} + record.label = "Production Key" + record.key_hint = "***A1B2" + record.vault_secret_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + record.sql_parameters = {"secret": raw_key} + record.provider_body = f'{{"key":"{raw_key}"}}' + record.authorization = f"Bearer {raw_key}" + record.exception = f"failed with {raw_key}" + record.user_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + record.email = "owner@example.com" + + rendered = main._JsonLogFormatter().format(record) + + assert json.loads(rendered) == { + "level": "INFO", + "logger": "provider_keys", + "event": "provider_key_validation", + "request_id": "request-id", + "provider": "openai", + "code": "VALID", + "duration_ms": 42, + "provider_request_id": "provider-request-id", + } + for secret in ( + raw_key, + "Production Key", + "***A1B2", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "Bearer", + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "owner@example.com", + ): + assert secret not in rendered + + +def test_engine_hides_parameters_and_bounds_database_waits(monkeypatch: pytest.MonkeyPatch): + create_engine = Mock(return_value=Mock()) + monkeypatch.setattr(config, "create_engine", create_engine) + monkeypatch.setattr( + config, + "load_settings", + lambda: SimpleNamespace(database_url="postgresql://backend:secret@db/postgres"), + ) + config.get_engine.cache_clear() + + try: + config.get_engine() + finally: + config.get_engine.cache_clear() + + _, kwargs = create_engine.call_args + assert kwargs["hide_parameters"] is True + assert kwargs["pool_timeout"] == 2 + assert kwargs["connect_args"]["connect_timeout"] == 2 + assert "statement_timeout=2000" in kwargs["connect_args"]["options"] + assert "lock_timeout=2000" in kwargs["connect_args"]["options"] + + +def test_database_privilege_assertion_fails_closed_without_database_url( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr( + config, + "load_settings", + lambda: SimpleNamespace(database_url=None), + ) + get_engine = Mock(side_effect=RuntimeError("DATABASE_URL is required")) + monkeypatch.setattr(config, "get_engine", get_engine) + + with pytest.raises(RuntimeError, match="DATABASE_URL is required"): + config.assert_database_role_privileges() + + get_engine.assert_called_once_with() + + +def test_startup_invokes_database_privilege_assertion(monkeypatch: pytest.MonkeyPatch): + assertion = Mock() + monkeypatch.setattr(main, "assert_database_role_privileges", assertion) + + async def start_and_stop() -> None: + async with main._lifespan(main.app): + pass + + asyncio.run(start_and_stop()) + + assertion.assert_called_once_with() + + +def test_database_privilege_assertion_rejects_client_or_underprivileged_role( + monkeypatch: pytest.MonkeyPatch, +): + connection = Mock() + connection.execute.return_value.mappings.return_value.one.return_value = { + "role_name": "authenticated", + "is_superuser": False, + "role_is_private": False, + "can_bypass_forced_rls": False, + "application_dml": False, + "vault_schema_usage": False, + "vault_create": False, + "vault_decrypt": False, + "vault_secret_select": False, + "vault_secret_delete": False, + "vault_least_privilege": False, + } + engine = MagicMock() + engine.connect.return_value.__enter__.return_value = connection + monkeypatch.setattr( + config, + "load_settings", + lambda: SimpleNamespace( + database_url="postgresql://db/postgres", + supabase_url="https://hosted.supabase.co", + ), + ) + monkeypatch.setattr(config, "get_engine", lambda: engine) + + with pytest.raises(RuntimeError, match="database role lacks required backend privileges"): + config.assert_database_role_privileges() + + +@pytest.mark.parametrize( + ("role_name", "is_superuser"), + [("postgres", True), ("service_role", False)], +) +def test_hosted_database_privilege_assertion_rejects_broad_or_client_roles( + monkeypatch: pytest.MonkeyPatch, + role_name: str, + is_superuser: bool, +): + privileges = { + "role_name": role_name, + "is_superuser": is_superuser, + "role_is_private": role_name != "service_role", + "can_bypass_forced_rls": True, + "application_dml": True, + "vault_schema_usage": True, + "vault_create": True, + "vault_decrypt": True, + "vault_secret_select": True, + "vault_secret_delete": True, + "vault_least_privilege": False, + } + connection = Mock() + connection.execute.return_value.mappings.return_value.one.return_value = privileges + engine = MagicMock() + engine.connect.return_value.__enter__.return_value = connection + monkeypatch.setattr( + config, + "load_settings", + lambda: SimpleNamespace( + database_url="postgresql://db/postgres", + supabase_url="https://hosted.supabase.co", + ), + ) + monkeypatch.setattr(config, "get_engine", lambda: engine) + + with pytest.raises(RuntimeError, match="database role lacks required backend privileges"): + config.assert_database_role_privileges() + + +def test_local_postgres_privilege_assertion_allows_documented_exception( + monkeypatch: pytest.MonkeyPatch, +): + privileges = { + "role_name": "postgres", + "is_superuser": False, + "role_is_private": True, + "can_bypass_forced_rls": True, + "application_dml": True, + "vault_schema_usage": True, + "vault_create": True, + "vault_decrypt": True, + "vault_secret_select": True, + "vault_secret_delete": True, + "vault_least_privilege": False, + } + connection = Mock() + connection.execute.return_value.mappings.return_value.one.return_value = privileges + engine = MagicMock() + engine.connect.return_value.__enter__.return_value = connection + monkeypatch.setattr( + config, + "load_settings", + lambda: SimpleNamespace( + database_url="postgresql://localhost/postgres", + supabase_url="http://127.0.0.1:54321", + ), + ) + monkeypatch.setattr(config, "get_engine", lambda: engine) + + config.assert_database_role_privileges() + + +def test_jwks_timeout_uses_remaining_validation_budget_only( + monkeypatch: pytest.MonkeyPatch, +): + created_timeouts: list[float] = [] + + class FakeJWKClient: + def __init__(self, uri: str, **kwargs): + created_timeouts.append(kwargs.get("timeout", 30)) + + def get_signing_key_from_jwt(self, token: str): + raise auth.jwt.PyJWTError + + monkeypatch.setattr(auth, "PyJWKClient", FakeJWKClient) + monkeypatch.setattr( + auth, + "load_settings", + lambda: SimpleNamespace( + supabase_url="https://example.supabase.co", + supabase_jwt_secret="unused", + ), + ) + monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "ES256"}) + auth._get_cached_jwks_client.cache_clear() + + validation_request = _request() + validation_request.state.validation_deadline = time.monotonic() + 0.5 + with pytest.raises(HTTPException): + asyncio.run(auth.get_current_user(validation_request, "Bearer token")) + + normal_request = _request("/api/v1/me") + with pytest.raises(HTTPException): + asyncio.run(auth.get_current_user(normal_request, "Bearer token")) + + assert 0 < created_timeouts[0] <= 0.5 + assert created_timeouts[1] == 30 + auth._get_cached_jwks_client.cache_clear() + + +def test_expired_validation_budget_skips_jwks_fetch(monkeypatch: pytest.MonkeyPatch): + client = Mock() + monkeypatch.setattr(auth, "PyJWKClient", client) + monkeypatch.setattr( + auth, + "load_settings", + lambda: SimpleNamespace( + supabase_url="https://example.supabase.co", + supabase_jwt_secret="unused", + ), + ) + monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "ES256"}) + request = _request() + request.state.validation_deadline = time.monotonic() - 0.01 + + with pytest.raises(HTTPException): + asyncio.run(auth.get_current_user(request, "Bearer token")) + + client.assert_not_called() + + +def test_expired_validation_budget_skips_hs256_decode(monkeypatch: pytest.MonkeyPatch): + decode = Mock() + monkeypatch.setattr(auth.jwt, "decode", decode) + monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "HS256"}) + monkeypatch.setattr( + auth, + "load_settings", + lambda: SimpleNamespace( + supabase_url="https://example.supabase.co", + supabase_jwt_secret="unused", + ), + ) + request = _request() + request.state.validation_deadline = time.monotonic() - 0.01 + + with pytest.raises(HTTPException): + asyncio.run(auth.get_current_user(request, "Bearer token")) + + decode.assert_not_called() + + +def test_validation_budget_bounds_total_jwks_work(monkeypatch: pytest.MonkeyPatch): + class SlowJWKClient: + def __init__(self, uri: str, **kwargs): + pass + + def get_signing_key_from_jwt(self, token: str): + time.sleep(0.2) + raise auth.jwt.PyJWTError + + async def slow_to_thread(function, *args): + await asyncio.sleep(0.2) + return function(*args) + + monkeypatch.setattr(auth, "PyJWKClient", SlowJWKClient) + monkeypatch.setattr(asyncio, "to_thread", slow_to_thread) + monkeypatch.setattr( + auth, + "load_settings", + lambda: SimpleNamespace( + supabase_url="https://example.supabase.co", + supabase_jwt_secret="unused", + ), + ) + monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "ES256"}) + request = _request() + request.state.validation_deadline = time.monotonic() + 0.03 + started = time.monotonic() + + with pytest.raises(HTTPException): + asyncio.run(auth.get_current_user(request, "Bearer token")) + + assert time.monotonic() - started < 0.15 diff --git a/backend/tests/integration/test_brand_crud.py b/backend/tests/integration/test_brand_crud.py index 9af1b97..83d99e5 100644 --- a/backend/tests/integration/test_brand_crud.py +++ b/backend/tests/integration/test_brand_crud.py @@ -8,6 +8,7 @@ import httpx import pytest from fastapi.testclient import TestClient +from sqlalchemy import text PNG_A = base64.b64decode( @@ -50,6 +51,16 @@ def _signup_and_login( return user_id, token_response.json()["access_token"] +def _hard_delete_owned_brands(user_id: str) -> None: + from backend.app.config import get_engine + + with get_engine().begin() as connection: + connection.execute( + text("DELETE FROM brands WHERE owner_user_id = :owner_user_id"), + {"owner_user_id": user_id}, + ) + + def test_create_brand_against_real_supabase(): supabase_url = _required_env("SUPABASE_URL") supabase_key = _required_env("SUPABASE_SECRET_KEY") @@ -223,6 +234,7 @@ def test_create_brand_against_real_supabase(): assert empty_list_response.json() == {"brands": []} assert created_brand["name"] == "Acme Coffee" assert created_brand["logo_url"] is None + assert created_brand["cleanup_state"] == "normal" assert single_list_response.status_code == 200 assert [brand["name"] for brand in single_list_response.json()["brands"]] == [ "Acme Coffee" @@ -235,6 +247,10 @@ def test_create_brand_against_real_supabase(): ] assert detail_response.status_code == 200 assert detail_response.json() == created_brand + assert all( + brand["cleanup_state"] == "normal" + for brand in multi_list_response.json()["brands"] + ) assert duplicate_response.status_code == 409 assert duplicate_response.json()["error"]["code"] == "BRAND_NAME_TAKEN" assert empty_response.status_code == 400 @@ -278,6 +294,7 @@ def test_create_brand_against_real_supabase(): ) assert cleanup_response.is_success if user_id: + _hard_delete_owned_brands(user_id) supabase_client.delete( f"{supabase_url}/auth/v1/admin/users/{user_id}", headers={ @@ -385,6 +402,7 @@ def test_delete_brands_with_and_without_logo_against_real_supabase(): ) assert cleanup_response.is_success if user_id: + _hard_delete_owned_brands(user_id) supabase_client.delete( f"{supabase_url}/auth/v1/admin/users/{user_id}", headers={ @@ -441,6 +459,267 @@ def test_list_and_open_fifty_brands_within_target_time(): assert elapsed < 10 finally: if user_id: + _hard_delete_owned_brands(user_id) + supabase_client.delete( + f"{supabase_url}/auth/v1/admin/users/{user_id}", + headers={ + "apikey": supabase_key, + "Authorization": f"Bearer {supabase_key}", + }, + ) + + +def test_cleanup_state_mutation_fences_and_authenticated_delete_restriction(): + supabase_url = _required_env("SUPABASE_URL") + supabase_key = _required_env("SUPABASE_SECRET_KEY") + _required_env("SUPABASE_JWT_SECRET") + _required_env("DATABASE_URL") + + from backend.app.config import get_engine + from backend.app.main import app + + user_id: str | None = None + active_brand_id: str | None = None + cleanup_brand_id: str | None = None + operation_id = str(uuid4()) + + with httpx.Client(timeout=30.0) as supabase_client: + try: + user_id, access_token = _signup_and_login( + supabase_client, + supabase_url, + supabase_key, + f"brand-fences-{uuid4().hex[:12]}@example.com", + "12345678", + ) + headers = {"Authorization": f"Bearer {access_token}"} + + with TestClient(app) as api_client: + active_response = api_client.post( + "/api/v1/brands", + headers=headers, + json={"name": "Active Brand"}, + ) + cleanup_response = api_client.post( + "/api/v1/brands", + headers=headers, + json={"name": "Cleanup Brand"}, + ) + assert ( + active_response.status_code + == cleanup_response.status_code + == 201 + ) + active_brand_id = active_response.json()["id"] + cleanup_brand_id = cleanup_response.json()["id"] + assert active_response.json()["cleanup_state"] == "normal" + + engine = get_engine() + with engine.begin() as connection: + connection.execute( + text( + """ + UPDATE brands + SET deletion_state = 'cleanup_required' + WHERE id = :brand_id + """ + ), + {"brand_id": cleanup_brand_id}, + ) + connection.execute( + text( + """ + INSERT INTO brand_asset_operations ( + id, brand_id, operation, state, remote_status + ) + VALUES ( + :id, :brand_id, 'upload', 'in_progress', 'pending' + ) + """ + ), + {"id": operation_id, "brand_id": active_brand_id}, + ) + + list_response = api_client.get("/api/v1/brands", headers=headers) + cleanup_detail = api_client.get( + f"/api/v1/brands/{cleanup_brand_id}", headers=headers + ) + cleanup_upload = api_client.post( + f"/api/v1/brands/{cleanup_brand_id}/logo", + headers=headers, + files={"file": ("logo.png", PNG_A, "image/png")}, + ) + cleanup_remove = api_client.delete( + f"/api/v1/brands/{cleanup_brand_id}/logo", headers=headers + ) + cleanup_delete = api_client.request( + "DELETE", + f"/api/v1/brands/{cleanup_brand_id}", + headers=headers, + json={"confirm_name": "Cleanup Brand"}, + ) + active_upload = api_client.post( + f"/api/v1/brands/{active_brand_id}/logo", + headers=headers, + files={"file": ("logo.png", PNG_A, "image/png")}, + ) + active_delete = api_client.request( + "DELETE", + f"/api/v1/brands/{active_brand_id}", + headers=headers, + json={"confirm_name": "Active Brand"}, + ) + + assert list_response.status_code == 200 + states = { + brand["id"]: brand["cleanup_state"] + for brand in list_response.json()["brands"] + } + assert states == { + active_brand_id: "normal", + cleanup_brand_id: "cleanup_required", + } + assert cleanup_detail.status_code == 200 + assert cleanup_detail.json()["cleanup_state"] == "cleanup_required" + assert "deletion_state" not in cleanup_detail.json() + for response in (cleanup_upload, cleanup_remove): + assert response.status_code == 409 + assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED" + assert cleanup_delete.status_code == 204 + for response in (active_upload, active_delete): + assert response.status_code == 409 + assert ( + response.json()["error"]["code"] + == "BRAND_MUTATION_IN_PROGRESS" + ) + + authenticated_delete = supabase_client.delete( + f"{supabase_url}/rest/v1/brands", + params={"id": f"eq.{active_brand_id}"}, + headers={ + "apikey": supabase_key, + "Authorization": f"Bearer {access_token}", + }, + ) + assert authenticated_delete.status_code in {401, 403} + assert api_client.get( + f"/api/v1/brands/{active_brand_id}", headers=headers + ).status_code == 200 + + auth_user_delete = supabase_client.delete( + f"{supabase_url}/auth/v1/admin/users/{user_id}", + headers={ + "apikey": supabase_key, + "Authorization": f"Bearer {supabase_key}", + }, + ) + assert not auth_user_delete.is_success + with engine.connect() as connection: + assert connection.execute( + text("SELECT count(*) FROM brands WHERE owner_user_id = :user_id"), + {"user_id": user_id}, + ).scalar_one() == 1 + finally: + if active_brand_id: + with get_engine().begin() as connection: + connection.execute( + text("DELETE FROM brand_asset_operations WHERE id = :id"), + {"id": operation_id}, + ) + if user_id: + _hard_delete_owned_brands(user_id) + supabase_client.delete( + f"{supabase_url}/auth/v1/admin/users/{user_id}", + headers={ + "apikey": supabase_key, + "Authorization": f"Bearer {supabase_key}", + }, + ) + + +def test_brand_storage_failure_persists_cleanup_required_retry_anchor(): + supabase_url = _required_env("SUPABASE_URL") + supabase_key = _required_env("SUPABASE_SECRET_KEY") + _required_env("SUPABASE_JWT_SECRET") + _required_env("DATABASE_URL") + + from backend.app.config import get_engine + from backend.app.main import app + from backend.app.routes.brands import get_brand_storage + from backend.app.services.brand_storage import BrandStorageError + + class ControllableStorage: + fail_delete = True + + async def delete_logo(self, path: str) -> None: + if self.fail_delete: + raise BrandStorageError + + user_id: str | None = None + brand_id = str(uuid4()) + logo_path = f"brands/{brand_id}/logo.png" + with httpx.Client(timeout=30.0) as supabase_client: + try: + user_id, access_token = _signup_and_login( + supabase_client, + supabase_url, + supabase_key, + f"brand-cleanup-anchor-{uuid4().hex[:12]}@example.com", + "12345678", + ) + with get_engine().begin() as connection: + connection.execute( + text( + "INSERT INTO brands (id, owner_user_id, name, logo_path) " + "VALUES (:id, :owner_user_id, 'Cleanup Anchor', :logo_path)" + ), + { + "id": brand_id, + "owner_user_id": user_id, + "logo_path": logo_path, + }, + ) + + storage = ControllableStorage() + app.dependency_overrides[get_brand_storage] = lambda: storage + with TestClient(app) as api_client: + response = api_client.request( + "DELETE", + f"/api/v1/brands/{brand_id}", + headers={"Authorization": f"Bearer {access_token}"}, + json={"confirm_name": "Cleanup Anchor"}, + ) + + assert response.status_code == 503 + assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED" + with get_engine().connect() as connection: + retained = connection.execute( + text( + "SELECT deletion_state, logo_path FROM brands WHERE id = :id" + ), + {"id": brand_id}, + ).one() + assert retained.deletion_state == "cleanup_required" + assert retained.logo_path == logo_path + + storage.fail_delete = False + with TestClient(app) as api_client: + retry = api_client.request( + "DELETE", + f"/api/v1/brands/{brand_id}", + headers={"Authorization": f"Bearer {access_token}"}, + json={"confirm_name": "Cleanup Anchor"}, + ) + assert retry.status_code == 204 + with get_engine().connect() as connection: + assert connection.execute( + text("SELECT count(*) FROM brands WHERE id = :id"), + {"id": brand_id}, + ).scalar_one() == 0 + finally: + app.dependency_overrides.clear() + if user_id: + _hard_delete_owned_brands(user_id) supabase_client.delete( f"{supabase_url}/auth/v1/admin/users/{user_id}", headers={ diff --git a/backend/tests/integration/test_brand_rls.py b/backend/tests/integration/test_brand_rls.py index 607b792..7c3450c 100644 --- a/backend/tests/integration/test_brand_rls.py +++ b/backend/tests/integration/test_brand_rls.py @@ -65,65 +65,39 @@ def _visible_brand_ids(engine: Engine, access_token: str, brand_id: str) -> list return [str(row) for row in rows] -def _cross_owner_mutation_results( +def _assert_brand_mutations_are_blocked( engine: Engine, access_token: str, brand_id: str, -) -> tuple[list[str], list[str]]: - claims = jwt.decode(access_token, options={"verify_signature": False}) - - with engine.begin() as connection: - connection.execute(text("SET LOCAL ROLE authenticated")) - connection.execute( - text("SELECT set_config('request.jwt.claims', :claims, true)"), - {"claims": json.dumps(claims)}, - ) - updated = connection.execute( - text( - """ - UPDATE brands - SET name = 'Unauthorized Update' - WHERE id = :brand_id - RETURNING id - """ - ), - {"brand_id": brand_id}, - ).scalars().all() - deleted = connection.execute( - text("DELETE FROM brands WHERE id = :brand_id RETURNING id"), - {"brand_id": brand_id}, - ).scalars().all() - - return [str(row) for row in updated], [str(row) for row in deleted] - - -def _assert_cross_owner_insert_is_blocked( - engine: Engine, - access_token: str, owner_user_id: str, ) -> None: claims = jwt.decode(access_token, options={"verify_signature": False}) - - with pytest.raises(DBAPIError) as exc_info: - with engine.begin() as connection: - connection.execute(text("SET LOCAL ROLE authenticated")) - connection.execute( - text("SELECT set_config('request.jwt.claims', :claims, true)"), - {"claims": json.dumps(claims)}, - ) - connection.execute( - text( - """ - INSERT INTO brands (owner_user_id, name) - VALUES (:owner_user_id, 'Unauthorized Insert') - """ - ), - {"owner_user_id": owner_user_id}, - ) - - original = exc_info.value.orig - sqlstate = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None) - assert sqlstate == "42501" + statements = ( + ( + "INSERT INTO brands (owner_user_id, name) " + "VALUES (:owner_user_id, 'Unauthorized Insert')", + {"owner_user_id": owner_user_id}, + ), + ( + "UPDATE brands SET name = 'Unauthorized Update' WHERE id = :brand_id", + {"brand_id": brand_id}, + ), + ("DELETE FROM brands WHERE id = :brand_id", {"brand_id": brand_id}), + ) + for statement, parameters in statements: + with pytest.raises(DBAPIError) as exc_info: + with engine.begin() as connection: + connection.execute(text("SET LOCAL ROLE authenticated")) + connection.execute( + text("SELECT set_config('request.jwt.claims', :claims, true)"), + {"claims": json.dumps(claims)}, + ) + connection.execute(text(statement), parameters) + original = exc_info.value.orig + sqlstate = getattr(original, "sqlstate", None) or getattr( + original, "pgcode", None + ) + assert sqlstate == "42501" def test_brand_operations_are_owner_scoped_at_api_and_database_layers(): @@ -250,8 +224,8 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers(): engine = get_engine() assert _visible_brand_ids(engine, token_a, brand_id) == [brand_id] assert _visible_brand_ids(engine, token_b, brand_id) == [] - assert _cross_owner_mutation_results(engine, token_b, brand_id) == ([], []) - _assert_cross_owner_insert_is_blocked(engine, token_b, user_a_id) + _assert_brand_mutations_are_blocked(engine, token_a, brand_id, user_a_id) + _assert_brand_mutations_are_blocked(engine, token_b, brand_id, user_a_id) with TestClient(app) as api_client: post_rls_owner_response = api_client.get( @@ -279,6 +253,13 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers(): }, ) assert cleanup_response.is_success + from backend.app.config import get_engine + + with get_engine().begin() as connection: + connection.execute( + text("DELETE FROM brands WHERE id = :brand_id"), + {"brand_id": brand_id}, + ) for user_id in (user_a_id, user_b_id): if user_id: supabase_client.delete( diff --git a/backend/tests/integration/test_provider_key_rls.py b/backend/tests/integration/test_provider_key_rls.py new file mode 100644 index 0000000..629cdb0 --- /dev/null +++ b/backend/tests/integration/test_provider_key_rls.py @@ -0,0 +1,666 @@ +from __future__ import annotations + +import json +import os +from uuid import uuid4 + +import httpx +import jwt +import pytest +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import DBAPIError + + +SAFE_COLUMNS = ( + "id, provider, label, key_hint, lifecycle, is_active, is_valid, " + "last_validated_at, last_validation_error, created_at" +) + + +class SecurityFixture(dict): + def __repr__(self) -> str: + return "" + + +def _required_env(name: str) -> str: + value = os.getenv(name) + if not value: + pytest.skip(f"{name} is required for integration tests") + return value + + +def _signup_and_login( + client: httpx.Client, + supabase_url: str, + supabase_key: str, + email: str, +) -> tuple[str, str]: + password = "12345678" + signup = client.post( + f"{supabase_url}/auth/v1/signup", + headers={"apikey": supabase_key, "Content-Type": "application/json"}, + json={"email": email, "password": password}, + ) + assert signup.status_code in {200, 201} + token = client.post( + f"{supabase_url}/auth/v1/token?grant_type=password", + headers={"apikey": supabase_key, "Content-Type": "application/json"}, + json={"email": email, "password": password}, + ) + assert token.status_code == 200 + return signup.json()["user"]["id"], token.json()["access_token"] + + +def _execute_as_authenticated( + engine: Engine, + access_token: str, + statement: str, + parameters: dict | None = None, +): + claims = jwt.decode(access_token, options={"verify_signature": False}) + with engine.begin() as connection: + connection.execute(text("SET LOCAL ROLE authenticated")) + connection.execute( + text("SELECT set_config('request.jwt.claims', :claims, true)"), + {"claims": json.dumps(claims)}, + ) + return connection.execute(text(statement), parameters or {}).mappings().all() + + +def _assert_permission_denied(operation) -> None: + with pytest.raises(DBAPIError) as exc_info: + operation() + original = exc_info.value.orig + sqlstate = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None) + assert sqlstate == "42501" + + +@pytest.fixture +def security_fixture(): + supabase_url = _required_env("SUPABASE_URL") + supabase_key = _required_env("SUPABASE_SECRET_KEY") + _required_env("DATABASE_URL") + + from backend.app.config import get_engine + + engine = get_engine() + user_ids: list[str] = [] + brand_ids: list[str] = [] + key_ids: list[str] = [] + with httpx.Client(timeout=30.0) as client: + try: + user_a, token_a = _signup_and_login( + client, + supabase_url, + supabase_key, + f"provider-rls-a-{uuid4().hex[:10]}@example.com", + ) + user_b, token_b = _signup_and_login( + client, + supabase_url, + supabase_key, + f"provider-rls-b-{uuid4().hex[:10]}@example.com", + ) + user_ids.extend((user_a, user_b)) + brand_a, brand_b = str(uuid4()), str(uuid4()) + key_a, key_b = str(uuid4()), str(uuid4()) + brand_ids.extend((brand_a, brand_b)) + key_ids.extend((key_a, key_b)) + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO brands (id, owner_user_id, name) VALUES " + "(:brand_a, :user_a, 'Provider RLS A'), " + "(:brand_b, :user_b, 'Provider RLS B')" + ), + { + "brand_a": brand_a, + "user_a": user_a, + "brand_b": brand_b, + "user_b": user_b, + }, + ) + connection.execute( + text( + "INSERT INTO provider_keys " + "(id, brand_id, provider, vault_secret_id, label, key_hint) " + "VALUES (:key_a, :brand_a, 'openai', :vault_a, 'A', '***a-_1'), " + "(:key_b, :brand_b, 'gemini', :vault_b, 'B', '***B_2-')" + ), + { + "key_a": key_a, + "brand_a": brand_a, + "vault_a": str(uuid4()), + "key_b": key_b, + "brand_b": brand_b, + "vault_b": str(uuid4()), + }, + ) + yield SecurityFixture({ + "engine": engine, + "client": client, + "supabase_url": supabase_url, + "supabase_key": supabase_key, + "user_a": user_a, + "token_a": token_a, + "token_b": token_b, + "brand_a": brand_a, + "key_a": key_a, + }) + finally: + if key_ids: + with engine.begin() as connection: + connection.execute( + text( + "DELETE FROM provider_keys " + "WHERE id = ANY(CAST(:ids AS uuid[]))" + ), + {"ids": key_ids}, + ) + if brand_ids: + with engine.begin() as connection: + connection.execute( + text( + "DELETE FROM brands WHERE id = ANY(CAST(:ids AS uuid[]))" + ), + {"ids": brand_ids}, + ) + for user_id in user_ids: + client.delete( + f"{supabase_url}/auth/v1/admin/users/{user_id}", + headers={ + "apikey": supabase_key, + "Authorization": f"Bearer {supabase_key}", + }, + ) + + +def test_schema_constraints_indexes_triggers_and_forced_rls(security_fixture): + engine = security_fixture["engine"] + with engine.connect() as connection: + enums = connection.execute( + text( + "SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) " + "FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid " + "WHERE t.typname = ANY(:names) GROUP BY t.typname" + ), + { + "names": [ + "provider_t", + "provider_key_lifecycle_t", + "brand_deletion_state_t", + ] + }, + ).all() + assert {name: list(values) for name, values in enums} == { + "provider_t": ["openai", "gemini"], + "provider_key_lifecycle_t": ["normal", "cleanup_required"], + "brand_deletion_state_t": ["active", "cleanup_required"], + } + + helper = connection.execute( + text( + "SELECT p.prosecdef, p.provolatile, p.proconfig, " + "has_function_privilege('public', p.oid, 'EXECUTE') " + "FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace " + "WHERE n.nspname = 'private' AND p.proname = 'is_brand_owner' " + "AND pg_get_function_identity_arguments(p.oid) = 'p_brand_id uuid'" + ) + ).one() + assert helper == (True, "s", ["search_path=\"\""], False) + assert connection.execute( + text( + "SELECT has_schema_privilege('authenticated', 'private', 'USAGE') " + "AND NOT has_schema_privilege('authenticated', 'private', 'CREATE') " + "AND has_function_privilege(" + "'authenticated', 'private.is_brand_owner(uuid)', 'EXECUTE')" + ) + ).scalar_one() + + table_security = connection.execute( + text( + "SELECT relname, relrowsecurity, relforcerowsecurity " + "FROM pg_class WHERE relname = ANY(:tables)" + ), + { + "tables": [ + "brands", + "provider_keys", + "provider_key_idempotency", + "brand_asset_operations", + ] + }, + ).all() + assert set(table_security) == { + ("brands", True, True), + ("provider_keys", True, True), + ("provider_key_idempotency", True, True), + ("brand_asset_operations", True, True), + } + + policies = connection.execute( + text( + "SELECT tablename, cmd FROM pg_policies " + "WHERE tablename = ANY(:tables)" + ), + { + "tables": [ + "provider_keys", + "provider_key_idempotency", + "brand_asset_operations", + ] + }, + ).all() + assert set(policies) == { + ("provider_keys", "SELECT"), + ("provider_keys", "INSERT"), + ("provider_keys", "UPDATE"), + ("provider_keys", "DELETE"), + ("provider_key_idempotency", "ALL"), + ("brand_asset_operations", "ALL"), + } + + indexes = set( + connection.execute( + text("SELECT indexname FROM pg_indexes WHERE tablename = 'provider_keys'") + ).scalars() + ) + assert { + "uq_provider_keys_vault_secret", + "uq_provider_keys_one_active", + "idx_provider_keys_brand_provider_created", + "idx_provider_keys_cleanup", + } <= indexes + + constraint_names = set( + connection.execute( + text( + "SELECT conname FROM pg_constraint " + "WHERE conrelid = ANY(ARRAY[" + "'brands'::regclass, 'provider_keys'::regclass, " + "'provider_key_idempotency'::regclass, " + "'brand_asset_operations'::regclass])" + ) + ).scalars() + ) + assert { + "brands_logo_path_check", + "brands_owner_user_id_fkey", + "provider_keys_brand_id_fkey", + "provider_keys_label_check", + "provider_keys_key_hint_check", + "provider_keys_cleanup_inactive", + "provider_keys_invalid_inactive", + "provider_keys_safe_validation_error", + "provider_keys_validation_lease_pair", + "provider_keys_cleanup_without_validation", + "provider_keys_validation_result", + "provider_key_idempotency_brand_id_fkey", + "provider_key_idempotency_provider_key_id_fkey", + "provider_key_idempotency_state_check", + "uq_provider_key_idempotency_brand_request", + "brand_asset_operations_brand_id_fkey", + "brand_asset_operations_operation_check", + "brand_asset_operations_state_check", + "brand_asset_operations_remote_status_check", + "uq_brand_asset_operations_brand", + } <= constraint_names + + definitions = "\n".join( + connection.execute( + text( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conrelid = ANY(ARRAY[" + "'brands'::regclass, 'provider_keys'::regclass, " + "'provider_key_idempotency'::regclass, " + "'brand_asset_operations'::regclass])" + ) + ).scalars() + ) + for expected in ( + "ON DELETE RESTRICT", + "ON DELETE CASCADE", + "ON DELETE SET NULL", + "INVALID_CREDENTIAL", + "validation_token", + "validation_lease_expires_at", + "cleanup_required", + "in_progress", + "unknown", + ): + assert expected in definitions + + foreign_keys = dict( + connection.execute( + text( + "SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conname = ANY(:names)" + ), + { + "names": [ + "brands_owner_user_id_fkey", + "provider_keys_brand_id_fkey", + "provider_key_idempotency_brand_id_fkey", + "provider_key_idempotency_provider_key_id_fkey", + "brand_asset_operations_brand_id_fkey", + ] + }, + ).all() + ) + assert "FOREIGN KEY (owner_user_id)" in foreign_keys["brands_owner_user_id_fkey"] + assert "REFERENCES auth.users(id) ON DELETE RESTRICT" in foreign_keys[ + "brands_owner_user_id_fkey" + ] + assert "ON DELETE RESTRICT" in foreign_keys["provider_keys_brand_id_fkey"] + assert "ON DELETE CASCADE" in foreign_keys[ + "provider_key_idempotency_brand_id_fkey" + ] + assert "ON DELETE SET NULL" in foreign_keys[ + "provider_key_idempotency_provider_key_id_fkey" + ] + assert "ON DELETE RESTRICT" in foreign_keys[ + "brand_asset_operations_brand_id_fkey" + ] + + triggers = set( + connection.execute( + text( + "SELECT trigger_name FROM information_schema.triggers " + "WHERE event_object_table = ANY(:tables)" + ), + {"tables": ["brands", "provider_keys", "brand_asset_operations"]}, + ).scalars() + ) + assert { + "trg_brands_cleanup_irreversible", + "trg_provider_keys_cleanup_irreversible", + "trg_provider_keys_updated_at", + "trg_brand_asset_operations_updated_at", + } <= triggers + + safe_grants = set( + connection.execute( + text( + "SELECT table_name, column_name FROM information_schema.column_privileges " + "WHERE grantee = 'authenticated' AND privilege_type = 'SELECT' " + "AND table_name IN ('brands', 'provider_keys')" + ) + ).all() + ) + assert safe_grants == { + ("brands", column) + for column in ( + "id", + "name", + "logo_path", + "deletion_state", + "created_at", + "updated_at", + ) + } | { + ("provider_keys", column) + for column in ( + "id", + "provider", + "label", + "key_hint", + "lifecycle", + "is_active", + "is_valid", + "last_validated_at", + "last_validation_error", + "created_at", + ) + } + + for role in ("anon", "authenticated"): + for table in ("provider_key_idempotency", "brand_asset_operations"): + for privilege in ("SELECT", "INSERT", "UPDATE", "DELETE"): + assert not connection.execute( + text( + "SELECT has_table_privilege(:role, :table, :privilege)" + ), + {"role": role, "table": table, "privilege": privilege}, + ).scalar_one() + + for role in ("anon", "authenticated"): + assert not connection.execute( + text("SELECT has_schema_privilege(:role, 'vault', 'USAGE')"), + {"role": role}, + ).scalar_one() + for relation, privilege in ( + ("vault.secrets", "SELECT"), + ("vault.secrets", "DELETE"), + ("vault.decrypted_secrets", "SELECT"), + ): + assert not connection.execute( + text("SELECT has_table_privilege(:role, :table, :privilege)"), + {"role": role, "table": relation, "privilege": privilege}, + ).scalar_one() + for function in ( + "vault.create_secret(text,text,text,uuid)", + "vault.update_secret(uuid,text,text,text,uuid)", + ): + assert not connection.execute( + text( + "SELECT has_function_privilege(" + ":role, :function, 'EXECUTE')" + ), + {"role": role, "function": function}, + ).scalar_one() + + assert connection.execute( + text("SELECT has_schema_privilege('service_role', 'vault', 'USAGE')") + ).scalar_one() + assert not connection.execute( + text("SELECT has_schema_privilege('service_role', 'vault', 'CREATE')") + ).scalar_one() + assert connection.execute( + text( + "SELECT has_function_privilege('service_role', " + "'vault.create_secret(text,text,text,uuid)', 'EXECUTE')" + ) + ).scalar_one() + for relation, column in ( + ("vault.decrypted_secrets", "id"), + ("vault.decrypted_secrets", "decrypted_secret"), + ("vault.secrets", "id"), + ): + assert connection.execute( + text( + "SELECT has_column_privilege('service_role', :table, :column, 'SELECT')" + ), + {"table": relation, "column": column}, + ).scalar_one() + assert connection.execute( + text("SELECT has_table_privilege('service_role', 'vault.secrets', 'DELETE')") + ).scalar_one() + + +def test_owner_safe_reads_and_authenticated_dml_or_internal_reads_are_denied( + security_fixture, +): + fixture = security_fixture + engine = fixture["engine"] + own_rows = _execute_as_authenticated( + engine, + fixture["token_a"], + f"SELECT {SAFE_COLUMNS} FROM provider_keys ORDER BY id", + ) + assert [str(row["id"]) for row in own_rows] == [fixture["key_a"]] + assert _execute_as_authenticated( + engine, + fixture["token_b"], + f"SELECT {SAFE_COLUMNS} FROM provider_keys WHERE id = :key_id", + {"key_id": fixture["key_a"]}, + ) == [] + + for column in ( + "brand_id", + "vault_secret_id", + "validation_token", + "validation_lease_expires_at", + "last_used_at", + "updated_at", + ): + _assert_permission_denied( + lambda column=column: _execute_as_authenticated( + engine, fixture["token_a"], f"SELECT {column} FROM provider_keys" + ) + ) + + statements = ( + ( + "INSERT INTO provider_keys " + "(id, brand_id, provider, vault_secret_id, key_hint) " + "VALUES (:id, :brand_id, 'openai', :vault_id, '***abcd')", + { + "id": str(uuid4()), + "brand_id": fixture["brand_a"], + "vault_id": str(uuid4()), + }, + ), + ( + "UPDATE provider_keys SET label = 'changed' WHERE id = :id", + {"id": fixture["key_a"]}, + ), + ("DELETE FROM provider_keys WHERE id = :id", {"id": fixture["key_a"]}), + ) + for token in (fixture["token_a"], fixture["token_b"]): + for statement, parameters in statements: + _assert_permission_denied( + lambda statement=statement, parameters=parameters, token=token: ( + _execute_as_authenticated(engine, token, statement, parameters) + ) + ) + + +def test_backend_only_tables_vault_and_private_rpc_are_denied(security_fixture): + fixture = security_fixture + engine = fixture["engine"] + for table in ("provider_key_idempotency", "brand_asset_operations"): + statements = ( + f"SELECT * FROM {table}", + f"INSERT INTO {table} (id, brand_id) VALUES (:id, :brand_id)", + f"UPDATE {table} SET brand_id = :brand_id WHERE false", + f"DELETE FROM {table} WHERE false", + ) + for statement in statements: + _assert_permission_denied( + lambda statement=statement: _execute_as_authenticated( + engine, + fixture["token_a"], + statement, + {"id": str(uuid4()), "brand_id": fixture["brand_a"]}, + ) + ) + + for statement in ( + "SELECT id FROM vault.secrets", + "SELECT id, decrypted_secret FROM vault.decrypted_secrets", + "SELECT vault.create_secret('denied', NULL, NULL, gen_random_uuid())", + "SELECT vault.update_secret(gen_random_uuid(), 'denied', NULL, NULL, NULL)", + "DELETE FROM vault.secrets WHERE false", + ): + _assert_permission_denied( + lambda statement=statement: _execute_as_authenticated( + engine, fixture["token_a"], statement + ) + ) + + client = fixture["client"] + headers = { + "apikey": fixture["supabase_key"], + "Authorization": f"Bearer {fixture['token_a']}", + } + for table in ("provider_key_idempotency", "brand_asset_operations"): + response = client.get( + f"{fixture['supabase_url']}/rest/v1/{table}", headers=headers + ) + assert response.status_code in {401, 403} + helper_rpc = client.post( + f"{fixture['supabase_url']}/rest/v1/rpc/is_brand_owner", + headers={**headers, "Content-Type": "application/json"}, + json={"p_brand_id": fixture["brand_a"]}, + ) + assert helper_rpc.status_code == 404 + vault_profile = client.get( + f"{fixture['supabase_url']}/rest/v1/secrets", + headers={**headers, "Accept-Profile": "vault"}, + ) + assert vault_profile.status_code in {400, 401, 403, 404, 406} + vault_rpc = client.post( + f"{fixture['supabase_url']}/rest/v1/rpc/create_secret", + headers={**headers, "Content-Type": "application/json"}, + json={"new_secret": "denied"}, + ) + assert vault_rpc.status_code == 404 + + +def test_backend_role_has_required_application_and_vault_privileges( + security_fixture, +): + engine = security_fixture["engine"] + with engine.connect() as connection: + role = connection.execute( + text( + "SELECT rolname, rolsuper, rolbypassrls FROM pg_roles " + "WHERE rolname = current_user" + ) + ).one() + assert role.rolname not in {"anon", "authenticated"} + assert role.rolsuper or role.rolbypassrls + + for table in ( + "brands", + "provider_keys", + "provider_key_idempotency", + "brand_asset_operations", + ): + for privilege in ("SELECT", "INSERT", "UPDATE", "DELETE"): + assert connection.execute( + text( + "SELECT has_table_privilege(current_user, :table, :privilege)" + ), + {"table": table, "privilege": privilege}, + ).scalar_one() + + assert connection.execute( + text("SELECT has_schema_privilege(current_user, 'vault', 'USAGE')") + ).scalar_one() + assert connection.execute( + text( + "SELECT has_function_privilege(current_user, " + "'vault.create_secret(text,text,text,uuid)', 'EXECUTE')" + ) + ).scalar_one() + for relation, column in ( + ("vault.decrypted_secrets", "id"), + ("vault.decrypted_secrets", "decrypted_secret"), + ("vault.secrets", "id"), + ): + assert connection.execute( + text( + "SELECT has_column_privilege(current_user, :table, :column, 'SELECT')" + ), + {"table": relation, "column": column}, + ).scalar_one() + assert connection.execute( + text("SELECT has_table_privilege(current_user, 'vault.secrets', 'DELETE')") + ).scalar_one() + if role.rolname != "postgres" and not role.rolsuper: + assert not connection.execute( + text("SELECT has_schema_privilege(current_user, 'vault', 'CREATE')") + ).scalar_one() + assert not connection.execute( + text( + "SELECT has_function_privilege(current_user, " + "'vault.update_secret(uuid,text,text,text,uuid)', 'EXECUTE')" + ) + ).scalar_one() + for relation in ("vault.decrypted_secrets", "vault.secrets"): + assert not connection.execute( + text("SELECT has_table_privilege(current_user, :table, 'SELECT')"), + {"table": relation}, + ).scalar_one() diff --git a/specs/004-provider-keys/data-model.md b/specs/004-provider-keys/data-model.md index 91eb1cd..1ca84f3 100644 --- a/specs/004-provider-keys/data-model.md +++ b/specs/004-provider-keys/data-model.md @@ -253,7 +253,7 @@ The `DATABASE_URL` role must be private and have `rolbypassrls = true` (or be su - Raw values live only in `vault.secrets` and are read through `vault.decrypted_secrets` by the backend database role. - `vault` is not an exposed Data API schema. - `PUBLIC`, `anon`, and `authenticated` receive no `USAGE`, `SELECT`, `DELETE`, or Vault function execution privileges. Revokes use exact installed function identities: `vault.create_secret(text,text,text,uuid)` and `vault.update_secret(uuid,text,text,text,uuid)`. -- The backend role receives only the Vault privileges this feature uses: schema `USAGE`; `EXECUTE` on `vault.create_secret(text,text,text,uuid)`; `SELECT (id, decrypted_secret)` on `vault.decrypted_secrets`; and `SELECT (id), DELETE` on `vault.secrets`. It does not receive `update_secret` execution for this feature. +- A hosted `DATABASE_URL` role receives only the Vault privileges this feature uses: schema `USAGE`; `EXECUTE` on `vault.create_secret(text,text,text,uuid)`; `SELECT (id, decrypted_secret)` on `vault.decrypted_secrets`; and `SELECT (id), DELETE` on `vault.secrets`. It does not receive `update_secret` execution. The documented local `postgres` exception may retain broader extension-owned privileges; Supabase's built-in `service_role` may also retain extension-owner grants and is never accepted as the hosted database login. - Startup/integration checks fail closed if the configured backend role lacks required Vault/application privileges or `BYPASSRLS`/superuser behavior required by forced RLS. - Tests verify catalog privileges, direct-role denial with SQLSTATE `42501`, and Data API rejection even for the owner of the corresponding provider-key record. diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md index 488c1c3..d79213f 100644 --- a/specs/004-provider-keys/tasks.md +++ b/specs/004-provider-keys/tasks.md @@ -48,19 +48,19 @@ brand cleanup state, request deadline, and safe logging rules required by every **CRITICAL**: No user story implementation starts until this phase passes against a real local Supabase instance. -- [ ] T001 Create failing schema/security integration tests in `backend/tests/integration/test_provider_key_rls.py` that assert the planned enums, non-exposed `private.is_brand_owner(uuid)` helper and denied authenticated RPC, constraints, indexes, restrictive foreign keys, `ENABLE`/`FORCE RLS`, owner-only safe-column reads, denied authenticated DML and internal-column reads, complete denial of `provider_key_idempotency` and `brand_asset_operations`, exact Vault denial with SQLSTATE `42501`, and required backend-role `BYPASSRLS`/application/Vault privileges from `data-model.md` -- [ ] T002 Update failing Brand CRUD/RLS regressions in `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` so test cleanup hard-deletes brands before auth users, and direct authenticated brand `INSERT`/`UPDATE`/`DELETE` expects permission denial for owned and non-owned rows after authenticated DML is revoked -- [ ] T003 Create `supabase/migrations/00016_create_provider_keys.sql` with `supabase_vault`, `provider_t`, `provider_key_lifecycle_t`, `brand_deletion_state_t`, hardened `private.is_brand_owner(uuid)` in a non-exposed schema with RLS-only caller privileges, irreversible cleanup triggers, the `brands` lifecycle/logo-path/FK/grant changes, `provider_keys`, `provider_key_idempotency`, `brand_asset_operations`, all checks/indexes/triggers/restrictive FKs, `ENABLE`/`FORCE RLS`, safe-column grants, backend-only grants, and exact Vault privilege revocations/grants specified in `data-model.md` -- [ ] T004 Apply and statically validate `supabase/migrations/00016_create_provider_keys.sql` with `supabase migration up` and `supabase db lint --level warning`, then run `backend/tests/integration/test_provider_key_rls.py` and confirm its real-Supabase tests execute rather than skip -- [ ] T005 [P] Add failing shared contract tests in `backend/tests/contract/test_provider_keys.py` for the 15-second validation deadline context, fixed provider-key/brand cleanup errors, `X-Request-Id` parity, and JSON log allowlisting that excludes submitted bodies, labels, hints, Vault UUIDs, SQL parameters, provider bodies, authorization headers, exception strings, user IDs, and email addresses -- [ ] T006 [P] Extend `backend/app/models/brand.py` so every Brand response maps database `deletion_state` to contract field `cleanup_state: normal | cleanup_required` without exposing internal lifecycle fields -- [ ] T007 Extend `backend/app/services/brand_store.py` to select/return brand deletion state, lock the owned brand as the canonical first lock, distinguish hidden brands with `LookupError`, reject normal mutations after cleanup starts, detect active `brand_asset_operations`, and expose short transaction primitives used by provider-key and deletion services -- [ ] T008 [P] Harden `backend/app/config.py` with `hide_parameters=True`, bounded pool/connect/statement/lock settings, and a startup assertion that rejects client database roles and verifies `BYPASSRLS` or superuser plus the exact application and Vault privileges required by `00016_create_provider_keys.sql` -- [ ] T009 Extend `backend/app/main.py` to establish an absolute monotonic deadline at backend entry for the provider-key validation route, invoke the backend database privilege assertion during startup, and allowlist only `provider`, fixed outcome `code`, `duration_ms`, and `provider_request_id` in addition to existing safe log fields -- [ ] T010 Extend `backend/app/auth.py` so JWT/JWKS verification consumes only the remaining validation-route budget and uses a bounded JWKS retrieval timeout without changing behavior for non-validation routes -- [ ] T011 Extend `backend/app/routes/brands.py` to return the safe Brand cleanup shape, resolve ownership before cleanup conflicts, reject logo/brand mutations with fixed `BRAND_CLEANUP_REQUIRED` or `BRAND_MUTATION_IN_PROGRESS` errors, and preserve existing opaque 404 and exact confirmation-name behavior -- [ ] T012 Add real-Supabase regression coverage in `backend/tests/integration/test_brand_crud.py` for active-brand create/list/detail/logo behavior, visible cleanup-required brands, mutation fences, and restrictive auth-user deletion while owned brands remain -- [ ] T013 Run the foundational files `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_brand_crud.py`, `backend/tests/integration/test_brand_rls.py`, and `backend/tests/integration/test_provider_key_rls.py`; resolve only feature-related failures before starting User Story 1 +- [X] T001 Create failing schema/security integration tests in `backend/tests/integration/test_provider_key_rls.py` that assert the planned enums, non-exposed `private.is_brand_owner(uuid)` helper and denied authenticated RPC, constraints, indexes, restrictive foreign keys, `ENABLE`/`FORCE RLS`, owner-only safe-column reads, denied authenticated DML and internal-column reads, complete denial of `provider_key_idempotency` and `brand_asset_operations`, exact Vault denial with SQLSTATE `42501`, and required backend-role `BYPASSRLS`/application/Vault privileges from `data-model.md` +- [X] T002 Update failing Brand CRUD/RLS regressions in `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` so test cleanup hard-deletes brands before auth users, and direct authenticated brand `INSERT`/`UPDATE`/`DELETE` expects permission denial for owned and non-owned rows after authenticated DML is revoked +- [X] T003 Create `supabase/migrations/00016_create_provider_keys.sql` with `supabase_vault`, `provider_t`, `provider_key_lifecycle_t`, `brand_deletion_state_t`, hardened `private.is_brand_owner(uuid)` in a non-exposed schema with RLS-only caller privileges, irreversible cleanup triggers, the `brands` lifecycle/logo-path/FK/grant changes, `provider_keys`, `provider_key_idempotency`, `brand_asset_operations`, all checks/indexes/triggers/restrictive FKs, `ENABLE`/`FORCE RLS`, safe-column grants, backend-only grants, and exact Vault privilege revocations/grants specified in `data-model.md` +- [X] T004 Apply and statically validate `supabase/migrations/00016_create_provider_keys.sql` with `supabase migration up` and `supabase db lint --level warning`, then run `backend/tests/integration/test_provider_key_rls.py` and confirm its real-Supabase tests execute rather than skip +- [X] T005 [P] Add failing shared contract tests in `backend/tests/contract/test_provider_keys.py` for the 15-second validation deadline context, fixed provider-key/brand cleanup errors, `X-Request-Id` parity, and JSON log allowlisting that excludes submitted bodies, labels, hints, Vault UUIDs, SQL parameters, provider bodies, authorization headers, exception strings, user IDs, and email addresses +- [X] T006 [P] Extend `backend/app/models/brand.py` so every Brand response maps database `deletion_state` to contract field `cleanup_state: normal | cleanup_required` without exposing internal lifecycle fields +- [X] T007 Extend `backend/app/services/brand_store.py` to select/return brand deletion state, lock the owned brand as the canonical first lock, distinguish hidden brands with `LookupError`, reject normal mutations after cleanup starts, detect active `brand_asset_operations`, and expose short transaction primitives used by provider-key and deletion services +- [X] T008 [P] Harden `backend/app/config.py` with `hide_parameters=True`, bounded pool/connect/statement/lock settings, and a startup assertion that rejects client database roles and verifies `BYPASSRLS` or superuser plus the exact application and Vault privileges required by `00016_create_provider_keys.sql` +- [X] T009 Extend `backend/app/main.py` to establish an absolute monotonic deadline at backend entry for the provider-key validation route, invoke the backend database privilege assertion during startup, and allowlist only `provider`, fixed outcome `code`, `duration_ms`, and `provider_request_id` in addition to existing safe log fields +- [X] T010 Extend `backend/app/auth.py` so JWT/JWKS verification consumes only the remaining validation-route budget and uses a bounded JWKS retrieval timeout without changing behavior for non-validation routes +- [X] T011 Extend `backend/app/routes/brands.py` to return the safe Brand cleanup shape, resolve ownership before cleanup conflicts, reject logo/brand mutations with fixed `BRAND_CLEANUP_REQUIRED` or `BRAND_MUTATION_IN_PROGRESS` errors, and preserve existing opaque 404 and exact confirmation-name behavior +- [X] T012 Add real-Supabase regression coverage in `backend/tests/integration/test_brand_crud.py` for active-brand create/list/detail/logo behavior, visible cleanup-required brands, mutation fences, and restrictive auth-user deletion while owned brands remain +- [X] T013 Run the foundational files `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_brand_crud.py`, `backend/tests/integration/test_brand_rls.py`, and `backend/tests/integration/test_provider_key_rls.py`; resolve only feature-related failures before starting User Story 1 **Checkpoint**: The schema, backend privilege boundary, brand fence, validation deadline, and logging boundary are enforceable before raw credentials enter the system. diff --git a/supabase/migrations/00016_create_provider_keys.sql b/supabase/migrations/00016_create_provider_keys.sql new file mode 100644 index 0000000..a37734d --- /dev/null +++ b/supabase/migrations/00016_create_provider_keys.sql @@ -0,0 +1,237 @@ +CREATE EXTENSION IF NOT EXISTS supabase_vault CASCADE; + +CREATE TYPE provider_t AS ENUM ('openai', 'gemini'); +CREATE TYPE provider_key_lifecycle_t AS ENUM ('normal', 'cleanup_required'); +CREATE TYPE brand_deletion_state_t AS ENUM ('active', 'cleanup_required'); + +CREATE SCHEMA IF NOT EXISTS private; +REVOKE ALL ON SCHEMA private FROM PUBLIC, anon, authenticated; +GRANT USAGE ON SCHEMA private TO authenticated, service_role; + +CREATE FUNCTION private.is_brand_owner(p_brand_id UUID) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.brands + WHERE id = p_brand_id + AND owner_user_id = (SELECT auth.uid()) + ); +$$; + +REVOKE ALL ON FUNCTION private.is_brand_owner(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION private.is_brand_owner(UUID) TO authenticated, service_role; + +ALTER TABLE brands + ADD COLUMN deletion_state brand_deletion_state_t NOT NULL DEFAULT 'active'; + +ALTER TABLE brands DROP CONSTRAINT brands_logo_path_check; +ALTER TABLE brands ADD CONSTRAINT brands_logo_path_check CHECK ( + logo_path IS NULL + OR logo_path ~ '^brands/[0-9a-f-]+/logo\.[A-Za-z0-9]+$' + OR logo_path ~ '^brands/[0-9a-f-]+/logos/[0-9a-f-]+\.[A-Za-z0-9]+$' +); + +ALTER TABLE brands DROP CONSTRAINT brands_owner_user_id_fkey; +ALTER TABLE brands ADD CONSTRAINT brands_owner_user_id_fkey + FOREIGN KEY (owner_user_id) REFERENCES auth.users(id) ON DELETE RESTRICT; + +CREATE FUNCTION reject_brand_cleanup_reversal() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + IF OLD.deletion_state = 'cleanup_required' + AND NEW.deletion_state = 'active' THEN + RAISE EXCEPTION 'brand cleanup state is irreversible' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_brands_cleanup_irreversible + BEFORE UPDATE ON brands + FOR EACH ROW + EXECUTE FUNCTION reject_brand_cleanup_reversal(); + +CREATE TABLE provider_keys ( + id UUID PRIMARY KEY, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE RESTRICT, + provider provider_t NOT NULL, + vault_secret_id UUID NOT NULL, + label TEXT CHECK (label IS NULL OR char_length(label) <= 100), + key_hint TEXT NOT NULL CHECK (key_hint ~ '^\*\*\*[A-Za-z0-9_-]{4}$'), + lifecycle provider_key_lifecycle_t NOT NULL DEFAULT 'normal', + is_active BOOLEAN NOT NULL DEFAULT false, + is_valid BOOLEAN, + last_validated_at TIMESTAMPTZ, + last_validation_error TEXT, + validation_token UUID, + validation_lease_expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT provider_keys_cleanup_inactive CHECK ( + lifecycle <> 'cleanup_required' OR NOT is_active + ), + CONSTRAINT provider_keys_invalid_inactive CHECK ( + is_valid IS DISTINCT FROM false OR NOT is_active + ), + CONSTRAINT provider_keys_safe_validation_error CHECK ( + last_validation_error IS NULL + OR last_validation_error = 'INVALID_CREDENTIAL' + ), + CONSTRAINT provider_keys_validation_lease_pair CHECK ( + (validation_token IS NULL AND validation_lease_expires_at IS NULL) + OR (validation_token IS NOT NULL AND validation_lease_expires_at IS NOT NULL) + ), + CONSTRAINT provider_keys_cleanup_without_validation CHECK ( + lifecycle = 'normal' OR validation_token IS NULL + ), + CONSTRAINT provider_keys_validation_result CHECK ( + (is_valid IS NULL AND last_validated_at IS NULL AND last_validation_error IS NULL) + OR (is_valid IS TRUE AND last_validated_at IS NOT NULL AND last_validation_error IS NULL) + OR ( + is_valid IS FALSE + AND last_validated_at IS NOT NULL + AND last_validation_error = 'INVALID_CREDENTIAL' + ) + ) +); + +CREATE UNIQUE INDEX uq_provider_keys_vault_secret + ON provider_keys(vault_secret_id); +CREATE UNIQUE INDEX uq_provider_keys_one_active + ON provider_keys(brand_id, provider) + WHERE is_active; +CREATE INDEX idx_provider_keys_brand_provider_created + ON provider_keys(brand_id, provider, created_at DESC, id DESC); +CREATE INDEX idx_provider_keys_cleanup + ON provider_keys(brand_id, lifecycle) + WHERE lifecycle = 'cleanup_required'; + +CREATE TRIGGER trg_provider_keys_updated_at + BEFORE UPDATE ON provider_keys + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE FUNCTION reject_provider_key_cleanup_reversal() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + IF OLD.lifecycle = 'cleanup_required' AND NEW.lifecycle = 'normal' THEN + RAISE EXCEPTION 'provider key cleanup state is irreversible' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_provider_keys_cleanup_irreversible + BEFORE UPDATE ON provider_keys + FOR EACH ROW + EXECUTE FUNCTION reject_provider_key_cleanup_reversal(); + +CREATE TABLE provider_key_idempotency ( + id UUID PRIMARY KEY, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + request_id UUID NOT NULL, + provider_key_id UUID REFERENCES provider_keys(id) ON DELETE SET NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'deleted')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_provider_key_idempotency_brand_request UNIQUE (brand_id, request_id) +); + +CREATE TABLE brand_asset_operations ( + id UUID PRIMARY KEY, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE RESTRICT, + operation TEXT NOT NULL CHECK (operation IN ('upload', 'remove')), + object_path TEXT, + previous_path TEXT, + state TEXT NOT NULL CHECK (state IN ('in_progress', 'cleanup_required')), + remote_status TEXT NOT NULL CHECK ( + remote_status IN ('pending', 'succeeded', 'failed', 'unknown') + ), + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_brand_asset_operations_brand UNIQUE (brand_id) +); + +CREATE TRIGGER trg_brand_asset_operations_updated_at + BEFORE UPDATE ON brand_asset_operations + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +ALTER TABLE provider_keys ENABLE ROW LEVEL SECURITY; +ALTER TABLE provider_keys FORCE ROW LEVEL SECURITY; +ALTER TABLE provider_key_idempotency ENABLE ROW LEVEL SECURITY; +ALTER TABLE provider_key_idempotency FORCE ROW LEVEL SECURITY; +ALTER TABLE brand_asset_operations ENABLE ROW LEVEL SECURITY; +ALTER TABLE brand_asset_operations FORCE ROW LEVEL SECURITY; + +CREATE POLICY provider_keys_select ON provider_keys + FOR SELECT USING (private.is_brand_owner(brand_id)); +CREATE POLICY provider_keys_insert ON provider_keys + FOR INSERT WITH CHECK (private.is_brand_owner(brand_id)); +CREATE POLICY provider_keys_update ON provider_keys + FOR UPDATE + USING (private.is_brand_owner(brand_id)) + WITH CHECK (private.is_brand_owner(brand_id)); +CREATE POLICY provider_keys_delete ON provider_keys + FOR DELETE USING (private.is_brand_owner(brand_id)); + +CREATE POLICY provider_key_idempotency_owner ON provider_key_idempotency + FOR ALL + USING (private.is_brand_owner(brand_id)) + WITH CHECK (private.is_brand_owner(brand_id)); +CREATE POLICY brand_asset_operations_owner ON brand_asset_operations + FOR ALL + USING (private.is_brand_owner(brand_id)) + WITH CHECK (private.is_brand_owner(brand_id)); + +REVOKE ALL ON provider_keys FROM PUBLIC, anon, authenticated; +REVOKE ALL ON provider_key_idempotency FROM PUBLIC, anon, authenticated; +REVOKE ALL ON brand_asset_operations FROM PUBLIC, anon, authenticated; +REVOKE ALL ON brands FROM authenticated; + +GRANT SELECT, INSERT, UPDATE, DELETE ON provider_keys TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON provider_key_idempotency TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON brand_asset_operations TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON brands TO service_role; + +GRANT SELECT ( + id, provider, label, key_hint, lifecycle, is_active, is_valid, + last_validated_at, last_validation_error, created_at +) ON provider_keys TO authenticated; +GRANT SELECT ( + id, name, logo_path, deletion_state, created_at, updated_at +) ON brands TO authenticated; + +REVOKE ALL ON SCHEMA vault FROM PUBLIC, anon, authenticated; +REVOKE ALL ON ALL TABLES IN SCHEMA vault FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION vault.create_secret(TEXT, TEXT, TEXT, UUID) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION vault.update_secret(UUID, TEXT, TEXT, TEXT, UUID) + FROM PUBLIC, anon, authenticated; + +REVOKE ALL ON SCHEMA vault FROM service_role; +REVOKE ALL ON ALL TABLES IN SCHEMA vault FROM service_role; +REVOKE ALL ON FUNCTION vault.create_secret(TEXT, TEXT, TEXT, UUID) + FROM service_role; +REVOKE ALL ON FUNCTION vault.update_secret(UUID, TEXT, TEXT, TEXT, UUID) + FROM service_role; + +GRANT USAGE ON SCHEMA vault TO service_role; +GRANT EXECUTE ON FUNCTION vault.create_secret(TEXT, TEXT, TEXT, UUID) + TO service_role; +GRANT SELECT (id, decrypted_secret) ON vault.decrypted_secrets TO service_role; +GRANT SELECT (id) ON vault.secrets TO service_role; +GRANT DELETE ON vault.secrets TO service_role; From 4645e7c8a000189f753d7ca4184e4f4663ea3e0a Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Tue, 28 Jul 2026 00:19:56 +0300 Subject: [PATCH 05/14] Fix database validation and RLS test issues --- backend/app/config.py | 2 +- backend/tests/contract/test_brands.py | 30 +++++++++---------- backend/tests/contract/test_provider_keys.py | 6 ++-- backend/tests/integration/test_brand_rls.py | 4 +-- .../migrations/00016_create_provider_keys.sql | 2 ++ 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 791c7a4..51b1742 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -118,7 +118,7 @@ def assert_database_role_privileges() -> None: role_name = privileges.pop("role_name") is_superuser = privileges.pop("is_superuser") vault_least_privilege = privileges.pop("vault_least_privilege") - hostname = urlparse(settings.supabase_url).hostname + hostname = urlparse(settings.database_url).hostname local_postgres = role_name == "postgres" and hostname in { "127.0.0.1", "localhost", diff --git a/backend/tests/contract/test_brands.py b/backend/tests/contract/test_brands.py index f17f9a6..b1b20c6 100644 --- a/backend/tests/contract/test_brands.py +++ b/backend/tests/contract/test_brands.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import UUID import pytest @@ -37,7 +37,7 @@ def create_brand(self, user_id: str, payload: BrandCreate) -> Brand: id=UUID("22222222-2222-2222-2222-222222222222"), name=payload.name, logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) self.brands.insert(0, brand) self.owners[brand.id] = user_id @@ -221,13 +221,13 @@ def test_list_brands_returns_empty_and_populated_contract_shapes(): id=UUID("33333333-3333-3333-3333-333333333333"), name="New Brand", logo_url=None, - created_at=datetime(2026, 7, 26, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 26, tzinfo=UTC), ), Brand( id=UUID("22222222-2222-2222-2222-222222222222"), name="First Brand", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ), ] populated_response = client.get("/api/v1/brands") @@ -262,7 +262,7 @@ def test_get_brand_returns_owned_brand_and_opaque_not_found_errors(): id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore(brands=[owned_brand]) app.dependency_overrides[get_current_user] = lambda: CurrentUser( @@ -306,7 +306,7 @@ def test_cleanup_required_brand_remains_visible_in_list_and_detail(): name="Cleanup Brand", logo_url=None, cleanup_state="cleanup_required", - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore(brands=[brand], cleanup_required={brand.id}) app.dependency_overrides[get_current_user] = lambda: CurrentUser( @@ -345,7 +345,7 @@ def test_logo_and_brand_mutations_respect_brand_fences( id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore(brands=[brand]) getattr(store, fence_field).add(brand.id) @@ -401,7 +401,7 @@ def test_upload_logo_rejects_unsupported_or_spoofed_content( id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore(brands=[brand]) storage = FakeBrandStorage() @@ -433,7 +433,7 @@ def test_upload_logo_rejects_files_over_five_megabytes(): id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore(brands=[brand]) storage = FakeBrandStorage() @@ -466,7 +466,7 @@ def test_upload_logo_returns_updated_brand(): id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore(brands=[brand]) storage = FakeBrandStorage() @@ -501,7 +501,7 @@ def test_delete_logo_without_existing_logo_is_idempotent(): id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore(brands=[brand]) storage = FakeBrandStorage() @@ -530,7 +530,7 @@ def test_delete_brand_with_exact_confirmation_removes_brand_and_logo(): id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url="https://example.supabase.co/logo.png", - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) logo_path = f"brands/{brand.id}/logo.png" store = FakeBrandStore(brands=[brand], logo_paths={brand.id: logo_path}) @@ -564,7 +564,7 @@ def test_delete_brand_storage_failure_retains_cleanup_required_brand(): id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url="https://example.supabase.co/logo.png", - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) logo_path = f"brands/{brand.id}/logo.png" store = FakeBrandStore(brands=[brand], logo_paths={brand.id: logo_path}) @@ -620,7 +620,7 @@ def test_delete_brand_rejects_missing_or_wrong_confirmation_without_mutation( id=UUID("22222222-2222-2222-2222-222222222222"), name="Acme Coffee", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore(brands=[brand]) storage = FakeBrandStorage() @@ -654,7 +654,7 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra id=UUID("22222222-2222-2222-2222-222222222222"), name="Another Owner Brand", logo_url=None, - created_at=datetime(2026, 7, 25, tzinfo=timezone.utc), + created_at=datetime(2026, 7, 25, tzinfo=UTC), ) store = FakeBrandStore( brands=[brand], diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py index 84d0c2c..e38d838 100644 --- a/backend/tests/contract/test_provider_keys.py +++ b/backend/tests/contract/test_provider_keys.py @@ -278,7 +278,7 @@ def test_local_postgres_privilege_assertion_allows_documented_exception( "load_settings", lambda: SimpleNamespace( database_url="postgresql://localhost/postgres", - supabase_url="http://127.0.0.1:54321", + supabase_url="https://hosted.supabase.co", ), ) monkeypatch.setattr(config, "get_engine", lambda: engine) @@ -391,10 +391,10 @@ async def slow_to_thread(function, *args): ) monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "ES256"}) request = _request() - request.state.validation_deadline = time.monotonic() + 0.03 + request.state.validation_deadline = time.monotonic() + 0.1 started = time.monotonic() with pytest.raises(HTTPException): asyncio.run(auth.get_current_user(request, "Bearer token")) - assert time.monotonic() - started < 0.15 + assert time.monotonic() - started < 0.5 diff --git a/backend/tests/integration/test_brand_rls.py b/backend/tests/integration/test_brand_rls.py index 7c3450c..f1afcb2 100644 --- a/backend/tests/integration/test_brand_rls.py +++ b/backend/tests/integration/test_brand_rls.py @@ -252,14 +252,12 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers(): ] }, ) - assert cleanup_response.is_success - from backend.app.config import get_engine - with get_engine().begin() as connection: connection.execute( text("DELETE FROM brands WHERE id = :brand_id"), {"brand_id": brand_id}, ) + assert cleanup_response.is_success for user_id in (user_a_id, user_b_id): if user_id: supabase_client.delete( diff --git a/supabase/migrations/00016_create_provider_keys.sql b/supabase/migrations/00016_create_provider_keys.sql index a37734d..81838f1 100644 --- a/supabase/migrations/00016_create_provider_keys.sql +++ b/supabase/migrations/00016_create_provider_keys.sql @@ -203,7 +203,9 @@ REVOKE ALL ON brand_asset_operations FROM PUBLIC, anon, authenticated; REVOKE ALL ON brands FROM authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON provider_keys TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON provider_key_idempotency TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON provider_key_idempotency TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON brand_asset_operations TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON brand_asset_operations TO service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON brands TO service_role; From 3e9a7ae53339e7a93cd7c796c866abe08d79e53f Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Tue, 28 Jul 2026 14:41:47 +0300 Subject: [PATCH 06/14] Implement provider key management MVP --- backend/app/main.py | 2 + backend/app/models/provider_key.py | 72 ++++ backend/app/routes/provider_keys.py | 110 ++++++ backend/app/services/provider_key_store.py | 170 ++++++++ backend/tests/contract/test_provider_keys.py | 255 ++++++++++++ .../integration/test_provider_key_rls.py | 137 ++++++- .../tests/integration/test_provider_keys.py | 343 ++++++++++++++++ .../brands/[brandId]/keys/page.tsx | 368 ++++++++++++++++++ .../app/(dashboard)/brands/[brandId]/page.tsx | 39 +- frontend/app/(dashboard)/layout.tsx | 2 +- frontend/tests/e2e/provider-keys.spec.ts | 152 ++++++++ specs/004-provider-keys/tasks.md | 18 +- .../migrations/00016_create_provider_keys.sql | 2 - 13 files changed, 1647 insertions(+), 23 deletions(-) create mode 100644 backend/app/models/provider_key.py create mode 100644 backend/app/routes/provider_keys.py create mode 100644 backend/app/services/provider_key_store.py create mode 100644 backend/tests/integration/test_provider_keys.py create mode 100644 frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx create mode 100644 frontend/tests/e2e/provider-keys.spec.ts diff --git a/backend/app/main.py b/backend/app/main.py index bc66787..f3c7857 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,7 @@ from .routes.brands import router as brands_router from .routes.health import router as health_router from .routes.me import router as me_router +from .routes.provider_keys import router as provider_keys_router _VALIDATION_DEADLINE_SECONDS = 15 @@ -152,3 +153,4 @@ async def root() -> dict[str, str]: app.include_router(brands_router) app.include_router(health_router) app.include_router(me_router) +app.include_router(provider_keys_router) diff --git a/backend/app/models/provider_key.py b/backend/app/models/provider_key.py new file mode 100644 index 0000000..9e4be2f --- /dev/null +++ b/backend/app/models/provider_key.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import re +from datetime import datetime +from enum import Enum +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, field_validator, model_validator + + +_KEY_SUFFIX = re.compile(r"[A-Za-z0-9_-]{4}$") + + +class Provider(str, Enum): + OPENAI = "openai" + GEMINI = "gemini" + + +class ProviderKeyLifecycle(str, Enum): + NORMAL = "normal" + CLEANUP_REQUIRED = "cleanup_required" + + +class ProviderKeyAdd(BaseModel): + provider: Provider + key: str + label: str | None = None + make_active: bool = True + + @field_validator("key") + @classmethod + def validate_key(cls, value: str) -> str: + if len(value) < 5 or not value.strip() or _KEY_SUFFIX.search(value) is None: + raise ValueError( + "Key must be at least 5 characters and end in four letters, " + "numbers, underscores, or hyphens." + ) + return value + + @field_validator("label") + @classmethod + def validate_label_length(cls, value: str | None) -> str | None: + if value is not None and len(value) > 100: + raise ValueError("Label must be 100 characters or fewer.") + return value + + @model_validator(mode="after") + def reject_key_in_label(self) -> ProviderKeyAdd: + if self.label is not None and self.key in self.label: + raise ValueError("Label must not contain the provider key.") + return self + + +class ProviderKey(BaseModel): + id: UUID + provider: Provider + label: str | None + key_hint: str + is_active: bool + is_valid: bool | None + last_validated_at: datetime | None + last_validation_error: Literal["INVALID_CREDENTIAL"] | None + cleanup_state: ProviderKeyLifecycle + created_at: datetime + + +class ProviderKeyListResponse(BaseModel): + keys: list[ProviderKey] + + +IdempotencyKey = UUID diff --git a/backend/app/routes/provider_keys.py b/backend/app/routes/provider_keys.py new file mode 100644 index 0000000..64e5983 --- /dev/null +++ b/backend/app/routes/provider_keys.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status + +from ..auth import CurrentUserDep +from ..models.provider_key import ( + IdempotencyKey, + ProviderKey, + ProviderKeyAdd, + ProviderKeyListResponse, +) +from ..services.brand_store import BrandCleanupRequiredError +from ..services.provider_key_store import ( + IdempotencyKeyRetiredError, + ProviderKeyStore, + VaultUnavailableError, + get_provider_key_store, +) + + +router = APIRouter(prefix="/api/v1/brands", tags=["provider-keys"]) +logger = logging.getLogger(__name__) + +ProviderKeyStoreDep = Annotated[ProviderKeyStore, Depends(get_provider_key_store)] +IdempotencyKeyHeader = Annotated[IdempotencyKey, Header(alias="Idempotency-Key")] + + +def _error(status_code: int, code: str, message: str) -> HTTPException: + return HTTPException( + status_code=status_code, + detail={"code": code, "message": message}, + ) + + +def _not_found() -> HTTPException: + return _error(status.HTTP_404_NOT_FOUND, "BRAND_NOT_FOUND", "Brand not found.") + + +@router.get("/{brand_id}/keys", response_model=ProviderKeyListResponse) +def list_provider_keys( + request: Request, + brand_id: UUID, + current_user: CurrentUserDep, + provider_key_store: ProviderKeyStoreDep, +) -> ProviderKeyListResponse: + try: + keys = provider_key_store.list_keys(current_user.user_id, brand_id) + except LookupError as exc: + raise _not_found() from exc + logger.info( + "provider_keys.list_success", + extra={ + "event": "provider_keys.list_success", + "request_id": getattr(request.state, "request_id", "unknown"), + }, + ) + return ProviderKeyListResponse(keys=keys) + + +@router.post( + "/{brand_id}/keys", + response_model=ProviderKey, + status_code=status.HTTP_201_CREATED, +) +def add_provider_key( + request: Request, + brand_id: UUID, + payload: ProviderKeyAdd, + idempotency_key: IdempotencyKeyHeader, + current_user: CurrentUserDep, + provider_key_store: ProviderKeyStoreDep, +) -> ProviderKey: + try: + key = provider_key_store.add_key( + current_user.user_id, brand_id, payload, idempotency_key + ) + except LookupError as exc: + raise _not_found() from exc + except BrandCleanupRequiredError as exc: + raise _error( + status.HTTP_409_CONFLICT, + "BRAND_CLEANUP_REQUIRED", + "Brand cleanup is required. Retry deletion.", + ) from exc + except IdempotencyKeyRetiredError as exc: + raise _error( + status.HTTP_409_CONFLICT, + "IDEMPOTENCY_KEY_RETIRED", + "This add request was already completed and deleted. Use a new request ID.", + ) from exc + except VaultUnavailableError as exc: + raise _error( + status.HTTP_502_BAD_GATEWAY, + "VAULT_UNAVAILABLE", + "Secure key storage is unavailable right now.", + ) from exc + + logger.info( + "provider_keys.add_success", + extra={ + "event": "provider_keys.add_success", + "request_id": getattr(request.state, "request_id", "unknown"), + "provider": key.provider.value, + }, + ) + return key diff --git a/backend/app/services/provider_key_store.py b/backend/app/services/provider_key_store.py new file mode 100644 index 0000000..c5fb5f9 --- /dev/null +++ b/backend/app/services/provider_key_store.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from functools import lru_cache +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError + +from ..config import get_engine +from ..models.provider_key import ProviderKey, ProviderKeyAdd +from .brand_store import BrandCleanupRequiredError, BrandStore + + +class ProviderKeyStoreError(Exception): + pass + + +class IdempotencyKeyRetiredError(ProviderKeyStoreError): + pass + + +class VaultUnavailableError(ProviderKeyStoreError): + pass + + +_SAFE_COLUMNS = """ + id, provider, label, key_hint, is_active, is_valid, + last_validated_at, last_validation_error, lifecycle, created_at +""" + + +@dataclass(frozen=True, slots=True) +class ProviderKeyStore: + engine: Engine + + @staticmethod + def _to_provider_key(row: Mapping[str, Any]) -> ProviderKey: + return ProviderKey.model_validate( + {**row, "cleanup_state": row["lifecycle"]} + ) + + def list_keys(self, user_id: str, brand_id: UUID) -> list[ProviderKey]: + with self.engine.begin() as connection: + BrandStore.lock_owned_brand(connection, user_id, brand_id) + rows = connection.execute( + text( + f""" + SELECT {_SAFE_COLUMNS} + FROM provider_keys + WHERE brand_id = :brand_id + ORDER BY provider, created_at DESC, id DESC + """ + ), + {"brand_id": brand_id}, + ).mappings().all() + return [self._to_provider_key(row) for row in rows] + + def add_key( + self, + user_id: str, + brand_id: UUID, + payload: ProviderKeyAdd, + idempotency_key: UUID, + ) -> ProviderKey: + try: + with self.engine.begin() as connection: + brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) + receipt = connection.execute( + text( + """ + SELECT r.state, pk.id, pk.provider, pk.label, pk.key_hint, + pk.is_active, pk.is_valid, pk.last_validated_at, + pk.last_validation_error, pk.lifecycle, pk.created_at + FROM provider_key_idempotency r + LEFT JOIN provider_keys pk ON pk.id = r.provider_key_id + WHERE r.brand_id = :brand_id AND r.request_id = :request_id + """ + ), + {"brand_id": brand_id, "request_id": idempotency_key}, + ).mappings().one_or_none() + if receipt is not None: + if receipt["state"] != "active" or receipt["id"] is None: + raise IdempotencyKeyRetiredError + return self._to_provider_key(receipt) + + BrandStore.require_normal_brand(brand) + cleanup_pending = connection.execute( + text( + "SELECT EXISTS (" + "SELECT 1 FROM provider_keys " + "WHERE brand_id = :brand_id AND lifecycle = 'cleanup_required'" + ")" + ), + {"brand_id": brand_id}, + ).scalar_one() + if cleanup_pending: + raise BrandCleanupRequiredError + + key_id = uuid4() + if payload.make_active: + connection.execute( + text( + "UPDATE provider_keys SET is_active = false " + "WHERE brand_id = :brand_id " + "AND provider = CAST(:provider AS provider_t) " + "AND is_active" + ), + {"brand_id": brand_id, "provider": payload.provider.value}, + ) + + vault_secret_id = connection.execute( + text( + "SELECT vault.create_secret(:secret, NULL, '', NULL)" + ), + {"secret": payload.key}, + ).scalar_one() + row = connection.execute( + text( + f""" + INSERT INTO provider_keys + (id, brand_id, provider, vault_secret_id, label, + key_hint, is_active) + VALUES + (:id, :brand_id, CAST(:provider AS provider_t), + :vault_secret_id, :label, + :key_hint, :is_active) + RETURNING {_SAFE_COLUMNS} + """ + ), + { + "id": key_id, + "brand_id": brand_id, + "provider": payload.provider.value, + "vault_secret_id": vault_secret_id, + "label": payload.label, + "key_hint": f"***{payload.key[-4:]}", + "is_active": payload.make_active, + }, + ).mappings().one() + connection.execute( + text( + "INSERT INTO provider_key_idempotency " + "(id, brand_id, request_id, provider_key_id, state) " + "VALUES (:id, :brand_id, :request_id, :provider_key_id, 'active')" + ), + { + "id": uuid4(), + "brand_id": brand_id, + "request_id": idempotency_key, + "provider_key_id": key_id, + }, + ) + return self._to_provider_key(row) + except ( + LookupError, + BrandCleanupRequiredError, + IdempotencyKeyRetiredError, + ): + raise + except SQLAlchemyError as exc: + raise VaultUnavailableError from exc + + +@lru_cache(maxsize=1) +def get_provider_key_store() -> ProviderKeyStore: + return ProviderKeyStore(get_engine()) diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py index e38d838..228370a 100644 --- a/backend/tests/contract/test_provider_keys.py +++ b/backend/tests/contract/test_provider_keys.py @@ -4,14 +4,26 @@ import json import logging import time +from dataclasses import dataclass, field +from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, Mock +from uuid import UUID import pytest from fastapi import HTTPException, Request +from fastapi.testclient import TestClient from starlette.responses import Response from backend.app import auth, config, main +from backend.app.auth import CurrentUser, get_current_user +from backend.app.models.provider_key import ProviderKey, ProviderKeyAdd +from backend.app.routes.provider_keys import get_provider_key_store +from backend.app.services.brand_store import BrandCleanupRequiredError +from backend.app.services.provider_key_store import ( + IdempotencyKeyRetiredError, + VaultUnavailableError, +) def _request(path: str = "/api/v1/brands/brand-id/keys/key-id/validate") -> Request: @@ -398,3 +410,246 @@ async def slow_to_thread(function, *args): asyncio.run(auth.get_current_user(request, "Bearer token")) assert time.monotonic() - started < 0.5 + + +BRAND_ID = UUID("22222222-2222-2222-2222-222222222222") +KEY_ID = UUID("33333333-3333-3333-3333-333333333333") +REQUEST_ID = UUID("44444444-4444-4444-4444-444444444444") +RAW_KEY = "contract-provider-secret-A1B2" + + +def _safe_key(**updates) -> ProviderKey: + values = { + "id": KEY_ID, + "provider": "openai", + "label": "Production Key", + "key_hint": "***A1B2", + "is_active": True, + "is_valid": None, + "last_validated_at": None, + "last_validation_error": None, + "cleanup_state": "normal", + "created_at": datetime(2026, 7, 26, 11, tzinfo=UTC), + } + values.update(updates) + return ProviderKey.model_validate(values) + + +@dataclass +class FakeProviderKeyStore: + keys: list[ProviderKey] = field(default_factory=list) + error: Exception | None = None + add_calls: list[tuple[str, UUID, ProviderKeyAdd, UUID]] = field(default_factory=list) + + def list_keys(self, user_id: str, brand_id: UUID) -> list[ProviderKey]: + if self.error: + raise self.error + return self.keys + + def add_key( + self, + user_id: str, + brand_id: UUID, + payload: ProviderKeyAdd, + idempotency_key: UUID, + ) -> ProviderKey: + self.add_calls.append((user_id, brand_id, payload, idempotency_key)) + if self.error: + raise self.error + return self.keys[0] if self.keys else _safe_key(is_active=payload.make_active) + + +@pytest.fixture +def provider_key_client(): + store = FakeProviderKeyStore() + main.app.dependency_overrides[get_current_user] = lambda: CurrentUser( + user_id="11111111-1111-1111-1111-111111111111", + email="owner@example.com", + access_token="redacted", + ) + main.app.dependency_overrides[get_provider_key_store] = lambda: store + try: + with TestClient(main.app) as client: + yield client, store + finally: + main.app.dependency_overrides.clear() + + +def test_list_keys_returns_exact_empty_and_populated_safe_shapes(provider_key_client): + client, store = provider_key_client + empty = client.get(f"/api/v1/brands/{BRAND_ID}/keys") + store.keys = [_safe_key()] + populated = client.get(f"/api/v1/brands/{BRAND_ID}/keys") + + assert empty.status_code == 200 + assert empty.json() == {"keys": []} + assert populated.status_code == 200 + assert populated.json() == { + "keys": [ + { + "id": str(KEY_ID), + "provider": "openai", + "label": "Production Key", + "key_hint": "***A1B2", + "is_active": True, + "is_valid": None, + "last_validated_at": None, + "last_validation_error": None, + "cleanup_state": "normal", + "created_at": "2026-07-26T11:00:00Z", + } + ] + } + forbidden = {"brand_id", "vault_secret_id", "lifecycle", "validation_token", "updated_at"} + assert forbidden.isdisjoint(populated.json()["keys"][0]) + + +@pytest.mark.parametrize("make_active", [None, True, False]) +def test_add_key_defaults_active_and_returns_only_safe_shape(provider_key_client, make_active): + client, store = provider_key_client + body = {"provider": "openai", "key": RAW_KEY, "label": "Production Key"} + if make_active is not None: + body["make_active"] = make_active + + response = client.post( + f"/api/v1/brands/{BRAND_ID}/keys", + headers={"Idempotency-Key": str(REQUEST_ID)}, + json=body, + ) + + expected_active = True if make_active is None else make_active + assert response.status_code == 201 + assert response.json()["is_active"] is expected_active + assert response.json()["key_hint"] == "***A1B2" + assert RAW_KEY not in response.text + assert store.add_calls[0][2].key == RAW_KEY + assert store.add_calls[0][2].make_active is expected_active + assert store.add_calls[0][3] == REQUEST_ID + + +@pytest.mark.parametrize("header", [None, "not-a-uuid"]) +def test_add_key_requires_uuid_idempotency_header(provider_key_client, header): + client, store = provider_key_client + headers = {} if header is None else {"Idempotency-Key": header} + response = client.post( + f"/api/v1/brands/{BRAND_ID}/keys", + headers=headers, + json={"provider": "openai", "key": RAW_KEY}, + ) + + assert response.status_code == 400 + assert response.json()["error"]["code"] == "VALIDATION_ERROR" + assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"] + assert store.add_calls == [] + + +@pytest.mark.parametrize( + "body", + [ + {"provider": "unsupported", "key": RAW_KEY}, + {"provider": "openai", "key": ""}, + {"provider": "openai", "key": " "}, + {"provider": "openai", "key": "abcd"}, + {"provider": "openai", "key": "secret-ab!?"}, + {"provider": "openai", "key": RAW_KEY, "label": "x" * 101}, + {"provider": "openai", "key": RAW_KEY, "label": f"contains {RAW_KEY}"}, + ], +) +def test_add_key_rejects_invalid_raw_request_before_store(provider_key_client, body): + client, store = provider_key_client + response = client.post( + f"/api/v1/brands/{BRAND_ID}/keys", + headers={"Idempotency-Key": str(REQUEST_ID)}, + json=body, + ) + + assert response.status_code == 400 + assert response.json()["error"]["code"] == "VALIDATION_ERROR" + assert RAW_KEY not in response.text + assert store.add_calls == [] + + +@pytest.mark.parametrize( + ("error", "status_code", "code", "message"), + [ + (LookupError(), 404, "BRAND_NOT_FOUND", "Brand not found."), + ( + BrandCleanupRequiredError(), + 409, + "BRAND_CLEANUP_REQUIRED", + "Brand cleanup is required. Retry deletion.", + ), + ( + IdempotencyKeyRetiredError(), + 409, + "IDEMPOTENCY_KEY_RETIRED", + "This add request was already completed and deleted. Use a new request ID.", + ), + ( + VaultUnavailableError(), + 502, + "VAULT_UNAVAILABLE", + "Secure key storage is unavailable right now.", + ), + ], +) +def test_provider_key_errors_use_exact_safe_envelopes( + provider_key_client, error, status_code, code, message +): + client, store = provider_key_client + store.error = error + response = client.post( + f"/api/v1/brands/{BRAND_ID}/keys", + headers={"Idempotency-Key": str(REQUEST_ID)}, + json={"provider": "gemini", "key": RAW_KEY}, + ) + + assert response.status_code == status_code + assert response.json()["error"]["code"] == code + assert response.json()["error"]["message"] == message + assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"] + assert RAW_KEY not in response.text + + +def test_opaque_brand_error_is_identical_for_list_and_add(provider_key_client): + client, store = provider_key_client + store.error = LookupError() + list_response = client.get(f"/api/v1/brands/{BRAND_ID}/keys") + add_response = client.post( + f"/api/v1/brands/{BRAND_ID}/keys", + headers={"Idempotency-Key": str(REQUEST_ID)}, + json={"provider": "openai", "key": RAW_KEY}, + ) + + for response in (list_response, add_response): + assert response.status_code == 404 + assert response.json()["error"]["code"] == "BRAND_NOT_FOUND" + assert response.json()["error"]["message"] == "Brand not found." + + +def test_response_model_filters_internal_store_fields(provider_key_client): + client, store = provider_key_client + store.keys = [ + { + **_safe_key().model_dump(), + "brand_id": BRAND_ID, + "vault_secret_id": UUID("55555555-5555-5555-5555-555555555555"), + "lifecycle": "normal", + "raw_key": RAW_KEY, + } + ] + response = client.get(f"/api/v1/brands/{BRAND_ID}/keys") + + assert response.status_code == 200 + assert RAW_KEY not in response.text + assert "vault_secret_id" not in response.text + + +def test_add_uses_no_provider_client_dependency(provider_key_client): + client, _ = provider_key_client + response = client.post( + f"/api/v1/brands/{BRAND_ID}/keys", + headers={"Idempotency-Key": str(REQUEST_ID)}, + json={"provider": "openai", "key": RAW_KEY}, + ) + assert response.status_code == 201 diff --git a/backend/tests/integration/test_provider_key_rls.py b/backend/tests/integration/test_provider_key_rls.py index 629cdb0..36189ed 100644 --- a/backend/tests/integration/test_provider_key_rls.py +++ b/backend/tests/integration/test_provider_key_rls.py @@ -7,6 +7,7 @@ import httpx import jwt import pytest +from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.engine import Engine from sqlalchemy.exc import DBAPIError @@ -87,7 +88,6 @@ def security_fixture(): engine = get_engine() user_ids: list[str] = [] brand_ids: list[str] = [] - key_ids: list[str] = [] with httpx.Client(timeout=30.0) as client: try: user_a, token_a = _signup_and_login( @@ -106,7 +106,6 @@ def security_fixture(): brand_a, brand_b = str(uuid4()), str(uuid4()) key_a, key_b = str(uuid4()), str(uuid4()) brand_ids.extend((brand_a, brand_b)) - key_ids.extend((key_a, key_b)) with engine.begin() as connection: connection.execute( text( @@ -149,14 +148,29 @@ def security_fixture(): "key_a": key_a, }) finally: - if key_ids: + if brand_ids: with engine.begin() as connection: + vault_ids = connection.execute( + text( + "SELECT vault_secret_id FROM provider_keys " + "WHERE brand_id = ANY(CAST(:ids AS uuid[]))" + ), + {"ids": brand_ids}, + ).scalars().all() + if vault_ids: + connection.execute( + text( + "DELETE FROM vault.secrets " + "WHERE id = ANY(CAST(:ids AS uuid[]))" + ), + {"ids": vault_ids}, + ) connection.execute( text( "DELETE FROM provider_keys " - "WHERE id = ANY(CAST(:ids AS uuid[]))" + "WHERE brand_id = ANY(CAST(:ids AS uuid[]))" ), - {"ids": key_ids}, + {"ids": brand_ids}, ) if brand_ids: with engine.begin() as connection: @@ -664,3 +678,116 @@ def test_backend_role_has_required_application_and_vault_privileges( text("SELECT has_table_privilege(current_user, :table, 'SELECT')"), {"table": relation}, ).scalar_one() + + +def test_provider_key_api_owner_and_hidden_brand_parity(security_fixture): + from backend.app.main import app + + fixture = security_fixture + nonexistent_brand = str(uuid4()) + raw_key = f"rls-api-secret-{uuid4().hex}-Q7_W" + with TestClient(app) as client: + owner_list = client.get( + f"/api/v1/brands/{fixture['brand_a']}/keys", + headers={"Authorization": f"Bearer {fixture['token_a']}"}, + ) + hidden_list = client.get( + f"/api/v1/brands/{fixture['brand_a']}/keys", + headers={"Authorization": f"Bearer {fixture['token_b']}"}, + ) + missing_list = client.get( + f"/api/v1/brands/{nonexistent_brand}/keys", + headers={"Authorization": f"Bearer {fixture['token_b']}"}, + ) + owner_add = client.post( + f"/api/v1/brands/{fixture['brand_a']}/keys", + headers={ + "Authorization": f"Bearer {fixture['token_a']}", + "Idempotency-Key": str(uuid4()), + }, + json={"provider": "gemini", "key": raw_key, "make_active": False}, + ) + hidden_add = client.post( + f"/api/v1/brands/{fixture['brand_a']}/keys", + headers={ + "Authorization": f"Bearer {fixture['token_b']}", + "Idempotency-Key": str(uuid4()), + }, + json={"provider": "openai", "key": raw_key}, + ) + missing_add = client.post( + f"/api/v1/brands/{nonexistent_brand}/keys", + headers={ + "Authorization": f"Bearer {fixture['token_b']}", + "Idempotency-Key": str(uuid4()), + }, + json={"provider": "openai", "key": raw_key}, + ) + + assert owner_list.status_code == 200 + assert owner_list.json() == { + "keys": [ + { + "id": fixture["key_a"], + "provider": "openai", + "label": "A", + "key_hint": "***a-_1", + "is_active": False, + "is_valid": None, + "last_validated_at": None, + "last_validation_error": None, + "cleanup_state": "normal", + "created_at": owner_list.json()["keys"][0]["created_at"], + } + ] + } + assert owner_add.status_code == 201 + assert owner_add.json()["key_hint"] == "***Q7_W" + assert raw_key not in owner_add.text + for hidden, missing in ((hidden_list, missing_list), (hidden_add, missing_add)): + assert hidden.status_code == missing.status_code == 404 + assert hidden.json()["error"]["code"] == missing.json()["error"]["code"] == "BRAND_NOT_FOUND" + assert hidden.json()["error"]["message"] == missing.json()["error"]["message"] == "Brand not found." + assert raw_key not in hidden_add.text + missing_add.text + + +def test_provider_key_data_api_exposes_only_owner_safe_columns(security_fixture): + fixture = security_fixture + client = fixture["client"] + + def headers(token: str) -> dict[str, str]: + return { + "apikey": fixture["supabase_key"], + "Authorization": f"Bearer {token}", + } + + safe_select = ( + "id,provider,label,key_hint,lifecycle,is_active,is_valid," + "last_validated_at,last_validation_error,created_at" + ) + owner = client.get( + f"{fixture['supabase_url']}/rest/v1/provider_keys", + headers=headers(fixture["token_a"]), + params={"select": safe_select}, + ) + non_owner = client.get( + f"{fixture['supabase_url']}/rest/v1/provider_keys", + headers=headers(fixture["token_b"]), + params={"select": safe_select}, + ) + internal = client.get( + f"{fixture['supabase_url']}/rest/v1/provider_keys", + headers=headers(fixture["token_a"]), + params={"select": "brand_id,vault_secret_id,validation_token,last_used_at"}, + ) + vault = client.get( + f"{fixture['supabase_url']}/rest/v1/decrypted_secrets", + headers={**headers(fixture["token_a"]), "Accept-Profile": "vault"}, + ) + + assert owner.status_code == 200 + assert [row["id"] for row in owner.json()] == [fixture["key_a"]] + assert non_owner.status_code == 200 + assert all(row["id"] != fixture["key_a"] for row in non_owner.json()) + assert internal.status_code in {400, 401, 403} + assert vault.status_code in {400, 401, 403, 404, 406} diff --git a/backend/tests/integration/test_provider_keys.py b/backend/tests/integration/test_provider_keys.py new file mode 100644 index 0000000..535f41f --- /dev/null +++ b/backend/tests/integration/test_provider_keys.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import os +from uuid import UUID, uuid4 + +import httpx +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + + +class SecretFixture(str): + def __repr__(self) -> str: + return "" + + +class ProviderFixture(dict): + def __repr__(self) -> str: + return "" + + +def _required_env(name: str) -> str: + value = os.getenv(name) + if not value: + pytest.fail(f"{name} is required; provider-key integration tests must execute") + return value + + +def _signup_and_login( + client: httpx.Client, supabase_url: str, supabase_key: str +) -> tuple[str, str]: + email = f"provider-keys-{uuid4().hex[:12]}@example.com" + password = "12345678" + signup = client.post( + f"{supabase_url}/auth/v1/signup", + headers={"apikey": supabase_key, "Content-Type": "application/json"}, + json={"email": email, "password": password}, + ) + assert signup.status_code in {200, 201} + token = client.post( + f"{supabase_url}/auth/v1/token?grant_type=password", + headers={"apikey": supabase_key, "Content-Type": "application/json"}, + json={"email": email, "password": password}, + ) + assert token.status_code == 200 + return signup.json()["user"]["id"], token.json()["access_token"] + + +@pytest.fixture +def provider_fixture(): + supabase_url = _required_env("SUPABASE_URL") + supabase_key = _required_env("SUPABASE_SECRET_KEY") + _required_env("DATABASE_URL") + + from backend.app.config import get_engine + + engine = get_engine() + user_id: str | None = None + brand_id = uuid4() + with httpx.Client(timeout=30.0) as supabase_client: + try: + user_id, access_token = _signup_and_login( + supabase_client, supabase_url, supabase_key + ) + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO brands (id, owner_user_id, name) " + "VALUES (:id, :owner, 'Provider Keys Integration')" + ), + {"id": brand_id, "owner": user_id}, + ) + yield ProviderFixture({ + "engine": engine, + "brand_id": brand_id, + "headers": {"Authorization": f"Bearer {access_token}"}, + }) + finally: + with engine.begin() as connection: + vault_ids = connection.execute( + text( + "SELECT vault_secret_id FROM provider_keys " + "WHERE brand_id = :brand_id" + ), + {"brand_id": brand_id}, + ).scalars().all() + if vault_ids: + connection.execute( + text("DELETE FROM vault.secrets WHERE id = ANY(:ids)"), + {"ids": vault_ids}, + ) + connection.execute( + text("DELETE FROM provider_keys WHERE brand_id = :brand_id"), + {"brand_id": brand_id}, + ) + connection.execute( + text( + "DELETE FROM provider_key_idempotency " + "WHERE brand_id = :brand_id" + ), + {"brand_id": brand_id}, + ) + connection.execute( + text("DELETE FROM brands WHERE id = :brand_id"), + {"brand_id": brand_id}, + ) + if user_id: + supabase_client.delete( + f"{supabase_url}/auth/v1/admin/users/{user_id}", + headers={ + "apikey": supabase_key, + "Authorization": f"Bearer {supabase_key}", + }, + ) + + +def test_real_vault_add_list_activation_idempotency_and_retired_receipt( + provider_fixture, +): + from backend.app.main import app + + fixture = provider_fixture + brand_id = fixture["brand_id"] + openai_request = uuid4() + gemini_request = uuid4() + replacement_request = uuid4() + openai_key = SecretFixture(f"openai-integration-{uuid4().hex}-A1B2") + gemini_key = SecretFixture(f"gemini-integration-{uuid4().hex}-C3_D") + replacement_key = SecretFixture(f"openai-replacement-{uuid4().hex}-E5-F") + + with TestClient(app) as client: + empty = client.get( + f"/api/v1/brands/{brand_id}/keys", headers=fixture["headers"] + ) + first = client.post( + f"/api/v1/brands/{brand_id}/keys", + headers={**fixture["headers"], "Idempotency-Key": str(openai_request)}, + json={ + "provider": "openai", + "key": str(openai_key), + "label": "Inactive OpenAI", + "make_active": False, + }, + ) + gemini = client.post( + f"/api/v1/brands/{brand_id}/keys", + headers={**fixture["headers"], "Idempotency-Key": str(gemini_request)}, + json={"provider": "gemini", "key": str(gemini_key)}, + ) + replacement = client.post( + f"/api/v1/brands/{brand_id}/keys", + headers={ + **fixture["headers"], + "Idempotency-Key": str(replacement_request), + }, + json={"provider": "openai", "key": str(replacement_key)}, + ) + retry = client.post( + f"/api/v1/brands/{brand_id}/keys", + headers={**fixture["headers"], "Idempotency-Key": str(openai_request)}, + json={"provider": "gemini", "key": str(replacement_key)}, + ) + listed = client.get( + f"/api/v1/brands/{brand_id}/keys", headers=fixture["headers"] + ) + + assert empty.status_code == 200 and empty.json() == {"keys": []} + assert first.status_code == gemini.status_code == replacement.status_code == 201 + assert first.json()["key_hint"] == "***A1B2" + assert gemini.json()["key_hint"] == "***C3_D" + assert replacement.json()["key_hint"] == "***E5-F" + assert first.json()["is_active"] is False + assert gemini.json()["is_active"] is True + assert replacement.json()["is_active"] is True + assert retry.status_code == 201 + assert retry.json() == first.json() + assert listed.status_code == 200 + keys = listed.json()["keys"] + assert [(key["provider"], key["id"]) for key in keys] == [ + ("openai", replacement.json()["id"]), + ("openai", first.json()["id"]), + ("gemini", gemini.json()["id"]), + ] + serialized = "".join(response.text for response in (first, gemini, replacement, retry, listed)) + for raw_key in (openai_key, gemini_key, replacement_key): + assert raw_key not in serialized + + engine = fixture["engine"] + with engine.connect() as connection: + rows = connection.execute( + text( + "SELECT id, provider, vault_secret_id, key_hint, is_active " + "FROM provider_keys WHERE brand_id = :brand_id " + "ORDER BY provider, created_at DESC, id DESC" + ), + {"brand_id": brand_id}, + ).mappings().all() + assert len(rows) == 3 + assert sum(row["is_active"] for row in rows if row["provider"] == "openai") == 1 + assert sum(row["is_active"] for row in rows if row["provider"] == "gemini") == 1 + assert connection.execute( + text( + "SELECT count(*) FROM provider_key_idempotency " + "WHERE brand_id = :brand_id" + ), + {"brand_id": brand_id}, + ).scalar_one() == 3 + decrypted_matches = connection.execute( + text( + "SELECT count(*) FROM provider_keys pk " + "JOIN vault.decrypted_secrets ds ON ds.id = pk.vault_secret_id " + "WHERE pk.brand_id = :brand_id AND ds.decrypted_secret = ANY(:values)" + ), + { + "brand_id": brand_id, + "values": [str(openai_key), str(gemini_key), str(replacement_key)], + }, + ).scalar_one() + assert decrypted_matches == 3 + + retired_id = UUID(first.json()["id"]) + with engine.begin() as connection: + vault_id = connection.execute( + text("SELECT vault_secret_id FROM provider_keys WHERE id = :id"), + {"id": retired_id}, + ).scalar_one() + connection.execute(text("DELETE FROM vault.secrets WHERE id = :id"), {"id": vault_id}) + connection.execute(text("DELETE FROM provider_keys WHERE id = :id"), {"id": retired_id}) + receipt = connection.execute( + text( + "SELECT state, provider_key_id FROM provider_key_idempotency " + "WHERE brand_id = :brand_id AND request_id = :request_id" + ), + {"brand_id": brand_id, "request_id": openai_request}, + ).one() + assert receipt.state == "active" + assert receipt.provider_key_id is None + + with TestClient(app) as client: + retired = client.post( + f"/api/v1/brands/{brand_id}/keys", + headers={**fixture["headers"], "Idempotency-Key": str(openai_request)}, + json={"provider": "openai", "key": str(openai_key)}, + ) + assert retired.status_code == 409 + assert retired.json()["error"]["code"] == "IDEMPOTENCY_KEY_RETIRED" + + +def test_real_add_validation_and_cleanup_fences_create_no_secret(provider_fixture): + from backend.app.main import app + + fixture = provider_fixture + brand_id = fixture["brand_id"] + raw_key = SecretFixture(f"fenced-provider-{uuid4().hex}-Z9_Y") + engine = fixture["engine"] + invalid_bodies = [ + {"provider": "openai", "key": ""}, + {"provider": "openai", "key": "abcd"}, + {"provider": "openai", "key": "invalid!?"}, + {"provider": "unknown", "key": str(raw_key)}, + {"provider": "gemini", "key": str(raw_key), "label": "x" * 101}, + ] + with engine.connect() as connection: + before = connection.execute(text("SELECT count(*) FROM vault.secrets")).scalar_one() + with TestClient(app) as client: + for body in invalid_bodies: + response = client.post( + f"/api/v1/brands/{brand_id}/keys", + headers={**fixture["headers"], "Idempotency-Key": str(uuid4())}, + json=body, + ) + assert response.status_code == 400 + cleanup_key_id = uuid4() + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO provider_keys " + "(id, brand_id, provider, vault_secret_id, key_hint, lifecycle) " + "VALUES (:id, :brand_id, 'openai', :vault_id, '***K3_Y', " + "'cleanup_required')" + ), + { + "id": cleanup_key_id, + "brand_id": brand_id, + "vault_id": uuid4(), + }, + ) + pending_key_cleanup = client.post( + f"/api/v1/brands/{brand_id}/keys", + headers={**fixture["headers"], "Idempotency-Key": str(uuid4())}, + json={"provider": "openai", "key": str(raw_key)}, + ) + with engine.begin() as connection: + connection.execute( + text("DELETE FROM provider_keys WHERE id = :id"), + {"id": cleanup_key_id}, + ) + with engine.begin() as connection: + connection.execute( + text( + "UPDATE brands SET deletion_state = 'cleanup_required' " + "WHERE id = :brand_id" + ), + {"brand_id": brand_id}, + ) + fenced = client.post( + f"/api/v1/brands/{brand_id}/keys", + headers={**fixture["headers"], "Idempotency-Key": str(uuid4())}, + json={"provider": "openai", "key": str(raw_key)}, + ) + assert pending_key_cleanup.status_code == 409 + assert pending_key_cleanup.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED" + assert fenced.status_code == 409 + assert fenced.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED" + with engine.connect() as connection: + assert connection.execute(text("SELECT count(*) FROM vault.secrets")).scalar_one() == before + assert connection.execute( + text("SELECT count(*) FROM provider_keys WHERE brand_id = :brand_id"), + {"brand_id": brand_id}, + ).scalar_one() == 0 + + +def test_real_vault_and_metadata_work_roll_back_together(provider_fixture): + fixture = provider_fixture + engine = fixture["engine"] + vault_id = None + raw_key = SecretFixture(f"rollback-provider-{uuid4().hex}-R0_L") + + with pytest.raises(DBAPIError): + with engine.begin() as connection: + vault_id = connection.execute( + text("SELECT vault.create_secret(:secret, NULL, '')"), + {"secret": str(raw_key)}, + ).scalar_one() + connection.execute(text("SELECT 1 / 0")) + + assert vault_id is not None + with engine.connect() as connection: + assert connection.execute( + text("SELECT count(*) FROM vault.secrets WHERE id = :id"), + {"id": vault_id}, + ).scalar_one() == 0 diff --git a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx new file mode 100644 index 0000000..a50220d --- /dev/null +++ b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx @@ -0,0 +1,368 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useRef, useState, type FormEvent } from "react"; + +import { getPublicEnv } from "@/lib/runtime-env"; +import { supabase } from "@/lib/supabase/client"; + +type Provider = "openai" | "gemini"; +type CleanupState = "normal" | "cleanup_required"; + +type Brand = { + id: string; + name: string; + cleanup_state: CleanupState; +}; + +type ProviderKey = { + id: string; + provider: Provider; + label: string | null; + key_hint: string; + is_active: boolean; + is_valid: boolean | null; + last_validated_at: string | null; + last_validation_error: string | null; + cleanup_state: CleanupState; + created_at: string; +}; + +type ErrorResponse = { error?: { code?: string } }; + +const PROVIDERS: Array<{ id: Provider; name: string }> = [ + { id: "openai", name: "OpenAI" }, + { id: "gemini", name: "Gemini" }, +]; + +function addErrorMessage(code?: string) { + switch (code) { + case "VALIDATION_ERROR": + return "Check the key format and label, then try again."; + case "BRAND_CLEANUP_REQUIRED": + return "Brand cleanup is required before another key can be added."; + case "IDEMPOTENCY_KEY_RETIRED": + return "This request was already completed and deleted. Submit again to create a new key."; + case "VAULT_UNAVAILABLE": + return "Secure key storage is unavailable right now."; + default: + return "Unable to add the provider key."; + } +} + +function validationLabel(key: ProviderKey) { + if (key.is_valid === true) return "Valid"; + if (key.is_valid === false) return "Invalid"; + return "Unvalidated"; +} + +export default function ProviderKeysPage() { + const { brandId } = useParams<{ brandId: string }>(); + const router = useRouter(); + const apiBase = getPublicEnv("NEXT_PUBLIC_API_URL"); + const keyInputRef = useRef(null); + const retryIdRef = useRef(null); + const [brand, setBrand] = useState(null); + const [keys, setKeys] = useState([]); + const [provider, setProvider] = useState("openai"); + const [label, setLabel] = useState(""); + const [makeActive, setMakeActive] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [isNotFound, setIsNotFound] = useState(false); + const [isAdding, setIsAdding] = useState(false); + const [error, setError] = useState(null); + const [addError, setAddError] = useState(null); + + useEffect(() => { + let active = true; + + async function loadKeys() { + setIsLoading(true); + setError(null); + setIsNotFound(false); + + try { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session) { + router.push("/login"); + return; + } + + const path = `${apiBase}/v1/brands/${encodeURIComponent(brandId)}`; + const headers = { Authorization: `Bearer ${session.access_token}` }; + const [brandResponse, keysResponse] = await Promise.all([ + fetch(path, { headers }), + fetch(`${path}/keys`, { headers }), + ]); + + if (brandResponse.status === 404 || keysResponse.status === 404) { + if (active) setIsNotFound(true); + return; + } + if (!brandResponse.ok || !keysResponse.ok) { + throw new Error("Provider key metadata was unavailable."); + } + + const [brandBody, keysBody] = (await Promise.all([ + brandResponse.json(), + keysResponse.json(), + ])) as [Brand, { keys: ProviderKey[] }]; + if (active) { + setBrand(brandBody); + setKeys(keysBody.keys); + } + } catch { + if (active) setError("Unable to load provider keys."); + } finally { + if (active) setIsLoading(false); + } + } + + void loadKeys(); + return () => { + active = false; + }; + }, [apiBase, brandId, router]); + + function beginNewAttempt() { + retryIdRef.current = null; + setAddError(null); + } + + async function addKey(event: FormEvent) { + event.preventDefault(); + const rawKey = keyInputRef.current?.value ?? ""; + if (!rawKey) { + setAddError("Enter a provider key."); + return; + } + + const idempotencyKey = retryIdRef.current ?? crypto.randomUUID(); + setAddError(null); + setIsAdding(true); + + try { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session) { + router.push("/login"); + return; + } + + const response = await fetch( + `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/keys`, + { + method: "POST", + headers: { + Authorization: `Bearer ${session.access_token}`, + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify({ + provider, + key: rawKey, + label: label || null, + make_active: makeActive, + }), + } + ); + + const body = (await response.json().catch(() => null)) as + | ProviderKey + | ErrorResponse + | null; + if (!response.ok) { + retryIdRef.current = response.status >= 500 ? idempotencyKey : null; + setAddError(addErrorMessage((body as ErrorResponse | null)?.error?.code)); + return; + } + + retryIdRef.current = null; + const addedKey = body as ProviderKey; + setKeys((current) => [ + addedKey, + ...current.map((key) => + addedKey.is_active && key.provider === addedKey.provider + ? { ...key, is_active: false } + : key + ), + ]); + if (keyInputRef.current) keyInputRef.current.value = ""; + setLabel(""); + setMakeActive(true); + } catch { + retryIdRef.current = idempotencyKey; + setAddError( + "The request outcome is unknown. Retry this unchanged submission to reconcile it." + ); + } finally { + setIsAdding(false); + } + } + + if (isLoading) { + return

Loading provider keys...

; + } + + if (isNotFound) { + return ( +
+

Brand not found

+

+ This brand does not exist or is not available to you. +

+ + Back to brands + +
+ ); + } + + if (error || !brand) { + return

{error ?? "Unable to load provider keys."}

; + } + + const providerName = PROVIDERS.find((item) => item.id === provider)?.name ?? provider; + const providerKeys = keys.filter((key) => key.provider === provider); + const cleanupRequired = brand.cleanup_state === "cleanup_required"; + + return ( +
+
+ + Back to {brand.name} + +
+
+

Provider setup

+

Provider keys

+

+ Keys are stored securely. Only labels and masked hints are shown after submission. +

+
+ {cleanupRequired ? ( + + Brand cleanup required + + ) : null} +
+
+ + + +
+
+
+

{providerName} keys

+ {providerKeys.length} saved +
+ {providerKeys.length === 0 ? ( +
+ No {providerName} keys have been added. +
+ ) : ( + providerKeys.map((key) => ( +
+
+
+

{key.label || `${providerName} key`}

+

{key.key_hint}

+
+
+ + {key.is_active ? "Active" : "Inactive"} + + + {validationLabel(key)} + + {key.cleanup_state === "cleanup_required" ? ( + + Cleanup required + + ) : null} +
+
+

+ Added {new Date(key.created_at).toLocaleDateString()} +

+
+ )) + )} +
+ +
+
+

Add {providerName} key

+

The credential cannot be revealed after it is stored.

+
+ + + + {addError ?

{addError}

: null} + +
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/brands/[brandId]/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/page.tsx index 580e080..f70572e 100644 --- a/frontend/app/(dashboard)/brands/[brandId]/page.tsx +++ b/frontend/app/(dashboard)/brands/[brandId]/page.tsx @@ -17,6 +17,7 @@ type Brand = { id: string; name: string; logo_url: string | null; + cleanup_state: "normal" | "cleanup_required"; created_at: string; }; @@ -282,6 +283,8 @@ export default function BrandDetailPage() { ); } + const cleanupRequired = brand.cleanup_state === "cleanup_required"; + return (
@@ -291,9 +294,14 @@ export default function BrandDetailPage() {

Brand

-

- {brand.name} -

+
+

{brand.name}

+ {cleanupRequired ? ( + + Cleanup required + + ) : null} +
Created
@@ -304,6 +312,25 @@ export default function BrandDetailPage() {
+
+

Provider keys

+

+ Configure brand-scoped OpenAI and Gemini credentials without exposing them after submission. +

+ {cleanupRequired ? ( +

+ Provider setup is unavailable until brand cleanup completes. +

+ ) : ( + + Manage provider keys + + )} +
+

Brand logo

@@ -331,7 +358,7 @@ export default function BrandDetailPage() { className="block w-full text-sm file:mr-4 file:rounded-md file:border file:bg-white file:px-3 file:py-2 file:text-sm" type="file" accept="image/png,image/jpeg,image/webp" - disabled={isUploading || isRemoving || isDeleting} + disabled={cleanupRequired || isUploading || isRemoving || isDeleting} onChange={selectLogo} /> {logoError ? ( @@ -343,7 +370,7 @@ export default function BrandDetailPage() {

) : ( - providerKeys.map((key) => ( -
-
-
-

{key.label || `${providerName} key`}

-

{key.key_hint}

-
-
- - {key.is_active ? "Active" : "Inactive"} - - - {validationLabel(key)} - - {key.cleanup_state === "cleanup_required" ? ( - - Cleanup required + providerKeys.map((key) => { + const isValidating = validatingKeyIds.has(key.id); + const feedback = validationFeedbackByKey[key.id]; + const validationDisabled = + isValidating || + cleanupRequired || + key.cleanup_state === "cleanup_required"; + + return ( +
+
+
+

{key.label || `${providerName} key`}

+

{key.key_hint}

+
+
+ + {key.is_active ? "Active" : "Inactive"} + + + {validationLabel(key)} - ) : null} + {key.cleanup_state === "cleanup_required" ? ( + + Cleanup required + + ) : null} +
+
+
+
+

Added {new Date(key.created_at).toLocaleDateString()}

+

+ {key.last_validated_at + ? `Last validated ${new Date(key.last_validated_at).toLocaleString()}` + : "Not yet validated"} +

+
+
-
-

- Added {new Date(key.created_at).toLocaleDateString()} -

-
- )) + {feedback ? ( +

+ {feedback.message} +

+ ) : null} + + ); + }) )} diff --git a/frontend/tests/e2e/provider-keys.spec.ts b/frontend/tests/e2e/provider-keys.spec.ts index 5d0f416..96f2052 100644 --- a/frontend/tests/e2e/provider-keys.spec.ts +++ b/frontend/tests/e2e/provider-keys.spec.ts @@ -1,15 +1,17 @@ import { expect, test } from "@playwright/test"; +test.setTimeout(60_000); + type SafeKey = { id: string; provider: "openai" | "gemini"; label: string | null; key_hint: string; is_active: boolean; - is_valid: null; - last_validated_at: null; - last_validation_error: null; - cleanup_state: "normal"; + is_valid: boolean | null; + last_validated_at: string | null; + last_validation_error: "INVALID_CREDENTIAL" | null; + cleanup_state: "normal" | "cleanup_required"; created_at: string; }; @@ -23,6 +25,16 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => let submittedKeyBodies = 0; let requestWasOpaque = true; let responseWasOpaque = true; + let validationRequests = 0; + let brandCleanupState: "normal" | "cleanup_required" = "normal"; + let validationMode: + | "valid" + | "invalid" + | "unavailable" + | "timeout" + | "in_progress" + | "delayed" = "valid"; + let releaseDelayedValidation = () => {}; await page.route("**/api/v1/**", async (route) => { const request = route.request(); @@ -37,7 +49,7 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => id: brandId, name: "Provider Key Test Brand", logo_url: null, - cleanup_state: "normal", + cleanup_state: brandCleanupState, created_at: "2026-07-28T10:00:00Z", }, ], @@ -52,7 +64,7 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => id: brandId, name: "Provider Key Test Brand", logo_url: null, - cleanup_state: "normal", + cleanup_state: brandCleanupState, created_at: "2026-07-28T10:00:00Z", }); responseWasOpaque &&= !Object.values(rawKeys).some((key) => body.includes(key)); @@ -104,6 +116,71 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => return; } + const validationMatch = url.pathname.match( + new RegExp(`^/api/v1/brands/${brandId}/keys/([^/]+)/validate$`) + ); + if (validationMatch && request.method() === "POST") { + validationRequests += 1; + const key = savedKeys.find((item) => item.id === validationMatch[1]); + if (!key) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: { code: "PROVIDER_KEY_NOT_FOUND" } }), + }); + return; + } + + if (validationMode === "delayed") { + await new Promise((resolve) => { + releaseDelayedValidation = resolve; + }); + } + + const attemptedAt = new Date().toISOString(); + if (validationMode === "valid" || validationMode === "delayed") { + key.is_valid = true; + key.last_validated_at = attemptedAt; + key.last_validation_error = null; + } else if (validationMode === "invalid") { + key.is_active = false; + key.is_valid = false; + key.last_validated_at = attemptedAt; + key.last_validation_error = "INVALID_CREDENTIAL"; + } + + const temporaryCodes = { + unavailable: "PROVIDER_UNAVAILABLE", + timeout: "PROVIDER_TIMEOUT", + in_progress: "VALIDATION_IN_PROGRESS", + } as const; + const isTemporary = + validationMode === "unavailable" || + validationMode === "timeout" || + validationMode === "in_progress"; + const body = JSON.stringify({ + outcome: isTemporary + ? "temporary" + : validationMode === "invalid" + ? "invalid" + : "valid", + attempted_at: attemptedAt, + code: + validationMode === "unavailable" || + validationMode === "timeout" || + validationMode === "in_progress" + ? temporaryCodes[validationMode] + : validationMode === "invalid" + ? "INVALID_CREDENTIAL" + : "VALID", + message: "Fixed safe validation result.", + key: { ...key }, + }); + responseWasOpaque &&= !Object.values(rawKeys).some((rawKey) => body.includes(rawKey)); + await route.fulfill({ status: 200, contentType: "application/json", body }); + return; + } + await route.fulfill({ status: 404, contentType: "application/json", @@ -131,21 +208,94 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => await expect(page.getByText("Unvalidated", { exact: true })).toBeVisible(); await expect(page.getByLabel("API key")).toHaveValue(""); + const openAIKey = page.getByRole("article").filter({ hasText: "Inactive OpenAI" }); + validationMode = "valid"; + await openAIKey.getByRole("button", { name: "Validate key" }).click(); + await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible(); + await expect(openAIKey.getByText(/^Last validated /)).toBeVisible(); + await expect(openAIKey.getByRole("status")).toHaveText("The provider accepted this key."); + + const validatedText = await openAIKey.getByText(/^Last validated /).textContent(); + validationMode = "unavailable"; + await openAIKey.getByRole("button", { name: "Validate key" }).click(); + await expect(openAIKey.getByRole("status")).toContainText("saved key status was not changed"); + await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible(); + await expect(openAIKey.getByText(/^Last validated /)).toHaveText(validatedText ?? ""); + + validationMode = "timeout"; + await openAIKey.getByRole("button", { name: "Validate key" }).click(); + await expect(openAIKey.getByRole("status")).toContainText("Validation timed out"); + await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible(); + + validationMode = "in_progress"; + await openAIKey.getByRole("button", { name: "Validate key" }).click(); + await expect(openAIKey.getByRole("status")).toContainText("already in progress"); + await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible(); + + validationMode = "delayed"; + const requestsBeforeDedupe = validationRequests; + await openAIKey + .getByRole("button", { name: "Validate key" }) + .evaluate((button) => { + (button as HTMLButtonElement).click(); + (button as HTMLButtonElement).click(); + }); + await expect(openAIKey.getByRole("button", { name: "Validating..." })).toBeDisabled(); + await expect.poll(() => validationRequests).toBe(requestsBeforeDedupe + 1); + releaseDelayedValidation(); + await expect(openAIKey.getByRole("button", { name: "Validate key" })).toBeEnabled(); + await page.getByRole("tab", { name: "Gemini" }).click(); await page.getByLabel(/Label/).fill("Active Gemini"); await page.getByLabel("API key").fill(rawKeys.gemini); await page.getByRole("button", { name: "Add Gemini key" }).click(); await expect(page.getByText("***G3D4")).toBeVisible(); await expect(page.getByText("Active", { exact: true })).toBeVisible(); + const geminiKey = page.getByRole("article").filter({ hasText: "Active Gemini" }); + validationMode = "invalid"; + await geminiKey.getByRole("button", { name: "Validate key" }).click(); + await expect(geminiKey.getByText("Invalid", { exact: true })).toBeVisible(); + await expect(geminiKey.getByText("Inactive", { exact: true })).toBeVisible(); + await expect(geminiKey.getByRole("status")).toHaveText("The provider rejected this key."); await page.reload(); + await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible(); await expect(page.getByText("***A1B2")).toBeVisible(); await page.getByRole("tab", { name: "Gemini" }).click(); await expect(page.getByText("***G3D4")).toBeVisible(); const visibleText = (await page.locator("body").textContent()) ?? ""; const rawKeyIsVisible = Object.values(rawKeys).some((key) => visibleText.includes(key)); + const storedOpenAIKey = savedKeys.find((key) => key.provider === "openai"); + if (!storedOpenAIKey) throw new Error("Expected the OpenAI fixture key."); + storedOpenAIKey.cleanup_state = "cleanup_required"; + storedOpenAIKey.is_active = false; + storedOpenAIKey.is_valid = null; + storedOpenAIKey.last_validated_at = null; + storedOpenAIKey.last_validation_error = null; + await page.reload(); + await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible(); + await expect( + page + .getByRole("article") + .filter({ hasText: "Inactive OpenAI" }) + .getByRole("button", { name: "Validate key" }) + ).toBeDisabled(); + + storedOpenAIKey.cleanup_state = "normal"; + brandCleanupState = "cleanup_required"; + await page.reload(); + await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible(); + await expect( + page + .getByRole("article") + .filter({ hasText: "Inactive OpenAI" }) + .getByRole("button", { name: "Validate key" }) + ).toBeDisabled(); + await expect(page.getByLabel("API key")).toBeDisabled(); + expect(submittedKeyBodies === 2).toBeTruthy(); + expect(validationRequests).toBe(6); expect(requestWasOpaque).toBeTruthy(); expect(responseWasOpaque).toBeTruthy(); expect(rawKeyIsVisible).toBeFalsy(); diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md index 5277627..a7a59ac 100644 --- a/specs/004-provider-keys/tasks.md +++ b/specs/004-provider-keys/tasks.md @@ -108,19 +108,19 @@ for both providers and verify only accepted/explicit-invalid outcomes persist ch ### Tests for User Story 2 -- [ ] T024 [P] [US2] Create failing `httpx.MockTransport` unit tests in `backend/tests/unit/test_provider_validation.py` for exact official URLs/headers, redirects and retries disabled, structurally valid success, OpenAI `error.code == invalid_api_key`, every allowed Gemini `google.rpc.ErrorInfo.reason`, ambiguous 400/401/403, permission/quota/rate-limit/service/unknown/malformed responses, provider request IDs, network errors, and timeout classification without logging body/header/key data -- [ ] T025 [P] [US2] Extend `backend/tests/contract/test_provider_keys.py` with failing exact-shape tests for `POST /api/v1/brands/{brand_id}/keys/{key_id}/validate`: valid/invalid/temporary code matrix, complete safe key snapshot, fixed provider messages, active invalid deactivation, temporary byte-for-byte state preservation, opaque path membership, cleanup conflicts, Vault failure, `VALIDATION_IN_PROGRESS`, `VALIDATION_SUPERSEDED`, and response within the route deadline -- [ ] T026 [P] [US2] Extend `backend/tests/integration/test_provider_keys.py` with failing real-Supabase validation lease tests for atomic claim plus Vault decrypt, no connection held during provider I/O, one provider request under overlap, database-clock lease expiry, stale fencing-token rejection, valid persistence, invalid persistence plus deactivation, temporary preservation, absent-secret fencing, and bounded pool/lock/statement failures +- [X] T024 [P] [US2] Create failing `httpx.MockTransport` unit tests in `backend/tests/unit/test_provider_validation.py` for exact official URLs/headers, redirects and retries disabled, structurally valid success, OpenAI `error.code == invalid_api_key`, every allowed Gemini `google.rpc.ErrorInfo.reason`, ambiguous 400/401/403, permission/quota/rate-limit/service/unknown/malformed responses, provider request IDs, network errors, and timeout classification without logging body/header/key data +- [X] T025 [P] [US2] Extend `backend/tests/contract/test_provider_keys.py` with failing exact-shape tests for `POST /api/v1/brands/{brand_id}/keys/{key_id}/validate`: valid/invalid/temporary code matrix, complete safe key snapshot, fixed provider messages, active invalid deactivation, temporary byte-for-byte state preservation, opaque path membership, cleanup conflicts, Vault failure, `VALIDATION_IN_PROGRESS`, `VALIDATION_SUPERSEDED`, and response within the route deadline +- [X] T026 [P] [US2] Extend `backend/tests/integration/test_provider_keys.py` with failing real-Supabase validation lease tests for atomic claim plus Vault decrypt, no connection held during provider I/O, one provider request under overlap, database-clock lease expiry, stale fencing-token rejection, valid persistence, invalid persistence plus deactivation, temporary preservation, absent-secret fencing, and bounded pool/lock/statement failures ### Implementation for User Story 2 -- [ ] T027 [P] [US2] Create `backend/app/services/provider_validation.py` with injectable async `httpx` transport, official OpenAI and Gemini model-list probes, no redirects/retries, structured provider-specific invalid predicates, fixed valid/invalid/temporary code/message mapping, response-body disposal, provider request-ID extraction, and provider I/O bounded by ten seconds and the remaining route deadline -- [ ] T028 [US2] Extend `backend/app/services/provider_key_store.py` with short claim/complete validation transactions: brand-first/key-second locking, lifecycle and brand fences, database-clock lease/token assignment, request-local Vault decryption, `VALIDATION_IN_PROGRESS`, matching-token completion, accepted/invalid persistence, atomic invalid deactivation, stale `VALIDATION_SUPERSEDED`, temporary no-op semantics, and best-effort lease clearing without holding a connection during HTTP -- [ ] T029 [US2] Add the validate handler to `backend/app/routes/provider_keys.py`, budget auth/pool/lock/Vault/provider/completion/serialization against the entry deadline, return all completed provider classifications as HTTP 200 outcomes, preserve fixed pre-lease error envelopes, and emit only safe provider/code/duration/request-ID logs -- [ ] T030 [US2] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with explicit validate actions, pending-state deduplication, valid/invalid/unvalidated status and timestamp rendering, fixed temporary outcome feedback, active-key deactivation refresh, and `finally` cleanup for every network path -- [ ] T031 [US2] Extend `backend/tests/integration/test_provider_key_rls.py` with owner/non-owner/nonexistent validate parity and assertions that direct clients cannot read or mutate validation tokens, lease expiries, Vault IDs, decrypted values, validity fields, or active state -- [ ] T032 [US2] Add deadline and secrecy regression cases to `backend/tests/contract/test_provider_keys.py` that consume budget during authentication, pool checkout, lock/Vault access, provider I/O, and completion; assert every leased request settles within 15 seconds and captured logs/errors omit keys, binds, labels, hints, Vault IDs, provider content, headers, exception text, tokens, and PII -- [ ] T033 [US2] Extend `frontend/tests/e2e/provider-keys.spec.ts` with mocked valid, explicit-invalid, temporary, timeout, and already-in-progress UI outcomes, including invalid active-key deactivation and preservation of prior status on temporary outcomes +- [X] T027 [P] [US2] Create `backend/app/services/provider_validation.py` with injectable async `httpx` transport, official OpenAI and Gemini model-list probes, no redirects/retries, structured provider-specific invalid predicates, fixed valid/invalid/temporary code/message mapping, response-body disposal, provider request-ID extraction, and provider I/O bounded by ten seconds and the remaining route deadline +- [X] T028 [US2] Extend `backend/app/services/provider_key_store.py` with short claim/complete validation transactions: brand-first/key-second locking, lifecycle and brand fences, database-clock lease/token assignment, request-local Vault decryption, `VALIDATION_IN_PROGRESS`, matching-token completion, accepted/invalid persistence, atomic invalid deactivation, stale `VALIDATION_SUPERSEDED`, temporary no-op semantics, and best-effort lease clearing without holding a connection during HTTP +- [X] T029 [US2] Add the validate handler to `backend/app/routes/provider_keys.py`, budget auth/pool/lock/Vault/provider/completion/serialization against the entry deadline, return all completed provider classifications as HTTP 200 outcomes, preserve fixed pre-lease error envelopes, and emit only safe provider/code/duration/request-ID logs +- [X] T030 [US2] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with explicit validate actions, pending-state deduplication, valid/invalid/unvalidated status and timestamp rendering, fixed temporary outcome feedback, active-key deactivation refresh, and `finally` cleanup for every network path +- [X] T031 [US2] Extend `backend/tests/integration/test_provider_key_rls.py` with owner/non-owner/nonexistent validate parity and assertions that direct clients cannot read or mutate validation tokens, lease expiries, Vault IDs, decrypted values, validity fields, or active state +- [X] T032 [US2] Add deadline and secrecy regression cases to `backend/tests/contract/test_provider_keys.py` that consume budget during authentication, pool checkout, lock/Vault access, provider I/O, and completion; assert every leased request settles within 15 seconds and captured logs/errors omit keys, binds, labels, hints, Vault IDs, provider content, headers, exception text, tokens, and PII +- [X] T033 [US2] Extend `frontend/tests/e2e/provider-keys.spec.ts` with mocked valid, explicit-invalid, temporary, timeout, and already-in-progress UI outcomes, including invalid active-key deactivation and preservation of prior status on temporary outcomes - [ ] T034 [US2] Execute Quickstart Scenario 2's deterministic steps from `specs/004-provider-keys/quickstart.md` and run `backend/tests/unit/test_provider_validation.py`, the validation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, and `frontend/tests/e2e/provider-keys.spec.ts` **Checkpoint**: Both providers validate safely without generation, false invalidation, or From a13987e917fda915001de2e081685ce9bdacafef Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Tue, 28 Jul 2026 20:46:19 +0300 Subject: [PATCH 11/14] Complete provider key activation --- backend/app/routes/provider_keys.py | 69 ++++++ backend/app/services/provider_key_store.py | 80 ++++++- backend/tests/contract/test_provider_keys.py | 151 ++++++++++++ .../integration/test_provider_key_cleanup.py | 216 ++++++++++++++++++ .../brands/[brandId]/keys/page.tsx | 144 +++++++++++- frontend/tests/e2e/provider-keys.spec.ts | 50 ++++ specs/004-provider-keys/tasks.md | 14 +- 7 files changed, 707 insertions(+), 17 deletions(-) create mode 100644 backend/tests/integration/test_provider_key_cleanup.py diff --git a/backend/app/routes/provider_keys.py b/backend/app/routes/provider_keys.py index 683df86..7633048 100644 --- a/backend/app/routes/provider_keys.py +++ b/backend/app/routes/provider_keys.py @@ -24,7 +24,9 @@ from ..services.brand_store import BrandCleanupRequiredError from ..services.provider_key_store import ( IdempotencyKeyRetiredError, + KeyActivationConflictError, KeyCleanupRequiredError, + KeyInvalidError, ProviderKeyStore, VaultUnavailableError, get_provider_key_store, @@ -78,6 +80,38 @@ def _validation_error(exc: Exception) -> HTTPException: ) +def _activation_error(exc: Exception) -> HTTPException: + if isinstance(exc, LookupError): + return _error( + status.HTTP_404_NOT_FOUND, + "PROVIDER_KEY_NOT_FOUND", + "Provider key not found.", + ) + if isinstance(exc, BrandCleanupRequiredError): + return _error( + status.HTTP_409_CONFLICT, + "BRAND_CLEANUP_REQUIRED", + "Brand cleanup is required. Retry deletion.", + ) + if isinstance(exc, KeyCleanupRequiredError): + return _error( + status.HTTP_409_CONFLICT, + "KEY_CLEANUP_REQUIRED", + "Key cleanup is required. Retry deletion.", + ) + if isinstance(exc, KeyInvalidError): + return _error( + status.HTTP_409_CONFLICT, + "KEY_INVALID", + "Validate this key successfully before activating it.", + ) + return _error( + status.HTTP_409_CONFLICT, + "BRAND_MUTATION_IN_PROGRESS", + "A brand update is in progress. Retry shortly.", + ) + + async def _within_deadline(awaitable: Awaitable[_T], deadline: float) -> _T: remaining = max(0.0, deadline - time.monotonic()) return await asyncio.wait_for(awaitable, timeout=remaining) @@ -200,6 +234,41 @@ def add_provider_key( return key +@router.patch( + "/{brand_id}/keys/{key_id}/activate", + response_model=ProviderKey, +) +def activate_provider_key( + request: Request, + brand_id: UUID, + key_id: UUID, + current_user: CurrentUserDep, + provider_key_store: ProviderKeyStoreDep, +) -> ProviderKey: + try: + key = provider_key_store.activate_key( + current_user.user_id, brand_id, key_id + ) + except ( + LookupError, + BrandCleanupRequiredError, + KeyCleanupRequiredError, + KeyInvalidError, + KeyActivationConflictError, + ) as exc: + raise _activation_error(exc) from exc + + logger.info( + "provider_keys.activate_success", + extra={ + "event": "provider_keys.activate_success", + "request_id": getattr(request.state, "request_id", "unknown"), + "provider": key.provider.value, + }, + ) + return key + + @router.post( "/{brand_id}/keys/{key_id}/validate", response_model=ProviderKeyValidationResponse, diff --git a/backend/app/services/provider_key_store.py b/backend/app/services/provider_key_store.py index a2e87d4..c875934 100644 --- a/backend/app/services/provider_key_store.py +++ b/backend/app/services/provider_key_store.py @@ -10,7 +10,7 @@ from sqlalchemy import text from sqlalchemy.engine import Engine -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.exc import IntegrityError, SQLAlchemyError from ..config import get_engine from ..models.provider_key import ProviderKey, ProviderKeyAdd @@ -33,6 +33,14 @@ class KeyCleanupRequiredError(ProviderKeyStoreError): pass +class KeyInvalidError(ProviderKeyStoreError): + pass + + +class KeyActivationConflictError(ProviderKeyStoreError): + pass + + class ValidationResult(Protocol): outcome: str @@ -198,6 +206,76 @@ def add_key( except SQLAlchemyError as exc: raise VaultUnavailableError from exc + def activate_key( + self, + user_id: str, + brand_id: UUID, + key_id: UUID, + ) -> ProviderKey: + try: + with self.engine.begin() as connection: + brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) + row = connection.execute( + text( + f""" + SELECT {_SAFE_COLUMNS} + FROM provider_keys + WHERE brand_id = :brand_id AND id = :key_id + FOR UPDATE + """ + ), + {"brand_id": brand_id, "key_id": key_id}, + ).mappings().one_or_none() + if row is None: + raise LookupError("Provider key not found.") + + # Path membership is resolved before lifecycle conflicts are exposed. + BrandStore.require_normal_brand(brand) + if row["lifecycle"] != "normal": + raise KeyCleanupRequiredError + if row["is_valid"] is False: + raise KeyInvalidError + + connection.execute( + text( + "UPDATE provider_keys SET is_active = false " + "WHERE brand_id = :brand_id " + "AND provider = CAST(:provider AS provider_t) " + "AND is_active" + ), + {"brand_id": brand_id, "provider": row["provider"]}, + ) + activated = connection.execute( + text( + f""" + UPDATE provider_keys + SET is_active = true + WHERE id = :key_id + RETURNING {_SAFE_COLUMNS} + """ + ), + {"key_id": key_id}, + ).mappings().one() + return self._to_provider_key(activated) + except ( + LookupError, + BrandCleanupRequiredError, + KeyCleanupRequiredError, + KeyInvalidError, + ): + raise + except IntegrityError as exc: + original = exc.orig + constraint_name = getattr( + getattr(original, "diag", None), "constraint_name", None + ) + if ( + getattr(original, "pgcode", None) == "23505" + and constraint_name == "uq_provider_keys_one_active" + ): + raise KeyActivationConflictError from exc + raise + def claim_validation( self, user_id: str, diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py index 40e06c9..efad9c3 100644 --- a/backend/tests/contract/test_provider_keys.py +++ b/backend/tests/contract/test_provider_keys.py @@ -546,6 +546,7 @@ class FakeProviderKeyStore: keys: list[ProviderKey] = field(default_factory=list) error: Exception | None = None add_calls: list[tuple[str, UUID, ProviderKeyAdd, UUID]] = field(default_factory=list) + activate_calls: list[tuple[str, UUID, UUID]] = field(default_factory=list) validation: FakeValidationScenario | None = None claim_calls: list[tuple[str, UUID, UUID, float]] = field(default_factory=list) complete_calls: list[tuple[str, UUID, UUID, UUID, Any, float]] = field( @@ -569,6 +570,29 @@ def add_key( raise self.error return self.keys[0] if self.keys else _safe_key(is_active=payload.make_active) + def activate_key( + self, + user_id: str, + brand_id: UUID, + key_id: UUID, + ) -> ProviderKey: + self.activate_calls.append((user_id, brand_id, key_id)) + if self.error: + raise self.error + target = next((key for key in self.keys if key.id == key_id), None) + if target is None: + target = _safe_key(id=key_id, is_active=False) + activated = target.model_copy(update={"is_active": True}) + self.keys = [ + activated + if key.id == key_id + else key.model_copy(update={"is_active": False}) + if key.provider == target.provider + else key + for key in self.keys + ] + return activated + def claim_validation( self, user_id: str, @@ -814,6 +838,133 @@ def test_add_uses_no_provider_client_dependency(provider_key_client): assert response.status_code == 201 +def test_activate_returns_exact_safe_shape_and_accepts_unvalidated_key( + provider_key_client, +): + client, store = provider_key_client + store.keys = [_safe_key(is_active=False, is_valid=None)] + + response = client.patch( + f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/activate" + ) + + assert response.status_code == 200 + assert response.json() == _safe_key_json(_safe_key(is_active=True, is_valid=None)) + assert store.activate_calls == [ + ("11111111-1111-1111-1111-111111111111", BRAND_ID, KEY_ID) + ] + assert store.validator.calls == [] + assert { + "brand_id", + "vault_secret_id", + "lifecycle", + "validation_token", + "updated_at", + }.isdisjoint(response.json()) + + +def test_activate_deactivates_only_prior_key_for_same_provider(provider_key_client): + client, store = provider_key_client + prior_id = UUID("77777777-7777-7777-7777-777777777777") + gemini_id = UUID("88888888-8888-8888-8888-888888888888") + store.keys = [ + _safe_key(id=KEY_ID, is_active=False), + _safe_key(id=prior_id, is_active=True), + _safe_key(id=gemini_id, provider="gemini", is_active=True), + ] + + response = client.patch( + f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/activate" + ) + + assert response.status_code == 200 + states = {key.id: key.is_active for key in store.keys} + assert states == {KEY_ID: True, prior_id: False, gemini_id: True} + + +@pytest.mark.parametrize( + ("error_name", "fallback", "code", "message"), + [ + ( + "KeyInvalidError", + RuntimeError, + "KEY_INVALID", + "Validate this key successfully before activating it.", + ), + ( + "KeyCleanupRequiredError", + RuntimeError, + "KEY_CLEANUP_REQUIRED", + "Key cleanup is required. Retry deletion.", + ), + ( + "BrandCleanupRequiredError", + BrandCleanupRequiredError, + "BRAND_CLEANUP_REQUIRED", + "Brand cleanup is required. Retry deletion.", + ), + ( + "KeyActivationConflictError", + RuntimeError, + "BRAND_MUTATION_IN_PROGRESS", + "A brand update is in progress. Retry shortly.", + ), + ], +) +def test_activate_conflicts_use_fixed_safe_envelopes( + provider_key_client, + error_name: str, + fallback: type[Exception], + code: str, + message: str, +): + client, store = provider_key_client + error_type = ( + BrandCleanupRequiredError + if error_name == "BrandCleanupRequiredError" + else getattr(provider_key_store_module, error_name, fallback) + ) + store.error = error_type() + + response = client.patch( + f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/activate" + ) + + assert response.status_code == 409 + assert response.json()["error"]["code"] == code + assert response.json()["error"]["message"] == message + assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"] + assert store.validator.calls == [] + + +@pytest.mark.parametrize("hidden_case", ["missing", "wrong_brand", "not_owned"]) +def test_activate_path_membership_is_opaque(provider_key_client, hidden_case: str): + client, store = provider_key_client + store.error = LookupError(hidden_case) + + response = client.patch( + f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/activate" + ) + + assert response.status_code == 404 + assert response.json()["error"]["code"] == "PROVIDER_KEY_NOT_FOUND" + assert response.json()["error"]["message"] == "Provider key not found." + assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"] + assert store.validator.calls == [] + + +def test_activate_rejects_malformed_path_before_store(provider_key_client): + client, store = provider_key_client + + response = client.patch( + f"/api/v1/brands/{BRAND_ID}/keys/not-a-uuid/activate" + ) + + assert response.status_code == 400 + assert response.json()["error"]["code"] == "VALIDATION_ERROR" + assert store.activate_calls == [] + + def _safe_key_json(key: ProviderKey) -> dict[str, Any]: return { "id": str(key.id), diff --git a/backend/tests/integration/test_provider_key_cleanup.py b/backend/tests/integration/test_provider_key_cleanup.py new file mode 100644 index 0000000..645ea87 --- /dev/null +++ b/backend/tests/integration/test_provider_key_cleanup.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from backend.tests.integration.test_provider_keys import ( + MockProviderValidator, + ProviderFixture, + SecretFixture, + _add_real_key, + _provider_validator_dependency, + provider_fixture, +) + + +def _add_provider_key( + client: TestClient, + fixture: ProviderFixture, + provider: str, + suffix: str, +) -> UUID: + response = client.post( + f"/api/v1/brands/{fixture['brand_id']}/keys", + headers={**fixture["headers"], "Idempotency-Key": str(uuid4())}, + json={ + "provider": provider, + "key": f"activation-{uuid4().hex}-{suffix}", + "make_active": False, + }, + ) + assert response.status_code == 201 + return UUID(response.json()["id"]) + + +def _active_states(fixture: ProviderFixture) -> dict[UUID, bool]: + with fixture["engine"].connect() as connection: + rows = connection.execute( + text( + "SELECT id, is_active FROM provider_keys " + "WHERE brand_id = :brand_id" + ), + {"brand_id": fixture["brand_id"]}, + ).all() + return {row.id: row.is_active for row in rows} + + +def test_simultaneous_same_provider_activation_keeps_exactly_one_active( + provider_fixture, +): + from backend.app.main import app + + fixture = provider_fixture + with TestClient(app) as setup_client: + first_id = _add_provider_key(setup_client, fixture, "openai", "A1B2") + second_id = _add_provider_key(setup_client, fixture, "openai", "C3_D") + + with TestClient(app) as first_client, TestClient(app) as second_client: + with ThreadPoolExecutor(max_workers=2) as executor: + responses = [ + executor.submit( + client.patch, + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/activate", + headers=fixture["headers"], + ) + for client, key_id in ( + (first_client, first_id), + (second_client, second_id), + ) + ] + completed = [future.result(timeout=10) for future in responses] + + assert [response.status_code for response in completed] == [200, 200] + states = _active_states(fixture) + assert sum(states[key_id] for key_id in (first_id, second_id)) == 1 + observable = "".join(response.text for response in completed) + for unsafe in ("uq_provider_keys_one_active", "provider_keys_invalid_inactive", "UPDATE provider_keys"): + assert unsafe not in observable + + +def test_activation_is_independent_per_provider(provider_fixture): + from backend.app.main import app + + fixture = provider_fixture + with TestClient(app) as client: + openai_id = _add_provider_key(client, fixture, "openai", "E5-F") + gemini_id = _add_provider_key(client, fixture, "gemini", "G7_H") + openai = client.patch( + f"/api/v1/brands/{fixture['brand_id']}/keys/{openai_id}/activate", + headers=fixture["headers"], + ) + gemini = client.patch( + f"/api/v1/brands/{fixture['brand_id']}/keys/{gemini_id}/activate", + headers=fixture["headers"], + ) + + assert openai.status_code == gemini.status_code == 200 + states = _active_states(fixture) + assert states[openai_id] is True + assert states[gemini_id] is True + + +def test_invalid_validation_completion_wins_after_activation(provider_fixture): + from backend.app.main import app + + fixture = provider_fixture + raw_key = SecretFixture(f"activation-validation-{uuid4().hex}-J8_K") + validator = MockProviderValidator( + ("invalid", "INVALID_CREDENTIAL"), + expected_secret=raw_key, + block_first=True, + ) + dependency = _provider_validator_dependency() + app.dependency_overrides[dependency] = lambda: validator + try: + with TestClient(app) as validation_client, TestClient(app) as activation_client: + key_id = _add_real_key( + validation_client, fixture, raw_key, make_active=False + ) + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit( + validation_client.post, + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate", + headers=fixture["headers"], + ) + assert validator.entered.wait(5) + activated = activation_client.patch( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/activate", + headers=fixture["headers"], + ) + validator.release.set() + invalidated = pending.result(timeout=10) + finally: + app.dependency_overrides.pop(dependency, None) + + assert activated.status_code == 200 + assert invalidated.status_code == 200 + assert invalidated.json()["outcome"] == "invalid" + with fixture["engine"].connect() as connection: + state = connection.execute( + text("SELECT is_valid, is_active FROM provider_keys WHERE id = :key_id"), + {"key_id": key_id}, + ).one() + assert state == (False, False) + + +def test_activation_is_blocked_after_invalid_validation(provider_fixture): + from backend.app.main import app + + fixture = provider_fixture + raw_key = SecretFixture(f"invalid-before-activation-{uuid4().hex}-L9_M") + validator = MockProviderValidator( + ("invalid", "INVALID_CREDENTIAL"), expected_secret=raw_key + ) + dependency = _provider_validator_dependency() + app.dependency_overrides[dependency] = lambda: validator + try: + with TestClient(app) as client: + key_id = _add_real_key(client, fixture, raw_key, make_active=False) + invalidated = client.post( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate", + headers=fixture["headers"], + ) + activated = client.patch( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/activate", + headers=fixture["headers"], + ) + finally: + app.dependency_overrides.pop(dependency, None) + + assert invalidated.status_code == 200 + assert activated.status_code == 409 + assert activated.json()["error"]["code"] == "KEY_INVALID" + assert activated.json()["error"]["message"] == ( + "Validate this key successfully before activating it." + ) + assert activated.headers["X-Request-Id"] == activated.json()["error"]["request_id"] + + +def test_database_rejects_multiple_active_invalid_and_cleanup_active_rows( + provider_fixture, +): + from backend.app.main import app + + fixture = provider_fixture + with TestClient(app) as client: + first_id = _add_provider_key(client, fixture, "openai", "N0_P") + second_id = _add_provider_key(client, fixture, "openai", "Q1_R") + + engine = fixture["engine"] + with engine.begin() as connection: + connection.execute( + text("UPDATE provider_keys SET is_active = true WHERE id = :key_id"), + {"key_id": first_id}, + ) + with pytest.raises(DBAPIError): + with engine.begin() as connection: + connection.execute( + text("UPDATE provider_keys SET is_active = true WHERE id = :key_id"), + {"key_id": second_id}, + ) + for assignment in ( + "is_valid = false, last_validated_at = clock_timestamp(), " + "last_validation_error = 'INVALID_CREDENTIAL', is_active = true", + "lifecycle = 'cleanup_required', is_active = true", + ): + with pytest.raises(DBAPIError): + with engine.begin() as connection: + connection.execute( + text(f"UPDATE provider_keys SET {assignment} WHERE id = :key_id"), + {"key_id": second_id}, + ) diff --git a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx index 7d4eb1f..942831a 100644 --- a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx +++ b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx @@ -114,6 +114,21 @@ function validationErrorMessage(code?: string) { } } +function activationErrorMessage(code?: string) { + switch (code) { + case "KEY_INVALID": + return "Validate this key successfully before activating it."; + case "KEY_CLEANUP_REQUIRED": + return "Key cleanup is required before this key can be activated."; + case "BRAND_CLEANUP_REQUIRED": + return "Brand cleanup is required before a key can be activated."; + case "BRAND_MUTATION_IN_PROGRESS": + return "Another brand update is in progress. Retry shortly."; + default: + return "Unable to activate this key."; + } +} + export default function ProviderKeysPage() { const { brandId } = useParams<{ brandId: string }>(); const router = useRouter(); @@ -121,6 +136,7 @@ export default function ProviderKeysPage() { const keyInputRef = useRef(null); const retryIdRef = useRef(null); const validatingKeyIdsRef = useRef(new Set()); + const activatingKeyIdsRef = useRef(new Set()); const [brand, setBrand] = useState(null); const [keys, setKeys] = useState([]); const [provider, setProvider] = useState("openai"); @@ -134,9 +150,15 @@ export default function ProviderKeysPage() { const [validatingKeyIds, setValidatingKeyIds] = useState>( () => new Set() ); + const [activatingKeyIds, setActivatingKeyIds] = useState>( + () => new Set() + ); const [validationFeedbackByKey, setValidationFeedbackByKey] = useState< Record >({}); + const [activationFeedbackByKey, setActivationFeedbackByKey] = useState< + Record + >({}); useEffect(() => { let active = true; @@ -271,6 +293,11 @@ export default function ProviderKeysPage() { validatingKeyIdsRef.current.add(keyId); setValidatingKeyIds((current) => new Set(current).add(keyId)); + setActivationFeedbackByKey((current) => { + const next = { ...current }; + delete next[keyId]; + return next; + }); setValidationFeedbackByKey((current) => { const next = { ...current }; delete next[keyId]; @@ -335,6 +362,86 @@ export default function ProviderKeysPage() { } } + async function activateKey(keyId: string) { + if (activatingKeyIdsRef.current.has(keyId)) return; + + activatingKeyIdsRef.current.add(keyId); + setActivatingKeyIds((current) => new Set(current).add(keyId)); + setValidationFeedbackByKey((current) => { + const next = { ...current }; + delete next[keyId]; + return next; + }); + setActivationFeedbackByKey((current) => { + const next = { ...current }; + delete next[keyId]; + return next; + }); + + try { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session) { + router.push("/login"); + return; + } + + const response = await fetch( + `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/keys/${encodeURIComponent(keyId)}/activate`, + { + method: "PATCH", + headers: { Authorization: `Bearer ${session.access_token}` }, + } + ); + const body = (await response.json().catch(() => null)) as + | ProviderKey + | ErrorResponse + | null; + if (!response.ok) { + setActivationFeedbackByKey((current) => ({ + ...current, + [keyId]: { + message: activationErrorMessage( + (body as ErrorResponse | null)?.error?.code + ), + tone: "error", + }, + })); + return; + } + + const activatedKey = body as ProviderKey; + setKeys((current) => + current.map((key) => + key.id === activatedKey.id + ? activatedKey + : key.provider === activatedKey.provider + ? { ...key, is_active: false } + : key + ) + ); + setActivationFeedbackByKey((current) => ({ + ...current, + [keyId]: { message: "Key activated.", tone: "success" }, + })); + } catch { + setActivationFeedbackByKey((current) => ({ + ...current, + [keyId]: { + message: "Unable to activate this key. Refresh its status before trying again.", + tone: "error", + }, + })); + } finally { + activatingKeyIdsRef.current.delete(keyId); + setActivatingKeyIds((current) => { + const next = new Set(current); + next.delete(keyId); + return next; + }); + } + } + if (isLoading) { return

Loading provider keys...

; } @@ -416,11 +523,18 @@ export default function ProviderKeysPage() { ) : ( providerKeys.map((key) => { const isValidating = validatingKeyIds.has(key.id); - const feedback = validationFeedbackByKey[key.id]; + const isActivating = activatingKeyIds.has(key.id); + const feedback = + activationFeedbackByKey[key.id] ?? validationFeedbackByKey[key.id]; const validationDisabled = isValidating || cleanupRequired || key.cleanup_state === "cleanup_required"; + const activationDisabled = + isActivating || + cleanupRequired || + key.cleanup_state === "cleanup_required" || + key.is_valid === false; return (
@@ -452,14 +566,26 @@ export default function ProviderKeysPage() { : "Not yet validated"}

- +
+ {!key.is_active ? ( + + ) : null} + +
{feedback ? (

let requestWasOpaque = true; let responseWasOpaque = true; let validationRequests = 0; + let activationRequests = 0; let brandCleanupState: "normal" | "cleanup_required" = "normal"; let validationMode: | "valid" @@ -116,6 +117,37 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => return; } + const activationMatch = url.pathname.match( + new RegExp(`^/api/v1/brands/${brandId}/keys/([^/]+)/activate$`) + ); + if (activationMatch && request.method() === "PATCH") { + activationRequests += 1; + const key = savedKeys.find((item) => item.id === activationMatch[1]); + if (!key) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: { code: "PROVIDER_KEY_NOT_FOUND" } }), + }); + return; + } + if (key.is_valid === false) { + await route.fulfill({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ error: { code: "KEY_INVALID" } }), + }); + return; + } + savedKeys.forEach((item) => { + if (item.provider === key.provider) item.is_active = item.id === key.id; + }); + const body = JSON.stringify(key); + responseWasOpaque &&= !Object.values(rawKeys).some((rawKey) => body.includes(rawKey)); + await route.fulfill({ status: 200, contentType: "application/json", body }); + return; + } + const validationMatch = url.pathname.match( new RegExp(`^/api/v1/brands/${brandId}/keys/([^/]+)/validate$`) ); @@ -214,6 +246,9 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible(); await expect(openAIKey.getByText(/^Last validated /)).toBeVisible(); await expect(openAIKey.getByRole("status")).toHaveText("The provider accepted this key."); + await openAIKey.getByRole("button", { name: "Activate key" }).click(); + await expect(openAIKey.getByText("Active", { exact: true })).toBeVisible(); + await expect(openAIKey.getByRole("status")).toHaveText("Key activated."); const validatedText = await openAIKey.getByText(/^Last validated /).textContent(); validationMode = "unavailable"; @@ -257,10 +292,12 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => await expect(geminiKey.getByText("Invalid", { exact: true })).toBeVisible(); await expect(geminiKey.getByText("Inactive", { exact: true })).toBeVisible(); await expect(geminiKey.getByRole("status")).toHaveText("The provider rejected this key."); + await expect(geminiKey.getByRole("button", { name: "Activate key" })).toBeDisabled(); await page.reload(); await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible(); await expect(page.getByText("***A1B2")).toBeVisible(); + await expect(page.getByText("Active", { exact: true })).toBeVisible(); await page.getByRole("tab", { name: "Gemini" }).click(); await expect(page.getByText("***G3D4")).toBeVisible(); const visibleText = (await page.locator("body").textContent()) ?? ""; @@ -275,6 +312,12 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => storedOpenAIKey.last_validation_error = null; await page.reload(); await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible(); + await expect( + page + .getByRole("article") + .filter({ hasText: "Inactive OpenAI" }) + .getByRole("button", { name: "Activate key" }) + ).toBeDisabled(); await expect( page .getByRole("article") @@ -286,6 +329,12 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => brandCleanupState = "cleanup_required"; await page.reload(); await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible(); + await expect( + page + .getByRole("article") + .filter({ hasText: "Inactive OpenAI" }) + .getByRole("button", { name: "Activate key" }) + ).toBeDisabled(); await expect( page .getByRole("article") @@ -296,6 +345,7 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => expect(submittedKeyBodies === 2).toBeTruthy(); expect(validationRequests).toBe(6); + expect(activationRequests).toBe(1); expect(requestWasOpaque).toBeTruthy(); expect(responseWasOpaque).toBeTruthy(); expect(rawKeyIsVisible).toBeFalsy(); diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md index a7a59ac..ef7447d 100644 --- a/specs/004-provider-keys/tasks.md +++ b/specs/004-provider-keys/tasks.md @@ -121,7 +121,7 @@ for both providers and verify only accepted/explicit-invalid outcomes persist ch - [X] T031 [US2] Extend `backend/tests/integration/test_provider_key_rls.py` with owner/non-owner/nonexistent validate parity and assertions that direct clients cannot read or mutate validation tokens, lease expiries, Vault IDs, decrypted values, validity fields, or active state - [X] T032 [US2] Add deadline and secrecy regression cases to `backend/tests/contract/test_provider_keys.py` that consume budget during authentication, pool checkout, lock/Vault access, provider I/O, and completion; assert every leased request settles within 15 seconds and captured logs/errors omit keys, binds, labels, hints, Vault IDs, provider content, headers, exception text, tokens, and PII - [X] T033 [US2] Extend `frontend/tests/e2e/provider-keys.spec.ts` with mocked valid, explicit-invalid, temporary, timeout, and already-in-progress UI outcomes, including invalid active-key deactivation and preservation of prior status on temporary outcomes -- [ ] T034 [US2] Execute Quickstart Scenario 2's deterministic steps from `specs/004-provider-keys/quickstart.md` and run `backend/tests/unit/test_provider_validation.py`, the validation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, and `frontend/tests/e2e/provider-keys.spec.ts` +- [X] T034 [US2] Execute Quickstart Scenario 2's deterministic steps from `specs/004-provider-keys/quickstart.md` and run `backend/tests/unit/test_provider_validation.py`, the validation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, and `frontend/tests/e2e/provider-keys.spec.ts` **Checkpoint**: Both providers validate safely without generation, false invalidation, or late-result corruption. @@ -139,15 +139,15 @@ terminal state must satisfy the database active-key and invalid-inactive constra ### Tests for User Story 3 -- [ ] T035 [P] [US3] Extend `backend/tests/contract/test_provider_keys.py` with failing tests for `PATCH /api/v1/brands/{brand_id}/keys/{key_id}/activate`: successful safe shape, unvalidated eligibility, atomic prior deactivation, other-provider preservation, known-invalid block, key/brand cleanup conflicts, opaque path ownership, and no provider call -- [ ] T036 [P] [US3] Extend `backend/tests/integration/test_provider_key_cleanup.py` with failing real-Supabase concurrency tests for simultaneous activation of two same-provider keys, provider-independent activation, activation versus invalid validation in both lock orderings, database unique/check invariants, and safe conflict responses without leaked SQL details +- [X] T035 [P] [US3] Extend `backend/tests/contract/test_provider_keys.py` with failing tests for `PATCH /api/v1/brands/{brand_id}/keys/{key_id}/activate`: successful safe shape, unvalidated eligibility, atomic prior deactivation, other-provider preservation, known-invalid block, key/brand cleanup conflicts, opaque path ownership, and no provider call +- [X] T036 [P] [US3] Extend `backend/tests/integration/test_provider_key_cleanup.py` with failing real-Supabase concurrency tests for simultaneous activation of two same-provider keys, provider-independent activation, activation versus invalid validation in both lock orderings, database unique/check invariants, and safe conflict responses without leaked SQL details ### Implementation for User Story 3 -- [ ] T037 [US3] Extend `backend/app/services/provider_key_store.py` with atomic activation that locks brand then target key, rejects fenced/known-invalid targets, deactivates only the current active row for the same provider, activates the target in one transaction, and converts uniqueness races to a fixed safe retry outcome while ensuring no completed ordering leaves an invalid key active -- [ ] T038 [US3] Add the activate handler and fixed `KEY_INVALID`, `KEY_CLEANUP_REQUIRED`, `BRAND_CLEANUP_REQUIRED`, and opaque-not-found mappings to `backend/app/routes/provider_keys.py` -- [ ] T039 [US3] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with activate controls for eligible inactive keys, disabled controls for invalid/cleanup states, independent provider refresh, fixed conflict feedback, and defensive loading-state cleanup -- [ ] T040 [US3] Execute Quickstart Scenario 4 steps 1-2 from `specs/004-provider-keys/quickstart.md` and run the activation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_key_cleanup.py`, and `frontend/tests/e2e/provider-keys.spec.ts` +- [X] T037 [US3] Extend `backend/app/services/provider_key_store.py` with atomic activation that locks brand then target key, rejects fenced/known-invalid targets, deactivates only the current active row for the same provider, activates the target in one transaction, and converts uniqueness races to a fixed safe retry outcome while ensuring no completed ordering leaves an invalid key active +- [X] T038 [US3] Add the activate handler and fixed `KEY_INVALID`, `KEY_CLEANUP_REQUIRED`, `BRAND_CLEANUP_REQUIRED`, and opaque-not-found mappings to `backend/app/routes/provider_keys.py` +- [X] T039 [US3] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with activate controls for eligible inactive keys, disabled controls for invalid/cleanup states, independent provider refresh, fixed conflict feedback, and defensive loading-state cleanup +- [X] T040 [US3] Execute Quickstart Scenario 4 steps 1-2 from `specs/004-provider-keys/quickstart.md` and run the activation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_key_cleanup.py`, and `frontend/tests/e2e/provider-keys.spec.ts` **Checkpoint**: Rotation is atomic, provider-independent, and safe under concurrency. From 6f5561ddc26bbe32f8e78b0cbb6991aaa490ff7f Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Tue, 28 Jul 2026 20:52:51 +0300 Subject: [PATCH 12/14] Add repository contributor guide --- AGENTS.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..508742a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# Repository Guidelines + +## Project Structure + +- `frontend/` contains the Next.js 15 App Router application and Playwright tests. +- `backend/` contains the FastAPI service, SQLAlchemy stores/models, and pytest suites. +- `supabase/migrations/` contains ordered PostgreSQL schema, RLS, Vault, and Storage migrations. +- `specs/` contains feature specifications, plans, contracts, quickstarts, and task checklists. +- `docs/` and `scripts/` contain deployment/runbook documentation and container helpers. + +## Build, Test, and Development Commands + +Run `make help` for the complete command list. Common workflows are: + +```bash +make install # Install frontend and backend dependencies +make supabase-start # Start the local Supabase/Docker stack +make dev # Run backend and frontend development servers +make test-backend # Run backend pytest suites from the repository root +cd frontend && npm run lint # Run ESLint +cd frontend && npx tsc --noEmit # Type-check TypeScript +cd frontend && npx playwright test # Run browser tests +make build # Build the production container image +``` + +Real-Supabase integration tests require exported values from `backend/.env`, including `DATABASE_URL`, and a running local stack. + +## Coding Style and Naming + +Use four-space indentation for Python and two spaces for TypeScript/TSX. Follow existing FastAPI dependency, Pydantic model, and service-store patterns. Use `snake_case` for Python names and API fields, `PascalCase` for React components/types, and descriptive kebab-case feature directories such as `specs/004-provider-keys/`. Keep secrets, raw provider keys, Vault IDs, and SQL details out of responses and logs. Run ESLint and TypeScript checks before submitting frontend changes. + +## Testing Guidelines + +Name Python tests `test_*.py` and test functions `test_`. Keep contract tests in `backend/tests/contract/`, unit tests in `backend/tests/unit/`, and real Supabase coverage in `backend/tests/integration/`. Add focused Playwright coverage under `frontend/tests/e2e/`. Run the narrowest relevant tests first, then the full backend suite and frontend checks. + +## Commits and Pull Requests + +Use concise imperative commit subjects, typically describing the completed feature or fix (for example, `Complete provider key activation`). Pull requests should explain the behavior changed, list verification commands and results, identify migrations or security implications, and include screenshots for meaningful UI changes. Keep spec/task status synchronized with verified work. + +## Security and Configuration + +Never commit `.env` files, service-role keys, provider credentials, or decrypted secrets. Apply Supabase migrations locally before integration testing, and preserve the repository’s RLS, Vault isolation, ownership, hard-delete, and opaque-error boundaries. From dad9afb3736eea1352457cb758894808daed73cd Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Tue, 28 Jul 2026 21:33:10 +0300 Subject: [PATCH 13/14] Set explicit Next.js tracing root --- frontend/next.config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/next.config.js b/frontend/next.config.js index 5a85d65..11402c0 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,6 +1,11 @@ /** @type {import('next').NextConfig} */ +// Next config is loaded as CommonJS by the project setup. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const path = require("path"); + const nextConfig = { reactStrictMode: true, + outputFileTracingRoot: path.join(__dirname, ".."), async rewrites() { const backendUrl = process.env.NEXT_SERVER_API_URL ?? "http://127.0.0.1:8000"; From cbe80d60c4fcb2b63782302609bce544e535193f Mon Sep 17 00:00:00 2001 From: Mohammed Zayan Date: Wed, 29 Jul 2026 00:09:42 +0300 Subject: [PATCH 14/14] Complete provider key hardening and verification --- backend/.env.example | 7 +- backend/app/auth.py | 5 +- backend/app/config.py | 5 +- backend/app/models/provider_key.py | 10 +- backend/app/routes/auth.py | 6 +- backend/app/routes/brands.py | 232 ++++++---- backend/app/routes/provider_keys.py | 56 +++ backend/app/services/brand_deletion.py | 118 +++++ backend/app/services/brand_storage.py | 139 +++++- backend/app/services/brand_store.py | 313 ++++++++++--- backend/app/services/provider_key_store.py | 95 ++++ backend/app/services/provider_validation.py | 8 +- backend/tests/contract/test_brands.py | 258 +++++++++-- backend/tests/contract/test_provider_keys.py | 362 ++++++++++++++- backend/tests/integration/test_brand_crud.py | 35 +- backend/tests/integration/test_brand_rls.py | 7 +- .../integration/test_provider_key_cleanup.py | 418 +++++++++++++++++- .../integration/test_provider_key_rls.py | 12 +- .../tests/integration/test_provider_keys.py | 11 +- backend/tests/unit/test_brand_storage.py | 113 +++++ .../tests/unit/test_provider_validation.py | 37 +- docs/docker.md | 60 ++- .../brands/[brandId]/keys/page.tsx | 146 +++++- .../app/(dashboard)/brands/[brandId]/page.tsx | 52 ++- frontend/app/(dashboard)/brands/page.tsx | 14 +- frontend/tests/e2e/provider-keys.spec.ts | 213 +++++++++ specs/004-provider-keys/tasks.md | 44 +- specs/004-provider-keys/verification.md | 41 ++ 28 files changed, 2509 insertions(+), 308 deletions(-) create mode 100644 backend/app/services/brand_deletion.py create mode 100644 backend/tests/unit/test_brand_storage.py create mode 100644 specs/004-provider-keys/verification.md diff --git a/backend/.env.example b/backend/.env.example index 9af3bc6..77f1984 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,9 @@ SUPABASE_URL=http://localhost:54321 SUPABASE_SECRET_KEY=replace-me SUPABASE_JWT_SECRET=replace-me -DATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres +# Private backend-only PostgreSQL login; never expose it to browser/client roles. +# It must bypass forced RLS (BYPASSRLS, or the local postgres superuser) and have +# SELECT/INSERT/UPDATE/DELETE on brands, provider_keys, provider_key_idempotency, +# and brand_asset_operations. Vault access is limited to schema USAGE, +# create_secret EXECUTE, decrypted id/value SELECT, and secret id SELECT/DELETE. +DATABASE_URL= diff --git a/backend/app/auth.py b/backend/app/auth.py index 1c825ab..0c5129a 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -11,12 +11,15 @@ from .config import load_settings -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True, repr=False) class CurrentUser: user_id: str email: str | None = None access_token: str | None = None + def __repr__(self) -> str: + return "" + def _unauthorized(message: str = "Sign in required.") -> HTTPException: return HTTPException( diff --git a/backend/app/config.py b/backend/app/config.py index 51b1742..7301570 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,7 +12,7 @@ _DATABASE_TIMEOUT_MILLISECONDS = _DATABASE_TIMEOUT_SECONDS * 1000 -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True, repr=False) class Settings: supabase_url: str supabase_secret_key: str @@ -20,6 +20,9 @@ class Settings: database_url: str | None = None allowed_origins: tuple[str, ...] = () + def __repr__(self) -> str: + return "" + def load_settings() -> Settings: origins = os.getenv("ALLOWED_ORIGINS", _DEFAULT_ALLOWED_ORIGINS) diff --git a/backend/app/models/provider_key.py b/backend/app/models/provider_key.py index f2e8758..64d120f 100644 --- a/backend/app/models/provider_key.py +++ b/backend/app/models/provider_key.py @@ -6,7 +6,7 @@ from typing import Literal from uuid import UUID -from pydantic import BaseModel, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator _KEY_SUFFIX = re.compile(r"[A-Za-z0-9_-]{4}$") @@ -42,8 +42,8 @@ class ProviderValidationCode(str, Enum): class ProviderKeyAdd(BaseModel): provider: Provider - key: str - label: str | None = None + key: str = Field(repr=False) + label: str | None = Field(default=None, repr=False) make_active: bool = True @field_validator("key") @@ -73,8 +73,8 @@ def reject_key_in_label(self) -> ProviderKeyAdd: class ProviderKey(BaseModel): id: UUID provider: Provider - label: str | None - key_hint: str + label: str | None = Field(repr=False) + key_hint: str = Field(repr=False) is_active: bool is_valid: bool | None last_validated_at: datetime | None diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index a32fcfd..452a054 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -5,7 +5,7 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool -from pydantic import BaseModel +from pydantic import BaseModel, Field from ..config import load_settings from ..services.login_guard import LoginGuard, get_login_guard @@ -16,8 +16,8 @@ class LoginRequest(BaseModel): - email: str - password: str + email: str = Field(repr=False) + password: str = Field(repr=False) class InvalidCredentialsError(Exception): diff --git a/backend/app/routes/brands.py b/backend/app/routes/brands.py index 8d30e45..d8c20a3 100644 --- a/backend/app/routes/brands.py +++ b/backend/app/routes/brands.py @@ -8,12 +8,20 @@ from ..auth import CurrentUserDep from ..models.brand import Brand, BrandCreate, BrandDelete, BrandListResponse +from ..services.brand_deletion import ( + BrandConfirmationMismatchError, + BrandDeletion, + BrandDeletionCleanupError, + get_brand_deletion, +) from ..services.brand_storage import ( BrandStorage, BrandStorageError, + BrandStorageUnknownError, get_brand_storage, ) from ..services.brand_store import ( + BrandAssetOperationStaleError, BrandCleanupRequiredError, BrandMutationInProgressError, BrandNameTakenError, @@ -27,6 +35,7 @@ BrandStoreDep = Annotated[BrandStore, Depends(get_brand_store)] BrandStorageDep = Annotated[BrandStorage, Depends(get_brand_storage)] +BrandDeletionDep = Annotated[BrandDeletion, Depends(get_brand_deletion)] MAX_LOGO_BYTES = 5 * 1024 * 1024 LOGO_TYPES = { @@ -96,7 +105,7 @@ def _mutation_in_progress() -> HTTPException: def _mutation_error(exc: Exception) -> HTTPException: if isinstance(exc, BrandCleanupRequiredError): return _cleanup_required() - if isinstance(exc, BrandMutationInProgressError): + if isinstance(exc, (BrandMutationInProgressError, BrandAssetOperationStaleError)): return _mutation_in_progress() return _not_found() @@ -141,6 +150,31 @@ def _has_valid_signature(content_type: str, data: bytes) -> bool: return False +async def _reconcile_asset_operation( + user_id: str, + brand_id: UUID, + brand_store: BrandStore, + brand_storage: BrandStorage, +) -> None: + operation = brand_store.get_asset_operation(user_id, brand_id) + if operation is None: + return + if operation.remote_status == "failed": + brand_store.abandon_definitive_asset_failure( + user_id, brand_id, operation.id + ) + return + if operation.remote_status != "succeeded": + raise BrandMutationInProgressError + if operation.operation == "upload" and operation.previous_path: + try: + await brand_storage.delete_logo(operation.previous_path) + except BrandStorageError as exc: + brand_store.mark_asset_cleanup_required(user_id, brand_id, operation.id) + raise _storage_unavailable() from exc + brand_store.complete_asset_operation(user_id, brand_id, operation.id) + + @router.get("", response_model=BrandListResponse) def list_brands( request: Request, @@ -207,48 +241,30 @@ async def delete_brand( request: Request, brand_id: UUID, current_user: CurrentUserDep, - brand_store: BrandStoreDep, - brand_storage: BrandStorageDep, + brand_deletion: BrandDeletionDep, payload: BrandDelete | None = None, ) -> Response: + if payload is None: + raise _confirmation_mismatch() try: - brand = brand_store.get_brand(current_user.user_id, brand_id) + await brand_deletion.delete( + current_user.user_id, brand_id, payload.confirm_name + ) except LookupError as exc: raise _not_found() from exc - - if payload is None or payload.confirm_name != brand.name: - raise _confirmation_mismatch() - - try: - logo_path = brand_store.begin_brand_cleanup(current_user.user_id, brand_id) - except ( - LookupError, - BrandCleanupRequiredError, - BrandMutationInProgressError, - ) as exc: - raise _mutation_error(exc) from exc - - if logo_path: - try: - await brand_storage.delete_logo(logo_path) - except BrandStorageError as exc: - logger.warning( - "brands.delete_logo_cleanup_failed", - extra={ - "event": "brands.delete_logo_cleanup_failed", - "request_id": getattr(request.state, "request_id", "unknown"), - }, - ) - raise _cleanup_failed() from exc - - try: - brand_store.delete_brand_after_cleanup(current_user.user_id, brand_id) - except ( - LookupError, - BrandCleanupRequiredError, - BrandMutationInProgressError, - ) as exc: - raise _mutation_error(exc) from exc + except BrandConfirmationMismatchError as exc: + raise _confirmation_mismatch() from exc + except BrandMutationInProgressError as exc: + raise _mutation_in_progress() from exc + except BrandDeletionCleanupError as exc: + logger.warning( + "brands.delete_cleanup_failed", + extra={ + "event": "brands.delete_cleanup_failed", + "request_id": getattr(request.state, "request_id", "unknown"), + }, + ) + raise _cleanup_failed() from exc logger.info( "brands.delete_success", @@ -270,7 +286,7 @@ async def upload_brand_logo( file: Annotated[UploadFile, File()], ) -> Brand: try: - old_path = brand_store.get_logo_path(current_user.user_id, brand_id) + brand_store.get_logo_path(current_user.user_id, brand_id) except ( LookupError, BrandCleanupRequiredError, @@ -278,6 +294,13 @@ async def upload_brand_logo( ) as exc: raise _mutation_error(exc) from exc + try: + await _reconcile_asset_operation( + current_user.user_id, brand_id, brand_store, brand_storage + ) + except (BrandMutationInProgressError, BrandAssetOperationStaleError) as exc: + raise _mutation_error(exc) from exc + content_type = file.content_type or "" extension = LOGO_TYPES.get(content_type) if extension is None: @@ -294,48 +317,67 @@ async def upload_brand_logo( if not _has_valid_signature(content_type, data): raise _unsupported_media_type() - new_path = f"brands/{brand_id}/logo.{extension}" try: - await brand_storage.upload_logo(new_path, data, content_type) + operation = brand_store.begin_logo_upload( + current_user.user_id, brand_id, extension + ) + except ( + LookupError, + BrandCleanupRequiredError, + BrandMutationInProgressError, + ) as exc: + raise _mutation_error(exc) from exc + + assert operation.object_path is not None + try: + await brand_storage.upload_logo(operation.object_path, data, content_type) + except BrandStorageUnknownError as exc: + brand_store.record_asset_remote_status( + current_user.user_id, brand_id, operation.id, "unknown" + ) + raise _storage_unavailable() from exc except BrandStorageError as exc: + brand_store.record_asset_remote_status( + current_user.user_id, brand_id, operation.id, "failed" + ) + brand_store.abandon_definitive_asset_failure( + current_user.user_id, brand_id, operation.id + ) raise _storage_unavailable() from exc try: - brand = brand_store.update_logo_path( - current_user.user_id, - brand_id, - new_path, + brand_store.record_asset_remote_status( + current_user.user_id, brand_id, operation.id, "succeeded" + ) + brand = brand_store.publish_uploaded_logo( + current_user.user_id, brand_id, operation.id ) - except Exception as exc: - if new_path != old_path: - try: - await brand_storage.delete_logo(new_path) - except BrandStorageError: - logger.warning( - "brands.logo_rollback_failed", - extra={ - "event": "brands.logo_rollback_failed", - "request_id": getattr(request.state, "request_id", "unknown"), - }, - ) - if isinstance( - exc, - (LookupError, BrandCleanupRequiredError, BrandMutationInProgressError), - ): - raise _mutation_error(exc) from exc - raise - - if old_path and old_path != new_path: + except ( + LookupError, + BrandCleanupRequiredError, + BrandAssetOperationStaleError, + ) as exc: + raise _mutation_error(exc) from exc + + if operation.previous_path: try: - await brand_storage.delete_logo(old_path) - except BrandStorageError: - logger.warning( - "brands.logo_cleanup_failed", - extra={ - "event": "brands.logo_cleanup_failed", - "request_id": getattr(request.state, "request_id", "unknown"), - }, + await brand_storage.delete_logo(operation.previous_path) + except BrandStorageError as exc: + brand_store.mark_asset_cleanup_required( + current_user.user_id, brand_id, operation.id ) + raise _storage_unavailable() from exc + + try: + brand_store.complete_asset_operation( + current_user.user_id, brand_id, operation.id + ) + except ( + LookupError, + BrandCleanupRequiredError, + BrandAssetOperationStaleError, + ) as exc: + raise _mutation_error(exc) from exc logger.info( "brands.logo_upload_success", @@ -356,7 +398,11 @@ async def delete_brand_logo( brand_storage: BrandStorageDep, ) -> Response: try: - old_path = brand_store.get_logo_path(current_user.user_id, brand_id) + brand_store.get_logo_path(current_user.user_id, brand_id) + await _reconcile_asset_operation( + current_user.user_id, brand_id, brand_store, brand_storage + ) + operation = brand_store.begin_logo_remove(current_user.user_id, brand_id) except ( LookupError, BrandCleanupRequiredError, @@ -364,27 +410,39 @@ async def delete_brand_logo( ) as exc: raise _mutation_error(exc) from exc - if old_path is None: + if operation is None: return Response(status_code=status.HTTP_204_NO_CONTENT) + assert operation.object_path is not None + try: + await brand_storage.delete_logo(operation.object_path) + except BrandStorageUnknownError as exc: + brand_store.record_asset_remote_status( + current_user.user_id, brand_id, operation.id, "unknown" + ) + raise _storage_unavailable() from exc + except BrandStorageError as exc: + brand_store.record_asset_remote_status( + current_user.user_id, brand_id, operation.id, "failed" + ) + brand_store.abandon_definitive_asset_failure( + current_user.user_id, brand_id, operation.id + ) + raise _storage_unavailable() from exc + + brand_store.record_asset_remote_status( + current_user.user_id, brand_id, operation.id, "succeeded" + ) try: - brand_store.update_logo_path(current_user.user_id, brand_id, None) + brand_store.complete_asset_operation( + current_user.user_id, brand_id, operation.id + ) except ( LookupError, BrandCleanupRequiredError, - BrandMutationInProgressError, + BrandAssetOperationStaleError, ) as exc: raise _mutation_error(exc) from exc - try: - await brand_storage.delete_logo(old_path) - except BrandStorageError: - logger.warning( - "brands.logo_cleanup_failed", - extra={ - "event": "brands.logo_cleanup_failed", - "request_id": getattr(request.state, "request_id", "unknown"), - }, - ) logger.info( "brands.logo_delete_success", diff --git a/backend/app/routes/provider_keys.py b/backend/app/routes/provider_keys.py index 7633048..7f501fa 100644 --- a/backend/app/routes/provider_keys.py +++ b/backend/app/routes/provider_keys.py @@ -112,6 +112,26 @@ def _activation_error(exc: Exception) -> HTTPException: ) +def _deletion_error(exc: Exception) -> HTTPException: + if isinstance(exc, LookupError): + return _error( + status.HTTP_404_NOT_FOUND, + "PROVIDER_KEY_NOT_FOUND", + "Provider key not found.", + ) + if isinstance(exc, BrandCleanupRequiredError): + return _error( + status.HTTP_409_CONFLICT, + "BRAND_CLEANUP_REQUIRED", + "Brand cleanup is required. Retry deletion.", + ) + return _error( + status.HTTP_503_SERVICE_UNAVAILABLE, + "KEY_CLEANUP_REQUIRED", + "Key cleanup did not complete. Retry deletion.", + ) + + async def _within_deadline(awaitable: Awaitable[_T], deadline: float) -> _T: remaining = max(0.0, deadline - time.monotonic()) return await asyncio.wait_for(awaitable, timeout=remaining) @@ -269,6 +289,42 @@ def activate_provider_key( return key +@router.delete( + "/{brand_id}/keys/{key_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_provider_key( + request: Request, + brand_id: UUID, + key_id: UUID, + current_user: CurrentUserDep, + provider_key_store: ProviderKeyStoreDep, +) -> None: + try: + provider_key_store.delete_key(current_user.user_id, brand_id, key_id) + except ( + LookupError, + BrandCleanupRequiredError, + KeyCleanupRequiredError, + ) as exc: + logger.warning( + "provider_keys.delete_retry", + extra={ + "event": "provider_keys.delete_retry", + "request_id": getattr(request.state, "request_id", "unknown"), + }, + ) + raise _deletion_error(exc) from exc + + logger.info( + "provider_keys.delete_success", + extra={ + "event": "provider_keys.delete_success", + "request_id": getattr(request.state, "request_id", "unknown"), + }, + ) + + @router.post( "/{brand_id}/keys/{key_id}/validate", response_model=ProviderKeyValidationResponse, diff --git a/backend/app/services/brand_deletion.py b/backend/app/services/brand_deletion.py new file mode 100644 index 0000000..83dccd9 --- /dev/null +++ b/backend/app/services/brand_deletion.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError + +from ..config import get_engine +from .brand_storage import BrandStorage, BrandStorageError, get_brand_storage +from .brand_store import BrandStore, get_brand_store + + +class BrandConfirmationMismatchError(Exception): + pass + + +class BrandDeletionCleanupError(Exception): + pass + + +@dataclass(frozen=True, slots=True) +class BrandDeletion: + engine: Engine + brand_store: BrandStore + storage: BrandStorage + + async def delete(self, user_id: str, brand_id: UUID, confirm_name: str) -> None: + try: + self.brand_store.fence_brand_for_deletion( + user_id, brand_id, confirm_name + ) + except ValueError as exc: + raise BrandConfirmationMismatchError from exc + + try: + await self.storage.delete_brand_prefix(brand_id) + if not await self.storage.brand_prefix_is_empty(brand_id): + raise BrandStorageError + except BrandStorageError as exc: + raise BrandDeletionCleanupError from exc + + try: + self._delete_database_dependencies(user_id, brand_id) + except (LookupError, BrandDeletionCleanupError): + raise + except SQLAlchemyError as exc: + raise BrandDeletionCleanupError from exc + + def _delete_database_dependencies(self, user_id: str, brand_id: UUID) -> None: + with self.engine.begin() as connection: + brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) + BrandStore.require_no_asset_operation(connection, brand_id) + if brand["deletion_state"] != "cleanup_required": + raise BrandDeletionCleanupError + secret_ids = connection.execute( + text( + "SELECT vault_secret_id FROM provider_keys " + "WHERE brand_id = :brand_id FOR UPDATE" + ), + {"brand_id": brand_id}, + ).scalars().all() + if secret_ids: + connection.execute( + text("DELETE FROM vault.secrets WHERE id = ANY(:secret_ids)"), + {"secret_ids": list(secret_ids)}, + ) + remaining_secrets = connection.execute( + text( + "SELECT count(*) FROM vault.secrets " + "WHERE id = ANY(:secret_ids)" + ), + {"secret_ids": list(secret_ids)}, + ).scalar_one() + if remaining_secrets: + raise BrandDeletionCleanupError + connection.execute( + text("DELETE FROM provider_key_idempotency WHERE brand_id = :brand_id"), + {"brand_id": brand_id}, + ) + connection.execute( + text("DELETE FROM provider_keys WHERE brand_id = :brand_id"), + {"brand_id": brand_id}, + ) + remaining = connection.execute( + text( + """ + SELECT + (SELECT count(*) FROM provider_keys WHERE brand_id = :brand_id) + + (SELECT count(*) FROM provider_key_idempotency WHERE brand_id = :brand_id) + + (SELECT count(*) FROM brand_asset_operations WHERE brand_id = :brand_id) + """ + ), + {"brand_id": brand_id}, + ).scalar_one() + if remaining: + raise BrandDeletionCleanupError + deleted = connection.execute( + text( + "DELETE FROM brands WHERE id = :brand_id " + "AND owner_user_id = :user_id RETURNING id" + ), + {"brand_id": brand_id, "user_id": user_id}, + ).scalar_one_or_none() + if deleted is None: + raise LookupError("Brand not found.") + if connection.execute( + text("SELECT EXISTS (SELECT 1 FROM brands WHERE id = :brand_id)"), + {"brand_id": brand_id}, + ).scalar_one(): + raise BrandDeletionCleanupError + + +@lru_cache(maxsize=1) +def get_brand_deletion() -> BrandDeletion: + return BrandDeletion(get_engine(), get_brand_store(), get_brand_storage()) diff --git a/backend/app/services/brand_storage.py b/backend/app/services/brand_storage.py index 5764f7e..3d76637 100644 --- a/backend/app/services/brand_storage.py +++ b/backend/app/services/brand_storage.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from functools import lru_cache from urllib.parse import quote +from uuid import UUID import httpx @@ -10,13 +11,25 @@ class BrandStorageError(Exception): - pass + """A Storage operation did not complete safely.""" -@dataclass(frozen=True, slots=True) +class BrandStorageUnknownError(BrandStorageError): + """The remote outcome may have been committed and must remain fenced.""" + + +@dataclass(frozen=True, slots=True, repr=False) class BrandStorage: supabase_url: str secret_key: str + transport: httpx.AsyncBaseTransport | None = None + + PAGE_SIZE = 100 + DELETE_BATCH_SIZE = 100 + MAX_CLEANUP_BATCHES = 1_000 + + def __repr__(self) -> str: + return "" @property def _headers(self) -> dict[str, str]: @@ -25,6 +38,13 @@ def _headers(self) -> dict[str, str]: "Authorization": f"Bearer {self.secret_key}", } + @staticmethod + def logo_path(brand_id: UUID, operation_id: UUID, extension: str) -> str: + return f"brands/{brand_id}/logos/{operation_id}.{extension}" + + def _client(self) -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=30.0, transport=self.transport) + async def upload_logo(self, path: str, data: bytes, content_type: str) -> None: encoded_path = quote(path, safe="/") url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/brand-assets/{encoded_path}" @@ -32,36 +52,129 @@ async def upload_logo(self, path: str, data: bytes, content_type: str) -> None: **self._headers, "Content-Type": content_type, "Cache-Control": "no-cache", - "x-upsert": "true", + "x-upsert": "false", } - try: - async with httpx.AsyncClient(timeout=30.0) as client: + async with self._client() as client: response = await client.post(url, headers=headers, content=data) except (httpx.TimeoutException, httpx.RequestError) as exc: - raise BrandStorageError from exc + raise BrandStorageUnknownError from exc + if not response.is_success: + if response.status_code >= 500: + raise BrandStorageUnknownError + raise BrandStorageError + async def object_exists(self, path: str) -> bool: + encoded_path = quote(path, safe="/") + url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/brand-assets/{encoded_path}" + try: + async with self._client() as client: + response = await client.head(url, headers=self._headers) + except (httpx.TimeoutException, httpx.RequestError) as exc: + raise BrandStorageUnknownError from exc + if response.status_code in {400, 404}: + return False if not response.is_success: + if response.status_code >= 500: + raise BrandStorageUnknownError raise BrandStorageError + return True async def delete_logo(self, path: str) -> None: - url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/brand-assets" - headers = {**self._headers, "Content-Type": "application/json"} + await self._delete_paths([path]) + if await self.object_exists(path): + raise BrandStorageError + async def _list_directory( + self, prefix: str, offset: int = 0 + ) -> list[dict[str, object]]: + url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/list/brand-assets" + payload = { + "prefix": prefix.rstrip("/"), + "limit": self.PAGE_SIZE, + "offset": offset, + "sortBy": {"column": "name", "order": "asc"}, + } try: - async with httpx.AsyncClient(timeout=30.0) as client: + async with self._client() as client: + response = await client.post( + url, + headers={**self._headers, "Content-Type": "application/json"}, + json=payload, + ) + except (httpx.TimeoutException, httpx.RequestError) as exc: + raise BrandStorageUnknownError from exc + if not response.is_success: + if response.status_code >= 500: + raise BrandStorageUnknownError + raise BrandStorageError + value = response.json() + if not isinstance(value, list): + raise BrandStorageError + return value + + async def _list_prefix_page(self, root: str) -> list[str]: + objects: list[str] = [] + directories = [(root.rstrip("/"), 0)] + visited: set[tuple[str, int]] = set() + while directories and len(objects) < self.PAGE_SIZE: + directory, offset = directories.pop() + page_key = (directory, offset) + if page_key in visited: + continue + visited.add(page_key) + items = await self._list_directory(directory, offset) + if len(items) == self.PAGE_SIZE: + directories.append((directory, offset + self.PAGE_SIZE)) + for item in items: + name = item.get("name") + if not isinstance(name, str) or not name or "/" in name: + raise BrandStorageError + path = f"{directory}/{name}" + if item.get("metadata") is None: + directories.append((path, 0)) + else: + objects.append(path) + if len(objects) == self.PAGE_SIZE: + break + return objects + + async def _delete_paths(self, paths: list[str]) -> None: + if not paths: + return + url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/brand-assets" + try: + async with self._client() as client: response = await client.request( "DELETE", url, - headers=headers, - json={"prefixes": [path]}, + headers={**self._headers, "Content-Type": "application/json"}, + json={"prefixes": paths}, ) except (httpx.TimeoutException, httpx.RequestError) as exc: - raise BrandStorageError from exc - + raise BrandStorageUnknownError from exc if not response.is_success: + if response.status_code >= 500: + raise BrandStorageUnknownError raise BrandStorageError + async def delete_brand_prefix(self, brand_id: UUID) -> None: + prefix = f"brands/{brand_id}" + for _ in range(self.MAX_CLEANUP_BATCHES): + paths = await self._list_prefix_page(prefix) + if not paths: + # A separate final read prevents a transient empty page from authorizing + # physical deletion. + if await self._list_prefix_page(prefix): + continue + return + for start in range(0, len(paths), self.DELETE_BATCH_SIZE): + await self._delete_paths(paths[start : start + self.DELETE_BATCH_SIZE]) + raise BrandStorageError + + async def brand_prefix_is_empty(self, brand_id: UUID) -> bool: + return not await self._list_prefix_page(f"brands/{brand_id}") + @lru_cache(maxsize=1) def get_brand_storage() -> BrandStorage: diff --git a/backend/app/services/brand_store.py b/backend/app/services/brand_store.py index c9419dd..3e8b76d 100644 --- a/backend/app/services/brand_store.py +++ b/backend/app/services/brand_store.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from functools import lru_cache from typing import Any -from uuid import UUID +from uuid import UUID, uuid4 from sqlalchemy import text from sqlalchemy.engine import Connection, Engine @@ -26,6 +26,21 @@ class BrandMutationInProgressError(Exception): pass +class BrandAssetOperationStaleError(Exception): + pass + + +@dataclass(frozen=True, slots=True) +class BrandAssetOperation: + id: UUID + brand_id: UUID + operation: str + object_path: str | None + previous_path: str | None + state: str + remote_status: str + + @dataclass(frozen=True, slots=True) class BrandStore: engine: Engine @@ -171,113 +186,271 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand: def get_logo_path(self, user_id: str, brand_id: UUID) -> str | None: with self.engine.begin() as connection: - row = self.lock_owned_brand_without_asset_operation( - connection, user_id, brand_id - ) + row = self.lock_owned_brand_for_mutation(connection, user_id, brand_id) return row["logo_path"] - def begin_brand_cleanup(self, user_id: str, brand_id: UUID) -> str | None: + def get_asset_operation( + self, user_id: str, brand_id: UUID + ) -> BrandAssetOperation | None: with self.engine.begin() as connection: - row = self.lock_owned_brand(connection, user_id, brand_id) - self.require_no_asset_operation(connection, brand_id) - if row["deletion_state"] == "active": - row = connection.execute( - text( - """ - UPDATE brands - SET deletion_state = 'cleanup_required' - WHERE id = :brand_id AND owner_user_id = :owner_user_id - RETURNING id, name, logo_path, deletion_state, created_at - """ - ), - {"brand_id": brand_id, "owner_user_id": user_id}, - ).mappings().one_or_none() + self.lock_owned_brand(connection, user_id, brand_id) + connection.execute( + text( + """ + UPDATE brand_asset_operations + SET state = 'cleanup_required', remote_status = 'unknown' + WHERE brand_id = :brand_id AND remote_status = 'pending' + AND started_at < clock_timestamp() - interval '5 minutes' + """ + ), + {"brand_id": brand_id}, + ) + row = connection.execute( + text( + """ + SELECT id, brand_id, operation, object_path, previous_path, + state, remote_status + FROM brand_asset_operations + WHERE brand_id = :brand_id + FOR UPDATE + """ + ), + {"brand_id": brand_id}, + ).mappings().one_or_none() + return BrandAssetOperation(**row) if row is not None else None - if row is None: - raise LookupError("Brand not found.") - return row["logo_path"] + def begin_logo_upload( + self, user_id: str, brand_id: UUID, extension: str + ) -> BrandAssetOperation: + operation_id = uuid4() + object_path = f"brands/{brand_id}/logos/{operation_id}.{extension}" + with self.engine.begin() as connection: + brand = self.lock_owned_brand_without_asset_operation( + connection, user_id, brand_id + ) + row = connection.execute( + text( + """ + INSERT INTO brand_asset_operations ( + id, brand_id, operation, object_path, previous_path, + state, remote_status + ) VALUES ( + :id, :brand_id, 'upload', :object_path, :previous_path, + 'in_progress', 'pending' + ) + RETURNING id, brand_id, operation, object_path, previous_path, + state, remote_status + """ + ), + { + "id": operation_id, + "brand_id": brand_id, + "object_path": object_path, + "previous_path": brand["logo_path"], + }, + ).mappings().one() + return BrandAssetOperation(**row) - def update_logo_path( - self, - user_id: str, - brand_id: UUID, - logo_path: str | None, - ) -> Brand: + def begin_logo_remove( + self, user_id: str, brand_id: UUID + ) -> BrandAssetOperation | None: + operation_id = uuid4() with self.engine.begin() as connection: - self.lock_owned_brand_without_asset_operation( + brand = self.lock_owned_brand_without_asset_operation( connection, user_id, brand_id ) + if brand["logo_path"] is None: + return None row = connection.execute( text( """ - UPDATE brands - SET logo_path = :logo_path - WHERE id = :brand_id AND owner_user_id = :owner_user_id - RETURNING id, name, logo_path, deletion_state, created_at + INSERT INTO brand_asset_operations ( + id, brand_id, operation, object_path, previous_path, + state, remote_status + ) VALUES ( + :id, :brand_id, 'remove', :object_path, NULL, + 'in_progress', 'pending' + ) + RETURNING id, brand_id, operation, object_path, previous_path, + state, remote_status """ ), { + "id": operation_id, "brand_id": brand_id, - "owner_user_id": user_id, - "logo_path": logo_path, + "object_path": brand["logo_path"], }, - ).mappings().one_or_none() + ).mappings().one() + return BrandAssetOperation(**row) - if row is None: - raise LookupError("Brand not found.") - return self._to_brand(row) + @staticmethod + def _lock_operation( + connection: Connection, brand_id: UUID, operation_id: UUID + ) -> Mapping[str, Any]: + operation = connection.execute( + text( + """ + SELECT id, brand_id, operation, object_path, previous_path, + state, remote_status + FROM brand_asset_operations + WHERE brand_id = :brand_id AND id = :operation_id + FOR UPDATE + """ + ), + {"brand_id": brand_id, "operation_id": operation_id}, + ).mappings().one_or_none() + if operation is None: + raise BrandAssetOperationStaleError + return operation - def mark_cleanup_required(self, user_id: str, brand_id: UUID) -> Brand: + def record_asset_remote_status( + self, + user_id: str, + brand_id: UUID, + operation_id: UUID, + remote_status: str, + ) -> None: + if remote_status not in {"succeeded", "failed", "unknown"}: + raise ValueError("Invalid remote status.") with self.engine.begin() as connection: - current = self.lock_owned_brand(connection, user_id, brand_id) - self.require_no_asset_operation(connection, brand_id) - if current["deletion_state"] == "cleanup_required": - return self._to_brand(current) + self.lock_owned_brand(connection, user_id, brand_id) + self._lock_operation(connection, brand_id, operation_id) + connection.execute( + text( + """ + UPDATE brand_asset_operations + SET remote_status = :remote_status, + state = CASE WHEN :remote_status = 'succeeded' + THEN state ELSE 'cleanup_required' END + WHERE id = :operation_id + """ + ), + {"operation_id": operation_id, "remote_status": remote_status}, + ) + + def abandon_definitive_asset_failure( + self, user_id: str, brand_id: UUID, operation_id: UUID + ) -> None: + with self.engine.begin() as connection: + self.lock_owned_brand(connection, user_id, brand_id) + operation = self._lock_operation(connection, brand_id, operation_id) + if operation["remote_status"] != "failed": + raise BrandAssetOperationStaleError + connection.execute( + text("DELETE FROM brand_asset_operations WHERE id = :operation_id"), + {"operation_id": operation_id}, + ) + + def mark_asset_cleanup_required( + self, user_id: str, brand_id: UUID, operation_id: UUID + ) -> None: + with self.engine.begin() as connection: + self.lock_owned_brand(connection, user_id, brand_id) + operation = self._lock_operation(connection, brand_id, operation_id) + if operation["remote_status"] != "succeeded": + raise BrandAssetOperationStaleError + connection.execute( + text( + "UPDATE brand_asset_operations SET state = 'cleanup_required' " + "WHERE id = :operation_id" + ), + {"operation_id": operation_id}, + ) + + def publish_uploaded_logo( + self, user_id: str, brand_id: UUID, operation_id: UUID + ) -> Brand: + with self.engine.begin() as connection: + brand = self.lock_owned_brand_for_mutation(connection, user_id, brand_id) + operation = self._lock_operation(connection, brand_id, operation_id) + if ( + operation["operation"] != "upload" + or operation["remote_status"] != "succeeded" + ): + raise BrandAssetOperationStaleError row = connection.execute( text( """ - UPDATE brands - SET deletion_state = 'cleanup_required' - WHERE id = :brand_id AND owner_user_id = :owner_user_id + UPDATE brands SET logo_path = :logo_path + WHERE id = :brand_id AND deletion_state = 'active' RETURNING id, name, logo_path, deletion_state, created_at """ ), - {"brand_id": brand_id, "owner_user_id": user_id}, + {"brand_id": brand_id, "logo_path": operation["object_path"]}, ).mappings().one_or_none() - - if row is None: - raise LookupError("Brand not found.") + if row is None: + raise BrandAssetOperationStaleError return self._to_brand(row) - def delete_brand_after_cleanup(self, user_id: str, brand_id: UUID) -> None: + def complete_asset_operation( + self, user_id: str, brand_id: UUID, operation_id: UUID + ) -> None: + with self.engine.begin() as connection: + brand = self.lock_owned_brand_for_mutation(connection, user_id, brand_id) + operation = self._lock_operation(connection, brand_id, operation_id) + if operation["remote_status"] != "succeeded": + raise BrandAssetOperationStaleError + if operation["operation"] == "upload": + if brand["logo_path"] != operation["object_path"]: + raise BrandAssetOperationStaleError + elif brand["logo_path"] != operation["object_path"]: + raise BrandAssetOperationStaleError + else: + connection.execute( + text("UPDATE brands SET logo_path = NULL WHERE id = :brand_id"), + {"brand_id": brand_id}, + ) + connection.execute( + text("DELETE FROM brand_asset_operations WHERE id = :operation_id"), + {"operation_id": operation_id}, + ) + + def mark_abandoned_asset_operations_unknown(self, brand_id: UUID) -> int: + with self.engine.begin() as connection: + result = connection.execute( + text( + """ + UPDATE brand_asset_operations + SET state = 'cleanup_required', remote_status = 'unknown' + WHERE brand_id = :brand_id AND remote_status = 'pending' + AND started_at < clock_timestamp() - interval '5 minutes' + """ + ), + {"brand_id": brand_id}, + ) + return result.rowcount + + def fence_brand_for_deletion( + self, user_id: str, brand_id: UUID, confirm_name: str + ) -> None: with self.engine.begin() as connection: - current = self.lock_owned_brand(connection, user_id, brand_id) + brand = self.lock_owned_brand(connection, user_id, brand_id) + if brand["name"] != confirm_name: + raise ValueError("Confirmation mismatch.") self.require_no_asset_operation(connection, brand_id) - if current["deletion_state"] != "cleanup_required": - raise BrandCleanupRequiredError - has_provider_keys = connection.execute( + connection.execute( text( - "SELECT EXISTS (" - "SELECT 1 FROM provider_keys WHERE brand_id = :brand_id" - ")" + """ + UPDATE brands SET deletion_state = 'cleanup_required' + WHERE id = :brand_id AND deletion_state = 'active' + """ ), {"brand_id": brand_id}, - ).scalar_one() - if has_provider_keys: - raise BrandCleanupRequiredError - deleted_id = connection.execute( + ) + connection.execute( text( """ - DELETE FROM brands - WHERE id = :brand_id AND owner_user_id = :owner_user_id - RETURNING id + UPDATE provider_keys + SET lifecycle = 'cleanup_required', is_active = false, + is_valid = NULL, last_validated_at = NULL, + last_validation_error = NULL, validation_token = NULL, + validation_lease_expires_at = NULL + WHERE brand_id = :brand_id """ ), - {"brand_id": brand_id, "owner_user_id": user_id}, - ).scalar_one_or_none() + {"brand_id": brand_id}, + ) - if deleted_id is None: - raise LookupError("Brand not found.") @lru_cache(maxsize=1) def get_brand_store() -> BrandStore: diff --git a/backend/app/services/provider_key_store.py b/backend/app/services/provider_key_store.py index c875934..01414f7 100644 --- a/backend/app/services/provider_key_store.py +++ b/backend/app/services/provider_key_store.py @@ -276,6 +276,101 @@ def activate_key( raise KeyActivationConflictError from exc raise + def delete_key(self, user_id: str, brand_id: UUID, key_id: UUID) -> None: + try: + with self.engine.begin() as connection: + brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) + row = connection.execute( + text( + """ + SELECT vault_secret_id, lifecycle + FROM provider_keys + WHERE brand_id = :brand_id AND id = :key_id + FOR UPDATE + """ + ), + {"brand_id": brand_id, "key_id": key_id}, + ).mappings().one_or_none() + if row is None: + raise LookupError("Provider key not found.") + + # Resolve path membership before exposing whole-brand cleanup state. + if brand["deletion_state"] != "active": + raise BrandCleanupRequiredError + if row["lifecycle"] == "normal": + connection.execute( + text( + """ + UPDATE provider_keys + SET lifecycle = 'cleanup_required', is_active = false, + is_valid = NULL, last_validated_at = NULL, + last_validation_error = NULL, + validation_token = NULL, + validation_lease_expires_at = NULL + WHERE id = :key_id + """ + ), + {"key_id": key_id}, + ) + except (LookupError, BrandCleanupRequiredError): + raise + except SQLAlchemyError as exc: + raise KeyCleanupRequiredError from exc + + try: + with self.engine.begin() as connection: + brand = BrandStore.lock_owned_brand(connection, user_id, brand_id) + row = connection.execute( + text( + """ + SELECT vault_secret_id, lifecycle + FROM provider_keys + WHERE brand_id = :brand_id AND id = :key_id + FOR UPDATE + """ + ), + {"brand_id": brand_id, "key_id": key_id}, + ).mappings().one_or_none() + if row is None: + raise LookupError("Provider key not found.") + if brand["deletion_state"] != "active": + raise BrandCleanupRequiredError + if row["lifecycle"] != "cleanup_required": + raise KeyCleanupRequiredError + + connection.execute( + text( + """ + UPDATE provider_key_idempotency + SET state = 'deleted', provider_key_id = NULL + WHERE provider_key_id = :key_id + """ + ), + {"key_id": key_id}, + ) + connection.execute( + text("DELETE FROM vault.secrets WHERE id = :vault_secret_id"), + {"vault_secret_id": row["vault_secret_id"]}, + ) + deleted = connection.execute( + text( + "DELETE FROM provider_keys " + "WHERE id = :key_id AND lifecycle = 'cleanup_required'" + ), + {"key_id": key_id}, + ) + if deleted.rowcount != 1: + raise KeyCleanupRequiredError + except ( + LookupError, + BrandCleanupRequiredError, + KeyCleanupRequiredError, + ): + raise + except SQLAlchemyError as exc: + # The first transaction remains committed as the durable retry anchor. + raise KeyCleanupRequiredError from exc + def claim_validation( self, user_id: str, diff --git a/backend/app/services/provider_validation.py b/backend/app/services/provider_validation.py index fc99737..7c941c2 100644 --- a/backend/app/services/provider_validation.py +++ b/backend/app/services/provider_validation.py @@ -1,6 +1,6 @@ from __future__ import annotations -import re +import hashlib import time from dataclasses import dataclass from functools import lru_cache @@ -27,7 +27,6 @@ "INVALID_CREDENTIAL", } ) -_SAFE_REQUEST_ID = re.compile(r"[A-Za-z0-9._:/-]{1,200}\Z") @dataclass(frozen=True, slots=True, repr=False) @@ -69,9 +68,10 @@ def _result( def _request_id(response: httpx.Response, raw_key: str) -> str | None: value = response.headers.get("x-request-id") - if value is None or raw_key in value or _SAFE_REQUEST_ID.fullmatch(value) is None: + if value is None or raw_key in value or len(value) > 512: return None - return value + # Provider headers are untrusted. Preserve correlation without logging raw content. + return f"sha256:{hashlib.sha256(value.encode()).hexdigest()[:16]}" def _is_openai_invalid(body: object) -> bool: diff --git a/backend/tests/contract/test_brands.py b/backend/tests/contract/test_brands.py index b1b20c6..cd6e665 100644 --- a/backend/tests/contract/test_brands.py +++ b/backend/tests/contract/test_brands.py @@ -1,8 +1,8 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import UTC, datetime -from uuid import UUID +from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient @@ -10,8 +10,17 @@ from backend.app.auth import CurrentUser, get_current_user from backend.app.main import app from backend.app.models.brand import Brand, BrandCreate -from backend.app.routes.brands import get_brand_storage, get_brand_store +from backend.app.routes.brands import ( + get_brand_deletion, + get_brand_storage, + get_brand_store, +) +from backend.app.services.brand_deletion import ( + BrandConfirmationMismatchError, + BrandDeletionCleanupError, +) from backend.app.services.brand_store import ( + BrandAssetOperation, BrandCleanupRequiredError, BrandMutationInProgressError, BrandNameTakenError, @@ -26,6 +35,7 @@ class FakeBrandStore: owners: dict[UUID, str] = field(default_factory=dict) cleanup_required: set[UUID] = field(default_factory=set) asset_operations: set[UUID] = field(default_factory=set) + operations: dict[UUID, BrandAssetOperation] = field(default_factory=dict) def create_brand(self, user_id: str, payload: BrandCreate) -> Brand: normalized_name = payload.name.casefold() @@ -54,7 +64,8 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand: def get_logo_path(self, user_id: str, brand_id: UUID) -> str | None: self.get_brand(user_id, brand_id) - self._check_mutation(brand_id) + if brand_id in self.cleanup_required: + raise BrandCleanupRequiredError return self.logo_paths.get(brand_id) def begin_brand_cleanup(self, user_id: str, brand_id: UUID) -> str | None: @@ -91,6 +102,112 @@ def update_logo_path( self.brands = [updated_brand if item.id == brand_id else item for item in self.brands] return updated_brand + def begin_logo_upload( + self, user_id: str, brand_id: UUID, extension: str + ) -> BrandAssetOperation: + brand = self.get_brand(user_id, brand_id) + self._check_mutation(brand_id) + operation_id = uuid4() + operation = BrandAssetOperation( + id=operation_id, + brand_id=brand_id, + operation="upload", + object_path=f"brands/{brand_id}/logos/{operation_id}.{extension}", + previous_path=self.logo_paths.get(brand_id), + state="in_progress", + remote_status="pending", + ) + self.asset_operations.add(brand_id) + self.operations[operation_id] = operation + return operation + + def get_asset_operation( + self, user_id: str, brand_id: UUID + ) -> BrandAssetOperation | None: + self.get_brand(user_id, brand_id) + return next( + ( + operation + for operation in self.operations.values() + if operation.brand_id == brand_id + ), + None, + ) + + def begin_logo_remove( + self, user_id: str, brand_id: UUID + ) -> BrandAssetOperation | None: + self.get_brand(user_id, brand_id) + self._check_mutation(brand_id) + path = self.logo_paths.get(brand_id) + if path is None: + return None + operation_id = uuid4() + operation = BrandAssetOperation( + id=operation_id, + brand_id=brand_id, + operation="remove", + object_path=path, + previous_path=None, + state="in_progress", + remote_status="pending", + ) + self.asset_operations.add(brand_id) + self.operations[operation_id] = operation + return operation + + def record_asset_remote_status( + self, user_id: str, brand_id: UUID, operation_id: UUID, remote_status: str + ) -> None: + self.get_brand(user_id, brand_id) + operation = self.operations[operation_id] + self.operations[operation_id] = replace( + operation, + remote_status=remote_status, + state=( + operation.state + if remote_status == "succeeded" + else "cleanup_required" + ), + ) + + def abandon_definitive_asset_failure( + self, user_id: str, brand_id: UUID, operation_id: UUID + ) -> None: + self.operations.pop(operation_id) + self.asset_operations.discard(brand_id) + + def mark_asset_cleanup_required( + self, user_id: str, brand_id: UUID, operation_id: UUID + ) -> None: + operation = self.operations[operation_id] + self.operations[operation_id] = replace(operation, state="cleanup_required") + + def publish_uploaded_logo( + self, user_id: str, brand_id: UUID, operation_id: UUID + ) -> Brand: + operation = self.operations[operation_id] + self.logo_paths[brand_id] = operation.object_path + brand = self.get_brand(user_id, brand_id) + updated = brand.model_copy( + update={ + "logo_url": ( + "https://example.supabase.co/storage/v1/object/public/" + f"brand-assets/{operation.object_path}" + ) + } + ) + self.brands = [updated if item.id == brand_id else item for item in self.brands] + return updated + + def complete_asset_operation( + self, user_id: str, brand_id: UUID, operation_id: UUID + ) -> None: + operation = self.operations.pop(operation_id) + if operation.operation == "remove": + self.logo_paths[brand_id] = None + self.asset_operations.discard(brand_id) + def delete_brand(self, user_id: str, brand_id: UUID) -> None: self.get_brand(user_id, brand_id) self._check_mutation(brand_id) @@ -132,6 +249,47 @@ async def delete_logo(self, path: str) -> None: raise BrandStorageError + async def delete_brand_prefix(self, brand_id: UUID) -> None: + await self.delete_logo(f"brands/{brand_id}/") + + async def brand_prefix_is_empty(self, brand_id: UUID) -> bool: + return not self.fail_delete + + +@dataclass +class FakeBrandDeletion: + store: FakeBrandStore + storage: FakeBrandStorage + + async def delete(self, user_id: str, brand_id: UUID, confirm_name: str) -> None: + brand = self.store.get_brand(user_id, brand_id) + if brand.name != confirm_name: + raise BrandConfirmationMismatchError + if brand_id in self.store.asset_operations: + raise BrandMutationInProgressError + self.store.cleanup_required.add(brand_id) + updated = brand.model_copy(update={"cleanup_state": "cleanup_required"}) + self.store.brands = [ + updated if item.id == brand_id else item for item in self.store.brands + ] + try: + await self.storage.delete_brand_prefix(brand_id) + if not await self.storage.brand_prefix_is_empty(brand_id): + raise BrandDeletionCleanupError + except Exception as exc: + if isinstance(exc, BrandDeletionCleanupError): + raise + raise BrandDeletionCleanupError from exc + self.store.delete_brand_after_cleanup(user_id, brand_id) + + +def _override_brand_dependencies( + store: FakeBrandStore, storage: FakeBrandStorage +) -> None: + app.dependency_overrides[get_brand_store] = lambda: store + app.dependency_overrides[get_brand_storage] = lambda: storage + app.dependency_overrides[get_brand_deletion] = lambda: FakeBrandDeletion(store, storage) + @pytest.mark.parametrize("name", ["", " ", "A", "A" * 121]) def test_create_brand_rejects_invalid_names(name: str): @@ -355,8 +513,7 @@ def test_logo_and_brand_mutations_respect_brand_fences( email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: with TestClient(app) as client: @@ -380,7 +537,9 @@ def test_logo_and_brand_mutations_respect_brand_fences( assert delete_response.status_code == 409 assert delete_response.json()["error"]["code"] == expected_code assert storage.uploads == [] - assert storage.deletes == [] + assert storage.deletes == ( + [f"brands/{brand.id}/"] if fence_field == "cleanup_required" else [] + ) assert store.brands == ([] if fence_field == "cleanup_required" else [brand]) finally: app.dependency_overrides.clear() @@ -410,8 +569,7 @@ def test_upload_logo_rejects_unsupported_or_spoofed_content( email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: with TestClient(app) as client: @@ -442,8 +600,7 @@ def test_upload_logo_rejects_files_over_five_megabytes(): email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: oversized_png = b"\x89PNG\r\n\x1a\n" + b"x" * (5 * 1024 * 1024) @@ -475,8 +632,7 @@ def test_upload_logo_returns_updated_brand(): email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: png = b"\x89PNG\r\n\x1a\nvalid" @@ -487,11 +643,12 @@ def test_upload_logo_returns_updated_brand(): ) assert response.status_code == 200 - assert response.json()["logo_url"].endswith(f"brands/{brand.id}/logo.png") - assert storage.uploads == [ - (f"brands/{brand.id}/logo.png", png, "image/png") - ] - assert store.logo_paths[brand.id] == f"brands/{brand.id}/logo.png" + logo_path = store.logo_paths[brand.id] + assert logo_path is not None + assert logo_path.startswith(f"brands/{brand.id}/logos/") + assert logo_path.endswith(".png") + assert response.json()["logo_url"].endswith(logo_path) + assert storage.uploads == [(logo_path, png, "image/png")] finally: app.dependency_overrides.clear() @@ -510,8 +667,7 @@ def test_delete_logo_without_existing_logo_is_idempotent(): email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: with TestClient(app) as client: @@ -525,6 +681,50 @@ def test_delete_logo_without_existing_logo_is_idempotent(): app.dependency_overrides.clear() +def test_replacement_cleanup_failure_is_durable_and_retry_completes_it(): + brand = Brand( + id=UUID("22222222-2222-2222-2222-222222222222"), + name="Acme Coffee", + logo_url="https://example.supabase.co/old.png", + created_at=datetime(2026, 7, 25, tzinfo=UTC), + ) + old_path = f"brands/{brand.id}/logo.png" + store = FakeBrandStore(brands=[brand], logo_paths={brand.id: old_path}) + storage = FakeBrandStorage(fail_delete=True) + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + user_id="11111111-1111-1111-1111-111111111111", + email="owner@example.com", + access_token="eyJ...", + ) + _override_brand_dependencies(store, storage) + + try: + png = b"\x89PNG\r\n\x1a\nvalid" + with TestClient(app) as client: + failed = client.post( + f"/api/v1/brands/{brand.id}/logo", + files={"file": ("logo.png", png, "image/png")}, + ) + assert failed.status_code == 502 + operation = next(iter(store.operations.values())) + assert operation.remote_status == "succeeded" + assert operation.state == "cleanup_required" + assert store.logo_paths[brand.id] == operation.object_path + + storage.fail_delete = False + retried = client.post( + f"/api/v1/brands/{brand.id}/logo", + files={"file": ("logo.png", png, "image/png")}, + ) + + assert retried.status_code == 200 + assert store.operations == {} + assert old_path in storage.deletes + assert operation.object_path in storage.deletes + finally: + app.dependency_overrides.clear() + + def test_delete_brand_with_exact_confirmation_removes_brand_and_logo(): brand = Brand( id=UUID("22222222-2222-2222-2222-222222222222"), @@ -540,8 +740,7 @@ def test_delete_brand_with_exact_confirmation_removes_brand_and_logo(): email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: with TestClient(app) as client: @@ -554,7 +753,7 @@ def test_delete_brand_with_exact_confirmation_removes_brand_and_logo(): assert response.status_code == 204 assert response.content == b"" assert store.brands == [] - assert storage.deletes == [logo_path] + assert storage.deletes == [f"brands/{brand.id}/"] finally: app.dependency_overrides.clear() @@ -574,8 +773,7 @@ def test_delete_brand_storage_failure_retains_cleanup_required_brand(): email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: with TestClient(app) as client: @@ -594,7 +792,7 @@ def test_delete_brand_storage_failure_retains_cleanup_required_brand(): assert len(store.brands) == 1 assert store.brands[0].cleanup_state == "cleanup_required" assert store.logo_paths[brand.id] == logo_path - assert storage.deletes == [logo_path] + assert storage.deletes == [f"brands/{brand.id}/"] storage.fail_delete = False with TestClient(app) as client: @@ -607,7 +805,7 @@ def test_delete_brand_storage_failure_retains_cleanup_required_brand(): assert retry.status_code == 204 assert retry.content == b"" assert store.brands == [] - assert storage.deletes == [logo_path, logo_path] + assert storage.deletes == [f"brands/{brand.id}/", f"brands/{brand.id}/"] finally: app.dependency_overrides.clear() @@ -629,8 +827,7 @@ def test_delete_brand_rejects_missing_or_wrong_confirmation_without_mutation( email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: with TestClient(app) as client: @@ -668,8 +865,7 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra email="owner@example.com", access_token="eyJ...", ) - app.dependency_overrides[get_brand_store] = lambda: store - app.dependency_overrides[get_brand_storage] = lambda: storage + _override_brand_dependencies(store, storage) try: with TestClient(app) as client: diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py index efad9c3..e13495f 100644 --- a/backend/tests/contract/test_provider_keys.py +++ b/backend/tests/contract/test_provider_keys.py @@ -4,7 +4,9 @@ import io import json import logging +import secrets import time +from collections.abc import Container from dataclasses import dataclass, field from datetime import UTC, datetime from types import SimpleNamespace @@ -22,12 +24,21 @@ from backend.app.models.provider_key import ProviderKey, ProviderKeyAdd from backend.app.routes import provider_keys as provider_key_routes from backend.app.routes.provider_keys import get_provider_key_store +from backend.app.services import provider_key_store as provider_key_store_module from backend.app.services.brand_store import BrandCleanupRequiredError from backend.app.services.provider_key_store import ( IdempotencyKeyRetiredError, + KeyCleanupRequiredError, + ProviderKeyStore, VaultUnavailableError, ) -from backend.app.services import provider_key_store as provider_key_store_module + + +def _assert_not_exposed( + observable: Container[str], *prohibited_values: str +) -> None: + if any(value in observable for value in prohibited_values): + raise AssertionError("sensitive value was exposed") def _request(path: str = "/api/v1/brands/brand-id/keys/key-id/validate") -> Request: @@ -131,7 +142,8 @@ def test_json_log_formatter_allows_only_audited_safe_fields(): "duration_ms": 42, "provider_request_id": "provider-request-id", } - for secret in ( + _assert_not_exposed( + rendered, raw_key, "Production Key", "***A1B2", @@ -139,8 +151,7 @@ def test_json_log_formatter_allows_only_audited_safe_fields(): "Bearer", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "owner@example.com", - ): - assert secret not in rendered + ) def test_engine_hides_parameters_and_bounds_database_waits(monkeypatch: pytest.MonkeyPatch): @@ -439,7 +450,7 @@ def _safe_key(**updates) -> ProviderKey: return ProviderKey.model_validate(values) -@dataclass(frozen=True) +@dataclass(frozen=True, repr=False) class FakeValidationClaim: attempted_at: datetime key: ProviderKey @@ -473,7 +484,7 @@ def advance(self, seconds: float) -> None: self.current += seconds -@dataclass +@dataclass(repr=False) class FakeValidationScenario: before: ProviderKey = field(default_factory=_safe_key) outcome: str = "valid" @@ -524,7 +535,7 @@ def completed_key(self, result: FakeProviderResult) -> ProviderKey: return self.before -@dataclass +@dataclass(repr=False) class FakeProviderValidator: scenario: FakeValidationScenario calls: list[tuple[str, str, float]] = field(default_factory=list) @@ -541,12 +552,13 @@ async def validate( ) -@dataclass +@dataclass(repr=False) class FakeProviderKeyStore: keys: list[ProviderKey] = field(default_factory=list) error: Exception | None = None add_calls: list[tuple[str, UUID, ProviderKeyAdd, UUID]] = field(default_factory=list) activate_calls: list[tuple[str, UUID, UUID]] = field(default_factory=list) + delete_calls: list[tuple[str, UUID, UUID]] = field(default_factory=list) validation: FakeValidationScenario | None = None claim_calls: list[tuple[str, UUID, UUID, float]] = field(default_factory=list) complete_calls: list[tuple[str, UUID, UUID, UUID, Any, float]] = field( @@ -593,6 +605,12 @@ def activate_key( ] return activated + def delete_key(self, user_id: str, brand_id: UUID, key_id: UUID) -> None: + self.delete_calls.append((user_id, brand_id, key_id)) + if self.error: + raise self.error + self.keys = [key for key in self.keys if key.id != key_id] + def claim_validation( self, user_id: str, @@ -684,7 +702,7 @@ def test_list_keys_returns_exact_empty_and_populated_safe_shapes(provider_key_cl ] } forbidden = {"brand_id", "vault_secret_id", "lifecycle", "validation_token", "updated_at"} - assert forbidden.isdisjoint(populated.json()["keys"][0]) + _assert_not_exposed(populated.json()["keys"][0], *forbidden) @pytest.mark.parametrize("make_active", [None, True, False]) @@ -704,7 +722,7 @@ def test_add_key_defaults_active_and_returns_only_safe_shape(provider_key_client assert response.status_code == 201 assert response.json()["is_active"] is expected_active assert response.json()["key_hint"] == "***A1B2" - assert RAW_KEY not in response.text + _assert_not_exposed(response.text, RAW_KEY) assert store.add_calls[0][2].key == RAW_KEY assert store.add_calls[0][2].make_active is expected_active assert store.add_calls[0][3] == REQUEST_ID @@ -748,7 +766,7 @@ def test_add_key_rejects_invalid_raw_request_before_store(provider_key_client, b assert response.status_code == 400 assert response.json()["error"]["code"] == "VALIDATION_ERROR" - assert RAW_KEY not in response.text + _assert_not_exposed(response.text, RAW_KEY) assert store.add_calls == [] @@ -791,7 +809,7 @@ def test_provider_key_errors_use_exact_safe_envelopes( assert response.json()["error"]["code"] == code assert response.json()["error"]["message"] == message assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"] - assert RAW_KEY not in response.text + _assert_not_exposed(response.text, RAW_KEY) def test_opaque_brand_error_is_identical_for_list_and_add(provider_key_client): @@ -824,8 +842,7 @@ def test_response_model_filters_internal_store_fields(provider_key_client): response = client.get(f"/api/v1/brands/{BRAND_ID}/keys") assert response.status_code == 200 - assert RAW_KEY not in response.text - assert "vault_secret_id" not in response.text + _assert_not_exposed(response.text, RAW_KEY, "vault_secret_id") def test_add_uses_no_provider_client_dependency(provider_key_client): @@ -854,13 +871,14 @@ def test_activate_returns_exact_safe_shape_and_accepts_unvalidated_key( ("11111111-1111-1111-1111-111111111111", BRAND_ID, KEY_ID) ] assert store.validator.calls == [] - assert { + _assert_not_exposed( + response.json(), "brand_id", "vault_secret_id", "lifecycle", "validation_token", "updated_at", - }.isdisjoint(response.json()) + ) def test_activate_deactivates_only_prior_key_for_same_provider(provider_key_client): @@ -965,6 +983,310 @@ def test_activate_rejects_malformed_path_before_store(provider_key_client): assert store.activate_calls == [] +@pytest.mark.parametrize("is_active", [True, False]) +def test_delete_key_returns_empty_204_without_activating_replacement( + provider_key_client, + is_active: bool, +): + client, store = provider_key_client + replacement_id = UUID("77777777-7777-7777-7777-777777777777") + store.keys = [ + _safe_key(is_active=is_active), + _safe_key(id=replacement_id, is_active=False), + ] + + response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}") + + assert response.status_code == 204 + assert response.content == b"" + assert store.delete_calls == [ + ("11111111-1111-1111-1111-111111111111", BRAND_ID, KEY_ID) + ] + assert store.activate_calls == [] + assert [key.id for key in store.keys] == [replacement_id] + assert store.keys[0].is_active is False + + +def test_delete_cleanup_required_key_retries_same_operation(provider_key_client): + client, store = provider_key_client + store.keys = [ + _safe_key( + is_active=False, + is_valid=None, + cleanup_state="cleanup_required", + ) + ] + + response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}") + + assert response.status_code == 204 + assert store.keys == [] + + +@pytest.mark.parametrize( + ("error", "status_code", "code", "message"), + [ + ( + LookupError("secret path detail"), + 404, + "PROVIDER_KEY_NOT_FOUND", + "Provider key not found.", + ), + ( + BrandCleanupRequiredError("secret brand detail"), + 409, + "BRAND_CLEANUP_REQUIRED", + "Brand cleanup is required. Retry deletion.", + ), + ( + KeyCleanupRequiredError("secret SQL bind"), + 503, + "KEY_CLEANUP_REQUIRED", + "Key cleanup did not complete. Retry deletion.", + ), + ], +) +def test_delete_key_errors_use_exact_safe_envelopes( + provider_key_client, + error: Exception, + status_code: int, + code: str, + message: str, +): + client, store = provider_key_client + store.error = error + + response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}") + + assert response.status_code == status_code + assert response.json()["error"]["code"] == code + assert response.json()["error"]["message"] == message + assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"] + _assert_not_exposed(response.text, "secret") + + +def test_delete_path_membership_is_resolved_before_brand_cleanup(provider_key_client): + client, store = provider_key_client + store.error = LookupError("not owned while brand cleanup is pending") + + response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}") + + assert response.status_code == 404 + assert response.json()["error"]["code"] == "PROVIDER_KEY_NOT_FOUND" + + +def test_delete_brand_cleanup_takes_precedence_for_owned_key(provider_key_client): + client, store = provider_key_client + store.error = BrandCleanupRequiredError() + + response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}") + + assert response.status_code == 409 + assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED" + + +def test_delete_rejects_malformed_path_before_store(provider_key_client): + client, store = provider_key_client + + response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/not-a-uuid") + + assert response.status_code == 400 + assert response.json()["error"]["code"] == "VALIDATION_ERROR" + assert store.delete_calls == [] + + +def test_delete_logs_exclude_key_and_exception_details( + provider_key_client, + caplog: pytest.LogCaptureFixture, +): + client, store = provider_key_client + store.error = KeyCleanupRequiredError( + f"{RAW_KEY}; Production Key; ***A1B2; secret SQL bind" + ) + caplog.set_level(logging.INFO) + + response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}") + + rendered_logs = "\n".join( + main._JsonLogFormatter().format(record) for record in caplog.records + ) + observable = response.text + rendered_logs + caplog.text + assert response.status_code == 503 + _assert_not_exposed( + observable, RAW_KEY, "Production Key", "***A1B2", "secret SQL bind" + ) + + +class _StoreResult: + def __init__(self, *, row=None, rowcount: int = 0): + self._row = row + self.rowcount = rowcount + + def mappings(self): + return self + + def one_or_none(self): + return self._row + + +class _StoreConnection: + def __init__(self, results: list[_StoreResult | Exception]): + self.results = results + self.statements: list[str] = [] + + def execute(self, statement, parameters=None): + self.statements.append(str(statement)) + result = self.results.pop(0) + if isinstance(result, Exception): + raise result + return result + + +class _StoreTransaction: + def __init__(self, connection: _StoreConnection, events: list[str]): + self.connection = connection + self.events = events + + def __enter__(self): + self.events.append("begin") + return self.connection + + def __exit__(self, exception_type, exception, traceback): + self.events.append("rollback" if exception_type else "commit") + return False + + +class _StoreEngine: + def __init__(self, connections: list[_StoreConnection]): + self.connections = connections + self.events: list[str] = [] + + def begin(self): + return _StoreTransaction(self.connections.pop(0), self.events) + + +def _deletion_row(lifecycle: str = "normal") -> dict[str, Any]: + return { + "vault_secret_id": UUID("55555555-5555-5555-5555-555555555555"), + "lifecycle": lifecycle, + } + + +def test_store_delete_commits_fence_before_atomic_receipt_vault_and_key_cleanup( + monkeypatch: pytest.MonkeyPatch, +): + fence = _StoreConnection([_StoreResult(row=_deletion_row()), _StoreResult()]) + cleanup = _StoreConnection( + [ + _StoreResult(row=_deletion_row("cleanup_required")), + _StoreResult(), + _StoreResult(rowcount=1), + _StoreResult(rowcount=1), + ] + ) + engine = _StoreEngine([fence, cleanup]) + brands = iter([{"deletion_state": "active"}, {"deletion_state": "active"}]) + monkeypatch.setattr( + provider_key_store_module.BrandStore, + "lock_owned_brand", + lambda connection, user_id, brand_id: next(brands), + ) + + ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID) + + assert engine.events == ["begin", "commit", "begin", "commit"] + assert "lifecycle = 'cleanup_required'" in fence.statements[1] + assert "is_active = false" in fence.statements[1] + assert "validation_token = NULL" in fence.statements[1] + cleanup_sql = "\n".join(cleanup.statements) + assert cleanup_sql.index("provider_key_idempotency") < cleanup_sql.index( + "vault.secrets" + ) + assert cleanup_sql.index("vault.secrets") < cleanup_sql.rindex("provider_keys") + + +def test_store_delete_treats_absent_vault_secret_as_success( + monkeypatch: pytest.MonkeyPatch, +): + fence = _StoreConnection([_StoreResult(row=_deletion_row()), _StoreResult()]) + cleanup = _StoreConnection( + [ + _StoreResult(row=_deletion_row("cleanup_required")), + _StoreResult(), + _StoreResult(rowcount=0), + _StoreResult(rowcount=1), + ] + ) + engine = _StoreEngine([fence, cleanup]) + brands = iter([{"deletion_state": "active"}, {"deletion_state": "active"}]) + monkeypatch.setattr( + provider_key_store_module.BrandStore, + "lock_owned_brand", + lambda connection, user_id, brand_id: next(brands), + ) + + ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID) + + assert engine.events == ["begin", "commit", "begin", "commit"] + + +def test_store_delete_cleanup_sql_failure_rolls_back_after_committed_fence( + monkeypatch: pytest.MonkeyPatch, +): + from sqlalchemy.exc import OperationalError + + fence = _StoreConnection([_StoreResult(row=_deletion_row()), _StoreResult()]) + cleanup = _StoreConnection( + [ + _StoreResult(row=_deletion_row("cleanup_required")), + _StoreResult(), + OperationalError("DELETE", {}, Exception("ambiguous commit")), + ] + ) + engine = _StoreEngine([fence, cleanup]) + brands = iter([{"deletion_state": "active"}, {"deletion_state": "active"}]) + monkeypatch.setattr( + provider_key_store_module.BrandStore, + "lock_owned_brand", + lambda connection, user_id, brand_id: next(brands), + ) + + with pytest.raises(KeyCleanupRequiredError): + ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID) + + assert engine.events == ["begin", "commit", "begin", "rollback"] + + +def test_store_delete_checks_key_membership_before_brand_cleanup( + monkeypatch: pytest.MonkeyPatch, +): + missing = _StoreConnection([_StoreResult(row=None)]) + engine = _StoreEngine([missing]) + monkeypatch.setattr( + provider_key_store_module.BrandStore, + "lock_owned_brand", + lambda connection, user_id, brand_id: {"deletion_state": "cleanup_required"}, + ) + + with pytest.raises(LookupError): + ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID) + + +def test_store_delete_owned_key_defers_to_whole_brand_cleanup( + monkeypatch: pytest.MonkeyPatch, +): + owned = _StoreConnection([_StoreResult(row=_deletion_row())]) + engine = _StoreEngine([owned]) + monkeypatch.setattr( + provider_key_store_module.BrandStore, + "lock_owned_brand", + lambda connection, user_id, brand_id: {"deletion_state": "cleanup_required"}, + ) + + with pytest.raises(BrandCleanupRequiredError): + ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID) + + def _safe_key_json(key: ProviderKey) -> dict[str, Any]: return { "id": str(key.id), @@ -1046,9 +1368,10 @@ def test_validate_returns_exact_outcome_matrix_and_complete_snapshot( store.claim_calls[0][3], ) ] - assert store.validator.calls[0][:2] == (provider, RAW_KEY) + assert store.validator.calls[0][0] == provider + assert secrets.compare_digest(store.validator.calls[0][1], RAW_KEY) assert len(store.complete_calls) == 1 - assert RAW_KEY not in response.text + _assert_not_exposed(response.text, RAW_KEY) def test_invalid_validation_deactivates_without_replacement(provider_key_client): @@ -1305,8 +1628,7 @@ def test_validation_response_and_logs_exclude_every_sensitive_stage_value( main._JsonLogFormatter().format(record) for record in caplog.records ) observable = response.text + rendered_logs + caplog.text - for value in sensitive: - assert value not in observable + _assert_not_exposed(observable, *sensitive) def test_safe_validation_log_contains_only_fixed_allowlisted_metadata(): diff --git a/backend/tests/integration/test_brand_crud.py b/backend/tests/integration/test_brand_crud.py index 83d99e5..f8b7502 100644 --- a/backend/tests/integration/test_brand_crud.py +++ b/backend/tests/integration/test_brand_crud.py @@ -55,6 +55,13 @@ def _hard_delete_owned_brands(user_id: str) -> None: from backend.app.config import get_engine with get_engine().begin() as connection: + connection.execute( + text( + "DELETE FROM brand_asset_operations WHERE brand_id IN (" + "SELECT id FROM brands WHERE owner_user_id = :owner_user_id)" + ), + {"owner_user_id": user_id}, + ) connection.execute( text("DELETE FROM brands WHERE owner_user_id = :owner_user_id"), {"owner_user_id": user_id}, @@ -152,13 +159,19 @@ def test_create_brand_against_real_supabase(): files={"file": ("logo.png", PNG_B, "image/png")}, ) assert same_format_response.status_code == 200 - assert same_format_response.json()["logo_url"] == png_url + replacement_png_url = same_format_response.json()["logo_url"] + assert replacement_png_url != png_url + assert f"brands/{brand_id}/logos/" in replacement_png_url replaced_png = supabase_client.get( - png_url, + replacement_png_url, params={"v": uuid4().hex}, ) assert replaced_png.status_code == 200 assert replaced_png.content == PNG_B + assert supabase_client.get( + png_url, + params={"v": uuid4().hex}, + ).status_code in {400, 404} different_format_response = api_client.post( f"/api/v1/brands/{brand_id}/logo", @@ -167,7 +180,7 @@ def test_create_brand_against_real_supabase(): ) assert different_format_response.status_code == 200 jpeg_url = different_format_response.json()["logo_url"] - assert jpeg_url and jpeg_url != png_url + assert jpeg_url and jpeg_url != replacement_png_url stored_jpeg = supabase_client.get( jpeg_url, params={"v": uuid4().hex}, @@ -175,7 +188,7 @@ def test_create_brand_against_real_supabase(): assert stored_jpeg.status_code == 200 assert stored_jpeg.content == JPEG assert supabase_client.get( - png_url, + replacement_png_url, params={"v": uuid4().hex}, ).status_code in {400, 404} @@ -211,7 +224,7 @@ def test_create_brand_against_real_supabase(): assert unchanged_jpeg.status_code == 200 assert unchanged_jpeg.content == JPEG assert supabase_client.get( - png_url, + replacement_png_url, params={"v": uuid4().hex}, ).status_code in {400, 404} @@ -645,16 +658,21 @@ def test_brand_storage_failure_persists_cleanup_required_retry_anchor(): from backend.app.config import get_engine from backend.app.main import app - from backend.app.routes.brands import get_brand_storage + from backend.app.routes.brands import get_brand_deletion, get_brand_storage + from backend.app.services.brand_deletion import BrandDeletion from backend.app.services.brand_storage import BrandStorageError + from backend.app.services.brand_store import get_brand_store class ControllableStorage: fail_delete = True - async def delete_logo(self, path: str) -> None: + async def delete_brand_prefix(self, brand_id) -> None: if self.fail_delete: raise BrandStorageError + async def brand_prefix_is_empty(self, brand_id) -> bool: + return not self.fail_delete + user_id: str | None = None brand_id = str(uuid4()) logo_path = f"brands/{brand_id}/logo.png" @@ -682,6 +700,9 @@ async def delete_logo(self, path: str) -> None: storage = ControllableStorage() app.dependency_overrides[get_brand_storage] = lambda: storage + app.dependency_overrides[get_brand_deletion] = lambda: BrandDeletion( + get_engine(), get_brand_store(), storage + ) with TestClient(app) as api_client: response = api_client.request( "DELETE", diff --git a/backend/tests/integration/test_brand_rls.py b/backend/tests/integration/test_brand_rls.py index f1afcb2..e9459e8 100644 --- a/backend/tests/integration/test_brand_rls.py +++ b/backend/tests/integration/test_brand_rls.py @@ -146,6 +146,7 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers(): ) assert owner_upload_response.status_code == 200 logo_url = owner_upload_response.json()["logo_url"] + assert f"brands/{brand_id}/logos/" in logo_url owner_response = api_client.get( f"/api/v1/brands/{brand_id}", @@ -215,12 +216,6 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers(): ) assert stored_logo.status_code == 200 assert stored_logo.content == PNG - alternate_logo_url = logo_url.removesuffix("logo.png") + "logo.jpg" - assert supabase_client.get( - alternate_logo_url, - params={"v": uuid4().hex}, - ).status_code in {400, 404} - engine = get_engine() assert _visible_brand_ids(engine, token_a, brand_id) == [brand_id] assert _visible_brand_ids(engine, token_b, brand_id) == [] diff --git a/backend/tests/integration/test_provider_key_cleanup.py b/backend/tests/integration/test_provider_key_cleanup.py index 645ea87..9c7aa02 100644 --- a/backend/tests/integration/test_provider_key_cleanup.py +++ b/backend/tests/integration/test_provider_key_cleanup.py @@ -1,12 +1,14 @@ from __future__ import annotations +import asyncio from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient from sqlalchemy import text -from sqlalchemy.exc import DBAPIError +from sqlalchemy.exc import DBAPIError, SQLAlchemyError from backend.tests.integration.test_provider_keys import ( MockProviderValidator, @@ -214,3 +216,417 @@ def test_database_rejects_multiple_active_invalid_and_cleanup_active_rows( text(f"UPDATE provider_keys SET {assignment} WHERE id = :key_id"), {"key_id": second_id}, ) + + +def test_individual_delete_removes_active_secret_retires_receipt_and_has_no_replacement( + provider_fixture, +): + from backend.app.main import app + + fixture = provider_fixture + with TestClient(app) as client: + active_id = _add_real_key( + client, + fixture, + SecretFixture(f"delete-active-{uuid4().hex}-A1B2"), + make_active=True, + ) + inactive_id = _add_real_key( + client, + fixture, + SecretFixture(f"delete-inactive-{uuid4().hex}-C3D4"), + make_active=False, + ) + deleted = client.delete( + f"/api/v1/brands/{fixture['brand_id']}/keys/{active_id}", + headers=fixture["headers"], + ) + + assert deleted.status_code == 204 + with fixture["engine"].connect() as connection: + assert connection.execute( + text("SELECT count(*) FROM provider_keys WHERE id = :id"), + {"id": active_id}, + ).scalar_one() == 0 + assert connection.execute( + text("SELECT is_active FROM provider_keys WHERE id = :id"), + {"id": inactive_id}, + ).scalar_one() is False + receipt = connection.execute( + text( + "SELECT state, provider_key_id FROM provider_key_idempotency " + "WHERE brand_id = :brand_id AND state = 'deleted'" + ), + {"brand_id": fixture["brand_id"]}, + ).one() + assert receipt == ("deleted", None) + + +def test_individual_delete_accepts_missing_secret_and_reconciles_cleanup_row( + provider_fixture, +): + from backend.app.main import app + + fixture = provider_fixture + with TestClient(app) as client: + key_id = _add_real_key( + client, + fixture, + SecretFixture(f"delete-missing-{uuid4().hex}-E5F6"), + make_active=True, + ) + with fixture["engine"].begin() as connection: + vault_id = connection.execute( + text("SELECT vault_secret_id FROM provider_keys WHERE id = :id"), + {"id": key_id}, + ).scalar_one() + connection.execute( + text("DELETE FROM vault.secrets WHERE id = :id"), {"id": vault_id} + ) + connection.execute( + text( + """ + UPDATE provider_keys + SET lifecycle = 'cleanup_required', is_active = false, + is_valid = NULL, last_validated_at = NULL, + last_validation_error = NULL, validation_token = NULL, + validation_lease_expires_at = NULL + WHERE id = :id + """ + ), + {"id": key_id}, + ) + deleted = client.delete( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}", + headers=fixture["headers"], + ) + repeated = client.delete( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}", + headers=fixture["headers"], + ) + + assert deleted.status_code == 204 + assert repeated.status_code == 404 + + +def test_individual_delete_gives_brand_cleanup_precedence_and_retains_key( + provider_fixture, +): + from backend.app.main import app + + fixture = provider_fixture + with TestClient(app) as client: + key_id = _add_real_key( + client, + fixture, + SecretFixture(f"delete-brand-fence-{uuid4().hex}-G7H8"), + make_active=True, + ) + with fixture["engine"].begin() as connection: + connection.execute( + text( + "UPDATE brands SET deletion_state = 'cleanup_required' " + "WHERE id = :brand_id" + ), + {"brand_id": fixture["brand_id"]}, + ) + response = client.delete( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}", + headers=fixture["headers"], + ) + + assert response.status_code == 409 + assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED" + with fixture["engine"].connect() as connection: + assert connection.execute( + text("SELECT count(*) FROM provider_keys WHERE id = :id"), + {"id": key_id}, + ).scalar_one() == 1 + + +def test_individual_vault_failure_keeps_fence_and_retry_finishes_cleanup( + provider_fixture, +): + from backend.app.main import app + from backend.app.routes.provider_keys import get_provider_key_store + from backend.app.services.provider_key_store import ProviderKeyStore + + class FailingConnection: + def __init__(self, connection): + self.connection = connection + + def execute(self, statement, parameters=None): + if "DELETE FROM vault.secrets" in str(statement): + raise SQLAlchemyError("injected vault failure") + return self.connection.execute(statement, parameters) + + class FailingCleanupEngine: + def __init__(self, engine): + self.engine = engine + self.transactions = 0 + + @contextmanager + def begin(self): + self.transactions += 1 + with self.engine.begin() as connection: + if self.transactions == 2: + yield FailingConnection(connection) + else: + yield connection + + fixture = provider_fixture + with TestClient(app) as client: + key_id = _add_real_key( + client, + fixture, + SecretFixture(f"delete-vault-retry-{uuid4().hex}-N3P4"), + make_active=True, + ) + failing_store = ProviderKeyStore(FailingCleanupEngine(fixture["engine"])) + app.dependency_overrides[get_provider_key_store] = lambda: failing_store + try: + failed = client.delete( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}", + headers=fixture["headers"], + ) + finally: + app.dependency_overrides.pop(get_provider_key_store, None) + + assert failed.status_code == 503 + assert failed.json()["error"]["code"] == "KEY_CLEANUP_REQUIRED" + with fixture["engine"].connect() as connection: + retained = connection.execute( + text( + """ + SELECT lifecycle, is_active, validation_token, + validation_lease_expires_at, + EXISTS ( + SELECT 1 FROM vault.secrets + WHERE id = provider_keys.vault_secret_id + ) AS secret_exists + FROM provider_keys WHERE id = :id + """ + ), + {"id": key_id}, + ).one() + assert retained == ("cleanup_required", False, None, None, True) + + activation = client.patch( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/activate", + headers=fixture["headers"], + ) + validation = client.post( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate", + headers=fixture["headers"], + ) + retried = client.delete( + f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}", + headers=fixture["headers"], + ) + + assert activation.status_code == validation.status_code == 409 + assert activation.json()["error"]["code"] == "KEY_CLEANUP_REQUIRED" + assert validation.json()["error"]["code"] == "KEY_CLEANUP_REQUIRED" + assert retried.status_code == 204 + + +def test_asset_operation_is_unique_unknown_non_expiring_and_stale_completion_is_blocked( + provider_fixture, +): + from backend.app.services.brand_store import ( + BrandAssetOperationStaleError, + BrandMutationInProgressError, + BrandStore, + ) + + fixture = provider_fixture + store = BrandStore(fixture["engine"]) + with fixture["engine"].connect() as connection: + user_id = connection.execute( + text("SELECT owner_user_id FROM brands WHERE id = :id"), + {"id": fixture["brand_id"]}, + ).scalar_one() + operation = store.begin_logo_upload(user_id, fixture["brand_id"], "png") + try: + with pytest.raises(BrandMutationInProgressError): + store.begin_logo_remove(user_id, fixture["brand_id"]) + with fixture["engine"].begin() as connection: + connection.execute( + text( + "UPDATE brand_asset_operations " + "SET started_at = clock_timestamp() - interval '1 hour' " + "WHERE id = :id" + ), + {"id": operation.id}, + ) + assert store.mark_abandoned_asset_operations_unknown(fixture["brand_id"]) == 1 + assert store.mark_abandoned_asset_operations_unknown(fixture["brand_id"]) == 0 + with fixture["engine"].connect() as connection: + state = connection.execute( + text( + "SELECT state, remote_status FROM brand_asset_operations " + "WHERE id = :id" + ), + {"id": operation.id}, + ).one() + assert state == ("cleanup_required", "unknown") + with pytest.raises(BrandAssetOperationStaleError): + store.complete_asset_operation(user_id, fixture["brand_id"], uuid4()) + finally: + with fixture["engine"].begin() as connection: + connection.execute( + text("DELETE FROM brand_asset_operations WHERE id = :id"), + {"id": operation.id}, + ) + + +def test_brand_hard_delete_removes_legacy_tokenized_storage_vault_and_all_rows( + provider_fixture, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.main import app + from backend.app.services.brand_storage import BrandStorage, get_brand_storage + + fixture = provider_fixture + storage = get_brand_storage() + monkeypatch.setattr(BrandStorage, "PAGE_SIZE", 1) + monkeypatch.setattr(BrandStorage, "DELETE_BATCH_SIZE", 1) + seeded_paths = ( + f"brands/{fixture['brand_id']}/logo.png", + f"brands/{fixture['brand_id']}/archive/legacy.webp", + f"brands/{fixture['brand_id']}/logos/orphan.jpg", + ) + for path in seeded_paths: + asyncio.run(storage.upload_logo(path, b"legacy", "image/png")) + with TestClient(app) as client: + _add_real_key( + client, + fixture, + SecretFixture(f"brand-delete-a-{uuid4().hex}-J9K0"), + make_active=True, + ) + _add_real_key( + client, + fixture, + SecretFixture(f"brand-delete-b-{uuid4().hex}-L1M2"), + make_active=False, + ) + logo = client.post( + f"/api/v1/brands/{fixture['brand_id']}/logo", + headers=fixture["headers"], + files={"file": ("logo.png", b"\x89PNG\r\n\x1a\nlogo", "image/png")}, + ) + assert logo.status_code == 200 + with fixture["engine"].connect() as connection: + secret_ids = connection.execute( + text( + "SELECT vault_secret_id FROM provider_keys " + "WHERE brand_id = :brand_id" + ), + {"brand_id": fixture["brand_id"]}, + ).scalars().all() + deleted = client.request( + "DELETE", + f"/api/v1/brands/{fixture['brand_id']}", + headers=fixture["headers"], + json={"confirm_name": "Provider Keys Integration"}, + ) + + assert deleted.status_code == 204 + with fixture["engine"].connect() as connection: + assert connection.execute( + text( + """ + SELECT + (SELECT count(*) FROM brands WHERE id = :brand_id) + + (SELECT count(*) FROM provider_keys WHERE brand_id = :brand_id) + + (SELECT count(*) FROM provider_key_idempotency WHERE brand_id = :brand_id) + + (SELECT count(*) FROM brand_asset_operations WHERE brand_id = :brand_id) + """ + ), + {"brand_id": fixture["brand_id"]}, + ).scalar_one() == 0 + assert connection.execute( + text("SELECT count(*) FROM vault.secrets WHERE id = ANY(:ids)"), + {"ids": secret_ids}, + ).scalar_one() == 0 + assert asyncio.run(storage.brand_prefix_is_empty(fixture["brand_id"])) + + +def test_brand_vault_failure_rolls_back_and_retry_completes(provider_fixture): + from backend.app.main import app + from backend.app.routes.brands import get_brand_deletion + from backend.app.services.brand_deletion import BrandDeletion + from backend.app.services.brand_storage import get_brand_storage + from backend.app.services.brand_store import BrandStore + + class FailingConnection: + def __init__(self, connection): + self.connection = connection + + def execute(self, statement, parameters=None): + if "DELETE FROM vault.secrets" in str(statement): + raise SQLAlchemyError("injected brand vault failure") + return self.connection.execute(statement, parameters) + + class FailingEngine: + def __init__(self, engine): + self.engine = engine + + @contextmanager + def begin(self): + with self.engine.begin() as connection: + yield FailingConnection(connection) + + fixture = provider_fixture + with TestClient(app) as client: + key_id = _add_real_key( + client, + fixture, + SecretFixture(f"brand-vault-retry-{uuid4().hex}-R5S6"), + make_active=True, + ) + failing_deletion = BrandDeletion( + FailingEngine(fixture["engine"]), + BrandStore(fixture["engine"]), + get_brand_storage(), + ) + app.dependency_overrides[get_brand_deletion] = lambda: failing_deletion + try: + failed = client.request( + "DELETE", + f"/api/v1/brands/{fixture['brand_id']}", + headers=fixture["headers"], + json={"confirm_name": "Provider Keys Integration"}, + ) + finally: + app.dependency_overrides.pop(get_brand_deletion, None) + + assert failed.status_code == 503 + assert failed.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED" + with fixture["engine"].connect() as connection: + retained = connection.execute( + text( + "SELECT b.deletion_state, pk.lifecycle, pk.is_active, " + "EXISTS (SELECT 1 FROM vault.secrets " + "WHERE id = pk.vault_secret_id) AS secret_exists " + "FROM brands b JOIN provider_keys pk ON pk.brand_id = b.id " + "WHERE b.id = :brand_id AND pk.id = :key_id" + ), + {"brand_id": fixture["brand_id"], "key_id": key_id}, + ).one() + assert retained == ("cleanup_required", "cleanup_required", False, True) + + retried = client.request( + "DELETE", + f"/api/v1/brands/{fixture['brand_id']}", + headers=fixture["headers"], + json={"confirm_name": "Provider Keys Integration"}, + ) + + assert retried.status_code == 204 + with fixture["engine"].connect() as connection: + assert connection.execute( + text("SELECT count(*) FROM brands WHERE id = :brand_id"), + {"brand_id": fixture["brand_id"]}, + ).scalar_one() == 0 diff --git a/backend/tests/integration/test_provider_key_rls.py b/backend/tests/integration/test_provider_key_rls.py index 0b6820b..7adb534 100644 --- a/backend/tests/integration/test_provider_key_rls.py +++ b/backend/tests/integration/test_provider_key_rls.py @@ -2,6 +2,7 @@ import json import os +from collections.abc import Container from types import SimpleNamespace from uuid import uuid4 @@ -20,6 +21,13 @@ ) +def _assert_not_exposed( + observable: Container[str], *prohibited_values: str +) -> None: + if any(value in observable for value in prohibited_values): + raise AssertionError("sensitive value was exposed") + + class SecurityFixture(dict): def __repr__(self) -> str: return "" @@ -797,12 +805,12 @@ def test_provider_key_api_owner_and_hidden_brand_parity(security_fixture): } assert owner_add.status_code == 201 assert owner_add.json()["key_hint"] == "***Q7_W" - assert raw_key not in owner_add.text + _assert_not_exposed(owner_add.text, raw_key) for hidden, missing in ((hidden_list, missing_list), (hidden_add, missing_add)): assert hidden.status_code == missing.status_code == 404 assert hidden.json()["error"]["code"] == missing.json()["error"]["code"] == "BRAND_NOT_FOUND" assert hidden.json()["error"]["message"] == missing.json()["error"]["message"] == "Brand not found." - assert raw_key not in hidden_add.text + missing_add.text + _assert_not_exposed(hidden_add.text + missing_add.text, raw_key) def test_provider_key_data_api_exposes_only_owner_safe_columns(security_fixture): diff --git a/backend/tests/integration/test_provider_keys.py b/backend/tests/integration/test_provider_keys.py index aa45a0c..91d37d6 100644 --- a/backend/tests/integration/test_provider_keys.py +++ b/backend/tests/integration/test_provider_keys.py @@ -4,6 +4,7 @@ import os import threading import time +from collections.abc import Container from concurrent.futures import ThreadPoolExecutor from types import SimpleNamespace from uuid import UUID, uuid4 @@ -19,6 +20,13 @@ ) +def _assert_not_exposed( + observable: Container[str], *prohibited_values: str +) -> None: + if any(value in observable for value in prohibited_values): + raise AssertionError("sensitive value was exposed") + + class SecretFixture(str): def __repr__(self) -> str: return "" @@ -279,8 +287,7 @@ def test_real_vault_add_list_activation_idempotency_and_retired_receipt( ("gemini", gemini.json()["id"]), ] serialized = "".join(response.text for response in (first, gemini, replacement, retry, listed)) - for raw_key in (openai_key, gemini_key, replacement_key): - assert raw_key not in serialized + _assert_not_exposed(serialized, openai_key, gemini_key, replacement_key) engine = fixture["engine"] with engine.connect() as connection: diff --git a/backend/tests/unit/test_brand_storage.py b/backend/tests/unit/test_brand_storage.py new file mode 100644 index 0000000..d216bc1 --- /dev/null +++ b/backend/tests/unit/test_brand_storage.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import asyncio +import json +from uuid import UUID + +import httpx +import pytest + +from backend.app.services.brand_storage import ( + BrandStorage, + BrandStorageUnknownError, +) + + +def test_upload_uses_token_owned_path_without_upsert(): + observed: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + observed["path"] = request.url.path + observed["upsert"] = request.headers["x-upsert"] + return httpx.Response(200) + + storage = BrandStorage( + "https://example.supabase.co", "secret", httpx.MockTransport(handler) + ) + brand_id = UUID("11111111-1111-1111-1111-111111111111") + operation_id = UUID("22222222-2222-2222-2222-222222222222") + path = storage.logo_path(brand_id, operation_id, "png") + + asyncio.run(storage.upload_logo(path, b"png", "image/png")) + + assert path == f"brands/{brand_id}/logos/{operation_id}.png" + assert observed == { + "path": f"/storage/v1/object/brand-assets/{path}", + "upsert": "false", + } + + +def test_upload_transport_failure_is_an_unknown_remote_outcome(): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("ambiguous", request=request) + + storage = BrandStorage( + "https://example.supabase.co", "secret", httpx.MockTransport(handler) + ) + + with pytest.raises(BrandStorageUnknownError): + asyncio.run( + storage.upload_logo( + "brands/brand/logos/token.png", b"png", "image/png" + ) + ) + + +def test_prefix_cleanup_restarts_at_zero_and_removes_nested_and_legacy_objects(): + brand_id = UUID("11111111-1111-1111-1111-111111111111") + prefix = f"brands/{brand_id}" + objects = { + f"{prefix}/logo.png", + f"{prefix}/logos/token.png", + f"{prefix}/nested/archive/legacy.webp", + } + list_offsets: list[tuple[str, int]] = [] + deleted_batches: list[list[str]] = [] + + def children(directory: str) -> list[dict[str, object]]: + direct_objects: set[str] = set() + direct_folders: set[str] = set() + marker = f"{directory}/" + for path in objects: + if not path.startswith(marker): + continue + remainder = path[len(marker) :] + first, separator, _ = remainder.partition("/") + if separator: + direct_folders.add(first) + else: + direct_objects.add(first) + return [ + {"name": name, "metadata": None} for name in sorted(direct_folders) + ] + [ + {"name": name, "metadata": {"size": 1}} + for name in sorted(direct_objects) + ] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/object/list/brand-assets"): + payload = json.loads(request.content) + directory = payload["prefix"] + offset = payload["offset"] + list_offsets.append((directory, offset)) + page = children(directory)[offset : offset + payload["limit"]] + return httpx.Response(200, json=page) + if request.method == "DELETE": + paths = json.loads(request.content)["prefixes"] + deleted_batches.append(paths) + objects.difference_update(paths) + return httpx.Response(200, json=[]) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + storage = BrandStorage( + "https://example.supabase.co", "secret", httpx.MockTransport(handler) + ) + asyncio.run(storage.delete_brand_prefix(brand_id)) + + assert objects == set() + assert {path for batch in deleted_batches for path in batch} == { + f"{prefix}/logo.png", + f"{prefix}/logos/token.png", + f"{prefix}/nested/archive/legacy.webp", + } + assert list_offsets.count((prefix, 0)) >= 3 diff --git a/backend/tests/unit/test_provider_validation.py b/backend/tests/unit/test_provider_validation.py index edc6bb3..02ca18b 100644 --- a/backend/tests/unit/test_provider_validation.py +++ b/backend/tests/unit/test_provider_validation.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +import hashlib +import hmac import logging -from collections.abc import Callable +from collections.abc import Callable, Container import httpx import pytest @@ -17,6 +19,13 @@ RAW_KEY = "unit-test-provider-secret-A1B2" +def _assert_not_exposed( + observable: Container[str], *prohibited_values: str +) -> None: + if any(value in observable for value in prohibited_values): + raise AssertionError("sensitive value was exposed") + + def _validate( provider: str, handler: Callable[[httpx.Request], httpx.Response], @@ -92,12 +101,13 @@ def handler(request: httpx.Request) -> httpx.Response: request = requests[0] assert request.method == "GET" assert str(request.url) == expected_url - assert request.headers[auth_header[0]] == auth_header[1] + assert hmac.compare_digest(request.headers[auth_header[0]], auth_header[1]) assert ("x-goog-api-key" in request.headers) is (provider == "gemini") assert ("authorization" in request.headers) is (provider == "openai") - assert "openai-organization" not in request.headers - assert "openai-project" not in request.headers - assert RAW_KEY not in str(request.url) + _assert_not_exposed( + request.headers, "openai-organization", "openai-project" + ) + _assert_not_exposed(str(request.url), RAW_KEY) _assert_result( result, "valid", @@ -452,7 +462,7 @@ def test_extracts_provider_request_id_only_from_response_header(provider: str): "valid", "VALID", f"{'OpenAI' if provider == 'openai' else 'Gemini'} accepted this API key.", - "provider-request-123", + f"sha256:{hashlib.sha256(b'provider-request-123').hexdigest()[:16]}", ) @@ -467,7 +477,7 @@ def test_provider_request_id_cannot_echo_the_raw_key(): ) assert result.provider_request_id is None - assert RAW_KEY not in repr(result) + _assert_not_exposed(repr(result), RAW_KEY) @pytest.mark.parametrize("provider", ["openai", "gemini"]) @@ -498,7 +508,12 @@ def handler(request: httpx.Request) -> httpx.Response: rendered_logs = "\n".join(caplog.messages) rendered_result = repr(result) - for secret in (RAW_KEY, authorization_value, provider_body_secret): - assert secret not in rendered_logs - assert secret not in rendered_result - assert result.provider_request_id == "safe-request-id" + _assert_not_exposed( + rendered_logs, RAW_KEY, authorization_value, provider_body_secret + ) + _assert_not_exposed( + rendered_result, RAW_KEY, authorization_value, provider_body_secret + ) + assert result.provider_request_id == ( + f"sha256:{hashlib.sha256(b'safe-request-id').hexdigest()[:16]}" + ) diff --git a/docs/docker.md b/docs/docker.md index 955c9d8..dd009b2 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -7,7 +7,7 @@ container. Run every local command from the repository root. - Docker with the BuildKit builder running. - A reachable Supabase project and its project URL, publishable key, secret key, JWT - secret, and PostgreSQL connection string. + secret, and a private backend PostgreSQL connection as described below. - For Bunny deployment, an image repository in Docker Hub or GitHub Container Registry and access to the [bunny.net dashboard](https://dash.bunny.net/). @@ -43,7 +43,7 @@ Git and Docker; do not commit it. SUPABASE_URL=https://your-project.supabase.co SUPABASE_SECRET_KEY=replace-with-secret-key SUPABASE_JWT_SECRET=replace-with-jwt-secret -DATABASE_URL=postgresql://user:password@host:5432/database +DATABASE_URL=replace-with-private-backend-connection-string NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=replace-with-publishable-key NEXT_PUBLIC_API_URL=/api @@ -97,7 +97,7 @@ credentials remain server-side. | `SUPABASE_URL` | Backend | Supabase API URL reachable from inside the container | | `SUPABASE_SECRET_KEY` | Backend | Supabase secret/service-role credential; keep secret | | `SUPABASE_JWT_SECRET` | Backend | JWT verification secret; keep secret | -| `DATABASE_URL` | Backend | PostgreSQL connection string reachable from inside the container; keep credentials secret | +| `DATABASE_URL` | Backend | Private, non-client PostgreSQL login reachable from the container; keep the entire value secret | | `NEXT_PUBLIC_SUPABASE_URL` | Browser | Supabase API URL reachable from each user's browser | | `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | Browser | Supabase publishable key; public by design | | `NEXT_PUBLIC_API_URL` | Browser | `/api` for same-origin backend access through Next.js | @@ -109,6 +109,50 @@ such as `http://host.docker.internal:54321`, while the browser needs `http://localhost:54321`. On Linux, add `--add-host=host.docker.internal:host-gateway` to `docker run` when using that hostname. +### Hosted database role + +`DATABASE_URL` is used directly by SQLAlchemy; `SUPABASE_SECRET_KEY` does not grant its +database privileges. Before deployment, have a database administrator create a private +login that is not `anon`, `authenticated`, or `service_role`. The hosted login must be a +non-superuser with `BYPASSRLS`, because table ownership does not bypass forced RLS. The +local `postgres` superuser is accepted only for a loopback local-development database. + +The login requires `SELECT`, `INSERT`, `UPDATE`, and `DELETE` on `brands`, +`provider_keys`, `provider_key_idempotency`, and `brand_asset_operations`. Its Vault +capabilities must be exactly those used by the backend: + +- `USAGE` on schema `vault`. +- `EXECUTE` on `vault.create_secret(text,text,text,uuid)`. +- Column `SELECT` on `id` and `decrypted_secret` in `vault.decrypted_secrets`. +- Column `SELECT` on `id`, plus `DELETE`, on `vault.secrets`. +- No Vault schema `CREATE`, `vault.update_secret(...)` execution, or table-wide Vault + `SELECT`. + +Apply the provider-key migration before starting the container. Every backend startup +checks the connected role's identity, forced-RLS bypass, application DML, and Vault +least-privilege capabilities; a mismatch stops the backend before it serves requests. +Do not place `DATABASE_URL` in a `NEXT_PUBLIC_*` variable, browser configuration, image, +log, ticket, or command output. + +### Provider validation egress + +Allow direct outbound HTTPS (TCP 443, including DNS and TLS) only as needed for these +official read-only model-list requests: + +- OpenAI: `GET https://api.openai.com/v1/models`. +- Gemini: `GET https://generativelanguage.googleapis.com/v1beta/models?pageSize=1`. + +Validation does not follow redirects, use provider SDKs, or generate content. If an +egress policy filters by hostname, allow `api.openai.com` and +`generativelanguage.googleapis.com`; do not add alternate or redirect hosts. + +For a real validation check, create disposable keys in dedicated provider projects with +only the access needed to list models. Enter them through the PostForge UI, never through +source files, environment files, shell arguments/history, screenshots, fixtures, logs, +or tickets. Revoke them at the provider immediately after the check and verify they do +not appear in browser responses or application logs. A provider outage or temporary +validation result blocks the check; it is not a passing result. + Rotate a leaked credential at its source, update `.env.docker` or the hosting platform's runtime variables, and replace the running container. Rebuilding the image is neither required nor an acceptable way to distribute secrets. @@ -202,6 +246,16 @@ then replace the container. Do not print secret values into shared logs or ticke both local probes pass but sign-in fails, check Supabase and database connectivity separately; external services are deliberately outside the container healthcheck. +If logs report `database role lacks required backend privileges`, do not bypass the +startup check or substitute a client/service role. Confirm `DATABASE_URL` reaches the +intended database as the intended private login, migration `00016` is applied, and the +role has every forced-RLS, application, and Vault capability in **Hosted database role** +without broader Vault access. Have a database administrator correct the role, rotate the +connection credential if it was exposed while diagnosing, and replace the container. +Provider validation failures with an otherwise healthy app should be checked against the +two required outbound hosts; never log request headers, keys, or provider response bodies +while testing egress. + ### Port 3000 is already allocated Stop the conflicting container shown by `docker ps`, or use a different host port while diff --git a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx index 942831a..b04f0bd 100644 --- a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx +++ b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx @@ -137,6 +137,7 @@ export default function ProviderKeysPage() { const retryIdRef = useRef(null); const validatingKeyIdsRef = useRef(new Set()); const activatingKeyIdsRef = useRef(new Set()); + const deletingKeyIdsRef = useRef(new Set()); const [brand, setBrand] = useState(null); const [keys, setKeys] = useState([]); const [provider, setProvider] = useState("openai"); @@ -153,6 +154,13 @@ export default function ProviderKeysPage() { const [activatingKeyIds, setActivatingKeyIds] = useState>( () => new Set() ); + const [deletingKeyIds, setDeletingKeyIds] = useState>( + () => new Set() + ); + const [deleteFeedbackByKey, setDeleteFeedbackByKey] = useState< + Record + >({}); + const [deleteNotice, setDeleteNotice] = useState(null); const [validationFeedbackByKey, setValidationFeedbackByKey] = useState< Record >({}); @@ -442,6 +450,105 @@ export default function ProviderKeysPage() { } } + async function deleteKey(keyId: string) { + if (deletingKeyIdsRef.current.has(keyId)) return; + + deletingKeyIdsRef.current.add(keyId); + setDeletingKeyIds((current) => new Set(current).add(keyId)); + setDeleteNotice(null); + setDeleteFeedbackByKey((current) => { + const next = { ...current }; + delete next[keyId]; + return next; + }); + + try { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session) { + router.push("/login"); + return; + } + + const keysPath = `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/keys`; + let response: Response | null = null; + try { + response = await fetch(`${keysPath}/${encodeURIComponent(keyId)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${session.access_token}` }, + }); + } catch { + // A lost response can hide a committed deletion, so prove absence from a fresh list. + } + + if (response?.status === 204) { + setKeys((current) => current.filter((key) => key.id !== keyId)); + setDeleteNotice("Provider key deleted. No replacement key was activated."); + return; + } + + if (response === null || response.status === 404 || response.status >= 500) { + try { + const listResponse = await fetch(keysPath, { + headers: { Authorization: `Bearer ${session.access_token}` }, + }); + if (!listResponse.ok) throw new Error("Unable to reconcile provider keys."); + + const body = (await listResponse.json()) as { keys: ProviderKey[] }; + setKeys(body.keys); + if (!body.keys.some((key) => key.id === keyId)) { + setDeleteNotice("Provider key deletion was confirmed from the refreshed list. No replacement key was activated."); + return; + } + + setDeleteFeedbackByKey((current) => ({ + ...current, + [keyId]: { + message: "Key cleanup did not complete. Retry deletion.", + tone: "error", + }, + })); + } catch { + setDeleteFeedbackByKey((current) => ({ + ...current, + [keyId]: { + message: "The deletion outcome is unknown. Refresh or retry deletion.", + tone: "error", + }, + })); + } + return; + } + + const body = (await response.json().catch(() => null)) as ErrorResponse | null; + setDeleteFeedbackByKey((current) => ({ + ...current, + [keyId]: { + message: + body?.error?.code === "BRAND_CLEANUP_REQUIRED" + ? "Brand cleanup owns this deletion. Retry deletion from the brand page." + : "Unable to delete this key.", + tone: "error", + }, + })); + } catch { + setDeleteFeedbackByKey((current) => ({ + ...current, + [keyId]: { + message: "The deletion outcome is unknown. Refresh or retry deletion.", + tone: "error", + }, + })); + } finally { + deletingKeyIdsRef.current.delete(keyId); + setDeletingKeyIds((current) => { + const next = new Set(current); + next.delete(keyId); + return next; + }); + } + } + if (isLoading) { return

Loading provider keys...

; } @@ -467,6 +574,10 @@ export default function ProviderKeysPage() { const providerName = PROVIDERS.find((item) => item.id === provider)?.name ?? provider; const providerKeys = keys.filter((key) => key.provider === provider); const cleanupRequired = brand.cleanup_state === "cleanup_required"; + const addBlockedByCleanup = + cleanupRequired || + deletingKeyIds.size > 0 || + keys.some((key) => key.cleanup_state === "cleanup_required"); return (
@@ -512,6 +623,9 @@ export default function ProviderKeysPage() {
+ {deleteNotice ? ( +

{deleteNotice}

+ ) : null}

{providerName} keys

{providerKeys.length} saved @@ -524,14 +638,19 @@ export default function ProviderKeysPage() { providerKeys.map((key) => { const isValidating = validatingKeyIds.has(key.id); const isActivating = activatingKeyIds.has(key.id); + const isDeleting = deletingKeyIds.has(key.id); const feedback = - activationFeedbackByKey[key.id] ?? validationFeedbackByKey[key.id]; + deleteFeedbackByKey[key.id] ?? + activationFeedbackByKey[key.id] ?? + validationFeedbackByKey[key.id]; const validationDisabled = isValidating || + isDeleting || cleanupRequired || key.cleanup_state === "cleanup_required"; const activationDisabled = isActivating || + isDeleting || cleanupRequired || key.cleanup_state === "cleanup_required" || key.is_valid === false; @@ -585,6 +704,23 @@ export default function ProviderKeysPage() { > {isValidating ? "Validating..." : "Validate key"} +
{feedback ? ( @@ -619,7 +755,7 @@ export default function ProviderKeysPage() { type="text" maxLength={100} value={label} - disabled={isAdding || cleanupRequired} + disabled={isAdding || addBlockedByCleanup} onChange={(event) => { setLabel(event.target.value); beginNewAttempt(); @@ -633,7 +769,7 @@ export default function ProviderKeysPage() { className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:border-black" type="password" autoComplete="new-password" - disabled={isAdding || cleanupRequired} + disabled={isAdding || addBlockedByCleanup} onChange={beginNewAttempt} /> @@ -642,7 +778,7 @@ export default function ProviderKeysPage() { className="mt-1" type="checkbox" checked={makeActive} - disabled={isAdding || cleanupRequired} + disabled={isAdding || addBlockedByCleanup} onChange={(event) => { setMakeActive(event.target.checked); beginNewAttempt(); @@ -654,7 +790,7 @@ export default function ProviderKeysPage() { diff --git a/frontend/app/(dashboard)/brands/[brandId]/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/page.tsx index f70572e..3f11ead 100644 --- a/frontend/app/(dashboard)/brands/[brandId]/page.tsx +++ b/frontend/app/(dashboard)/brands/[brandId]/page.tsx @@ -223,18 +223,46 @@ export default function BrandDetailPage() { return; } - const response = await fetch( - `${apiBase}/v1/brands/${encodeURIComponent(brandId)}`, - { + const brandPath = `${apiBase}/v1/brands/${encodeURIComponent(brandId)}`; + let response: Response | null = null; + try { + response = await fetch(brandPath, { method: "DELETE", headers: { Authorization: `Bearer ${session.access_token}`, "Content-Type": "application/json", }, body: JSON.stringify({ confirm_name: confirmationName }), + }); + } catch { + // Reconcile below before deciding whether an ambiguous deletion completed. + } + + if (response?.status !== 204) { + if (response === null || response.status === 404 || response.status >= 500) { + try { + const listResponse = await fetch(`${apiBase}/v1/brands`, { + headers: { Authorization: `Bearer ${session.access_token}` }, + }); + if (!listResponse.ok) throw new Error("Unable to reconcile brands."); + + const body = (await listResponse.json()) as { brands: Brand[] }; + const retainedBrand = body.brands.find((item) => item.id === brandId); + if (!retainedBrand) { + window.dispatchEvent(new Event("postforge:brands-changed")); + router.push("/brands"); + router.refresh(); + return; + } + + setBrand(retainedBrand); + setDeleteError("Brand cleanup did not complete. Retry deletion."); + } catch { + setDeleteError("The deletion outcome is unknown. Refresh or retry deletion."); + } + return; } - ); - if (!response.ok) { + const body = await response.json().catch(() => null); setDeleteError(body?.error?.message ?? "Unable to delete the brand."); return; @@ -317,9 +345,9 @@ export default function BrandDetailPage() {

Configure brand-scoped OpenAI and Gemini credentials without exposing them after submission.

- {cleanupRequired ? ( + {cleanupRequired || isDeleting ? (

- Provider setup is unavailable until brand cleanup completes. + Provider setup is unavailable while brand deletion is in progress.

) : (

Delete brand

- This permanently deletes the brand and its logo. This action cannot be - undone. + This permanently deletes the brand, provider keys, and all stored assets. + This action cannot be undone.

@@ -430,7 +458,11 @@ export default function BrandDetailPage() { isRemoving } > - {isDeleting ? "Deleting..." : "Delete brand permanently"} + {isDeleting + ? "Deleting..." + : cleanupRequired + ? "Retry brand deletion" + : "Delete brand permanently"}
diff --git a/frontend/app/(dashboard)/brands/page.tsx b/frontend/app/(dashboard)/brands/page.tsx index 9808beb..6168285 100644 --- a/frontend/app/(dashboard)/brands/page.tsx +++ b/frontend/app/(dashboard)/brands/page.tsx @@ -11,6 +11,7 @@ type Brand = { id: string; name: string; logo_url: string | null; + cleanup_state: "normal" | "cleanup_required"; created_at: string; }; @@ -121,9 +122,16 @@ export default function BrandsPage() {
{brand.name.slice(0, 2).toUpperCase()}
-

- {brand.name} -

+
+

+ {brand.name} +

+ {brand.cleanup_state === "cleanup_required" ? ( + + Cleanup required + + ) : null} +

Created {new Date(brand.created_at).toLocaleDateString()}

diff --git a/frontend/tests/e2e/provider-keys.spec.ts b/frontend/tests/e2e/provider-keys.spec.ts index e5dac2d..593736e 100644 --- a/frontend/tests/e2e/provider-keys.spec.ts +++ b/frontend/tests/e2e/provider-keys.spec.ts @@ -350,3 +350,216 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => expect(responseWasOpaque).toBeTruthy(); expect(rawKeyIsVisible).toBeFalsy(); }); + +test("reconciles key and brand deletion outcomes without activating replacements", async ({ + page, +}) => { + const brandId = crypto.randomUUID(); + const activeKeyId = crypto.randomUUID(); + const inactiveKeyId = crypto.randomUUID(); + const retryKeyId = crypto.randomUUID(); + let brandExists = true; + let brandCleanupState: "normal" | "cleanup_required" = "normal"; + let retryKeyAttempts = 0; + let brandDeleteAttempts = 0; + const savedKeys: SafeKey[] = [ + { + id: activeKeyId, + provider: "openai", + label: "Active key", + key_hint: "***1111", + is_active: true, + is_valid: true, + last_validated_at: "2026-07-28T10:00:00Z", + last_validation_error: null, + cleanup_state: "normal", + created_at: "2026-07-28T09:00:00Z", + }, + { + id: inactiveKeyId, + provider: "openai", + label: "Inactive key", + key_hint: "***2222", + is_active: false, + is_valid: null, + last_validated_at: null, + last_validation_error: null, + cleanup_state: "normal", + created_at: "2026-07-28T08:00:00Z", + }, + { + id: retryKeyId, + provider: "openai", + label: "Cleanup key", + key_hint: "***3333", + is_active: false, + is_valid: null, + last_validated_at: null, + last_validation_error: null, + cleanup_state: "normal", + created_at: "2026-07-28T07:00:00Z", + }, + ]; + + const brandBody = () => ({ + id: brandId, + name: "Deletion Test Brand", + logo_url: null, + cleanup_state: brandCleanupState, + created_at: "2026-07-28T06:00:00Z", + }); + + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + + if (url.pathname === "/api/v1/brands" && request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ brands: brandExists ? [brandBody()] : [] }), + }); + return; + } + + if (url.pathname === `/api/v1/brands/${brandId}` && request.method() === "GET") { + await route.fulfill({ + status: brandExists ? 200 : 404, + contentType: "application/json", + body: JSON.stringify( + brandExists + ? brandBody() + : { error: { code: "BRAND_NOT_FOUND", message: "Brand not found." } } + ), + }); + return; + } + + if (url.pathname === `/api/v1/brands/${brandId}/keys` && request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ keys: savedKeys }), + }); + return; + } + + const keyDeleteMatch = url.pathname.match( + new RegExp(`^/api/v1/brands/${brandId}/keys/([^/]+)$`) + ); + if (keyDeleteMatch && request.method() === "DELETE") { + const keyId = keyDeleteMatch[1]; + const keyIndex = savedKeys.findIndex((key) => key.id === keyId); + if (keyId === activeKeyId) { + savedKeys.splice(keyIndex, 1); + await route.fulfill({ status: 204 }); + return; + } + if (keyId === inactiveKeyId) { + savedKeys.splice(keyIndex, 1); + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: { code: "PROVIDER_KEY_NOT_FOUND" } }), + }); + return; + } + if (keyId === retryKeyId && retryKeyAttempts++ === 0) { + savedKeys[keyIndex] = { + ...savedKeys[keyIndex], + is_active: false, + is_valid: null, + last_validated_at: null, + cleanup_state: "cleanup_required", + }; + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: { code: "KEY_CLEANUP_REQUIRED" } }), + }); + return; + } + savedKeys.splice(keyIndex, 1); + await route.fulfill({ status: 204 }); + return; + } + + if (url.pathname === `/api/v1/brands/${brandId}` && request.method() === "DELETE") { + const payload = request.postDataJSON() as { confirm_name: string }; + expect(payload.confirm_name).toBe("Deletion Test Brand"); + brandDeleteAttempts += 1; + if (brandDeleteAttempts === 1) { + brandCleanupState = "cleanup_required"; + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: { code: "BRAND_CLEANUP_REQUIRED" } }), + }); + return; + } + + brandExists = false; + await route.abort("connectionreset"); + return; + } + + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: { code: "NOT_FOUND" } }), + }); + }); + + await page.goto("/signup"); + await page.getByLabel("Email").fill(`delete-flows-${Date.now()}@example.com`); + await page.getByLabel("Password").fill("password123"); + await page.getByRole("button", { name: "Sign up" }).click(); + await expect(page.getByText(/Account created/i)).toBeVisible(); + + await page.goto(`/brands/${brandId}/keys`); + const activeKey = page.getByRole("article").filter({ + has: page.getByRole("heading", { name: "Active key", exact: true }), + }); + const inactiveKey = page.getByRole("article").filter({ + has: page.getByRole("heading", { name: "Inactive key", exact: true }), + }); + const cleanupKey = page.getByRole("article").filter({ + has: page.getByRole("heading", { name: "Cleanup key", exact: true }), + }); + + await activeKey.getByRole("button", { name: "Delete key" }).click(); + await expect(activeKey).toHaveCount(0); + await expect(inactiveKey.getByText("Inactive", { exact: true })).toBeVisible(); + await expect(inactiveKey.getByRole("button", { name: "Activate key" })).toBeVisible(); + + await inactiveKey.getByRole("button", { name: "Delete key" }).click(); + await expect(inactiveKey).toHaveCount(0); + + await cleanupKey.getByRole("button", { name: "Delete key" }).click(); + await expect(cleanupKey.getByText("Cleanup required", { exact: true })).toBeVisible(); + await expect(cleanupKey.getByRole("button", { name: "Activate key" })).toBeDisabled(); + await expect(cleanupKey.getByRole("button", { name: "Validate key" })).toBeDisabled(); + await expect(page.getByLabel("API key")).toBeDisabled(); + await cleanupKey.getByRole("button", { name: "Retry deletion" }).click(); + await expect(cleanupKey).toHaveCount(0); + + await page.goto(`/brands/${brandId}`); + const confirmation = page.getByLabel(/Type Deletion Test Brand to confirm/); + await confirmation.fill("Deletion Test Brand"); + await page.getByRole("button", { name: "Delete brand permanently" }).click(); + await expect(page.getByText("Cleanup required", { exact: true })).toBeVisible(); + await expect(confirmation).toHaveValue("Deletion Test Brand"); + await expect(page.getByRole("button", { name: "Retry brand deletion" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Manage provider keys" })).toHaveCount(0); + await expect(page.locator('input[type="file"]')).toBeDisabled(); + + await page.goto("/brands"); + const cleanupBrand = page.getByRole("link", { name: /Deletion Test Brand/ }); + await expect(cleanupBrand.getByText("Cleanup required", { exact: true })).toBeVisible(); + await cleanupBrand.click(); + await page.getByLabel(/Type Deletion Test Brand to confirm/).fill("Deletion Test Brand"); + await page.getByRole("button", { name: "Retry brand deletion" }).click(); + await expect(page).toHaveURL(/\/brands$/); + await expect(page.getByText("Deletion Test Brand")).toHaveCount(0); + expect(brandDeleteAttempts).toBe(2); +}); diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md index ef7447d..e5ae667 100644 --- a/specs/004-provider-keys/tasks.md +++ b/specs/004-provider-keys/tasks.md @@ -165,25 +165,25 @@ success must leave no row, secret, receipt, operation, or object under the brand ### Tests for User Story 4 -- [ ] T041 [P] [US4] Extend `backend/tests/contract/test_provider_keys.py` with failing individual-delete tests for active/inactive/cleanup-required keys, 204 only after secret and row absence, retired idempotency receipt, absent-secret idempotency, opaque ownership, brand-cleanup precedence, and fixed 503 `KEY_CLEANUP_REQUIRED` retention -- [ ] T042 [P] [US4] Extend `backend/tests/contract/test_brands.py` with failing cleanup-aware logo and brand-delete tests for durable mutation conflicts, exact confirmation, `BRAND_CLEANUP_REQUIRED`, retry behavior, visible cleanup state, and prohibition of 204 while Storage/Vault/asset-operation cleanup is unresolved -- [ ] T043 [P] [US4] Extend `backend/tests/integration/test_provider_key_cleanup.py` with failing individual cleanup tests for durable inactive fencing before Vault deletion, activation/validation rejection while fenced, Vault rollback, known and ambiguous commit failures, already-absent secret success, receipt retirement, repeated DELETE reconciliation, and no automatic replacement for a deleted active key -- [ ] T044 [P] [US4] Add failing brand hard-delete and asset-operation integration cases to `backend/tests/integration/test_provider_key_cleanup.py` for upload/delete overlap, unique operation ownership, abandoned pending-to-unknown reconciliation, non-expiring unknown outcomes, stale completion, replacement old-object failure, legacy plus tokenized objects, multi-page prefix cleanup, Storage/Vault failure retry, mutation rejection after brand fencing, empty-brand deletion, and final physical absence of all assets/secrets/rows +- [X] T041 [P] [US4] Extend `backend/tests/contract/test_provider_keys.py` with failing individual-delete tests for active/inactive/cleanup-required keys, 204 only after secret and row absence, retired idempotency receipt, absent-secret idempotency, opaque ownership, brand-cleanup precedence, and fixed 503 `KEY_CLEANUP_REQUIRED` retention +- [X] T042 [P] [US4] Extend `backend/tests/contract/test_brands.py` with failing cleanup-aware logo and brand-delete tests for durable mutation conflicts, exact confirmation, `BRAND_CLEANUP_REQUIRED`, retry behavior, visible cleanup state, and prohibition of 204 while Storage/Vault/asset-operation cleanup is unresolved +- [X] T043 [P] [US4] Extend `backend/tests/integration/test_provider_key_cleanup.py` with failing individual cleanup tests for durable inactive fencing before Vault deletion, activation/validation rejection while fenced, Vault rollback, known and ambiguous commit failures, already-absent secret success, receipt retirement, repeated DELETE reconciliation, and no automatic replacement for a deleted active key +- [X] T044 [P] [US4] Add failing brand hard-delete and asset-operation integration cases to `backend/tests/integration/test_provider_key_cleanup.py` for upload/delete overlap, unique operation ownership, abandoned pending-to-unknown reconciliation, non-expiring unknown outcomes, stale completion, replacement old-object failure, legacy plus tokenized objects, multi-page prefix cleanup, Storage/Vault failure retry, mutation rejection after brand fencing, empty-brand deletion, and final physical absence of all assets/secrets/rows ### Implementation for User Story 4 -- [ ] T045 [US4] Extend `backend/app/services/provider_key_store.py` with individual deletion that first commits irreversible cleanup-required/inactive state and clears validation fields/lease, then atomically treats an absent Vault row as clean, deletes the secret and key row, retires idempotency receipts, and retains a safe retry anchor on known or ambiguous cleanup failure -- [ ] T046 [US4] Add the individual DELETE handler to `backend/app/routes/provider_keys.py` with whole-brand-cleanup precedence, fixed 204/404/409/503 behavior, safe retry logs, and no replacement activation -- [ ] T047 [US4] Refactor `backend/app/services/brand_storage.py` to create token-owned `brands/{brand_id}/logos/{operation_id}.{ext}` objects, paginate/list and batch-delete complete prefixes from offset zero, verify final absence, distinguish definitive from unknown remote outcomes, and never clear unknown state from elapsed time or temporary absence -- [ ] T048 [US4] Extend `backend/app/services/brand_store.py` with durable `brand_asset_operations` create/complete/fail/reconcile primitives, one-operation-per-brand enforcement, recorded current/previous paths, stale-token-safe logo updates/removals, abandoned pending-to-unknown fencing, brand deletion fencing, and irreversible transition of all provider keys to cleanup-required/inactive -- [ ] T049 [US4] Create `backend/app/services/brand_deletion.py` to coordinate exact-confirmation ownership lookup, zero-asset-operation precondition, committed brand/key fence, external exhaustive Storage-prefix cleanup without a DB connection, one-transaction Vault/key/receipt cleanup with absent-secret success, final empty-prefix and row verification, and physical brand deletion only after every dependency is absent -- [ ] T050 [US4] Refactor logo upload/removal and confirmed brand deletion in `backend/app/routes/brands.py` to use durable asset operations and `BrandDeletion`, preserve ownership-before-lifecycle ordering, stop treating Storage failure as best effort, and return fixed retryable cleanup/mutation errors without reporting false success -- [ ] T051 [US4] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with explicit individual deletion, cleanup-required rendering, normal-action disabling, retry DELETE, ambiguous-response relisting, and removal from UI only after confirmed absence -- [ ] T052 [US4] Extend `frontend/app/(dashboard)/brands/[brandId]/page.tsx` with visible brand cleanup state, disabled normal/logo/key navigation mutations, retained exact-name retry deletion, and ambiguous-delete reconciliation before redirect -- [ ] T053 [P] [US4] Extend `frontend/app/(dashboard)/brands/page.tsx` to parse and display `cleanup_state`, keep cleanup-required brands navigable as retry anchors, and distinguish them from normal brands without treating them as deleted -- [ ] T054 [US4] Update `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` for tokenized logo paths, no direct authenticated brand writes, hard-delete-only user cleanup, exact owner/non-owner mutation parity, and regression coverage that successful deletion removes the complete Storage prefix -- [ ] T055 [US4] Extend `frontend/tests/e2e/provider-keys.spec.ts` with active/inactive delete, injected cleanup-required retry, disabled normal actions, brand cleanup-state retry, and no automatic replacement assertions -- [ ] T056 [US4] Execute Quickstart Scenarios 4-5 from `specs/004-provider-keys/quickstart.md` and run `backend/tests/contract/test_brands.py`, `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_brand_crud.py`, `backend/tests/integration/test_brand_rls.py`, `backend/tests/integration/test_provider_key_cleanup.py`, and `frontend/tests/e2e/provider-keys.spec.ts` +- [X] T045 [US4] Extend `backend/app/services/provider_key_store.py` with individual deletion that first commits irreversible cleanup-required/inactive state and clears validation fields/lease, then atomically treats an absent Vault row as clean, deletes the secret and key row, retires idempotency receipts, and retains a safe retry anchor on known or ambiguous cleanup failure +- [X] T046 [US4] Add the individual DELETE handler to `backend/app/routes/provider_keys.py` with whole-brand-cleanup precedence, fixed 204/404/409/503 behavior, safe retry logs, and no replacement activation +- [X] T047 [US4] Refactor `backend/app/services/brand_storage.py` to create token-owned `brands/{brand_id}/logos/{operation_id}.{ext}` objects, paginate/list and batch-delete complete prefixes from offset zero, verify final absence, distinguish definitive from unknown remote outcomes, and never clear unknown state from elapsed time or temporary absence +- [X] T048 [US4] Extend `backend/app/services/brand_store.py` with durable `brand_asset_operations` create/complete/fail/reconcile primitives, one-operation-per-brand enforcement, recorded current/previous paths, stale-token-safe logo updates/removals, abandoned pending-to-unknown fencing, brand deletion fencing, and irreversible transition of all provider keys to cleanup-required/inactive +- [X] T049 [US4] Create `backend/app/services/brand_deletion.py` to coordinate exact-confirmation ownership lookup, zero-asset-operation precondition, committed brand/key fence, external exhaustive Storage-prefix cleanup without a DB connection, one-transaction Vault/key/receipt cleanup with absent-secret success, final empty-prefix and row verification, and physical brand deletion only after every dependency is absent +- [X] T050 [US4] Refactor logo upload/removal and confirmed brand deletion in `backend/app/routes/brands.py` to use durable asset operations and `BrandDeletion`, preserve ownership-before-lifecycle ordering, stop treating Storage failure as best effort, and return fixed retryable cleanup/mutation errors without reporting false success +- [X] T051 [US4] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with explicit individual deletion, cleanup-required rendering, normal-action disabling, retry DELETE, ambiguous-response relisting, and removal from UI only after confirmed absence +- [X] T052 [US4] Extend `frontend/app/(dashboard)/brands/[brandId]/page.tsx` with visible brand cleanup state, disabled normal/logo/key navigation mutations, retained exact-name retry deletion, and ambiguous-delete reconciliation before redirect +- [X] T053 [P] [US4] Extend `frontend/app/(dashboard)/brands/page.tsx` to parse and display `cleanup_state`, keep cleanup-required brands navigable as retry anchors, and distinguish them from normal brands without treating them as deleted +- [X] T054 [US4] Update `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` for tokenized logo paths, no direct authenticated brand writes, hard-delete-only user cleanup, exact owner/non-owner mutation parity, and regression coverage that successful deletion removes the complete Storage prefix +- [X] T055 [US4] Extend `frontend/tests/e2e/provider-keys.spec.ts` with active/inactive delete, injected cleanup-required retry, disabled normal actions, brand cleanup-state retry, and no automatic replacement assertions +- [X] T056 [US4] Execute Quickstart Scenarios 4-5 from `specs/004-provider-keys/quickstart.md` and run `backend/tests/contract/test_brands.py`, `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_brand_crud.py`, `backend/tests/integration/test_brand_rls.py`, `backend/tests/integration/test_provider_key_cleanup.py`, and `frontend/tests/e2e/provider-keys.spec.ts` **Checkpoint**: All key and brand deletion outcomes are either physically complete or durably retryable; no successful response can leave an untracked secret or asset. @@ -195,13 +195,13 @@ durably retryable; no successful response can leave an untracked secret or asset **Purpose**: Complete deployment documentation, real-provider checks, regression, secrecy review, and the phase-aware constitutional Definition of Done. -- [ ] T057 [P] Document in `backend/.env.example` that `DATABASE_URL` must use a private non-client role with forced-RLS bypass and only the required application/Vault privileges, without placing credentials or full example secrets in the file -- [ ] T058 [P] Update `docs/docker.md` with hosted backend-role creation/verification expectations, startup failure troubleshooting, official provider outbound-host requirements, and safe handling of disposable validation keys -- [ ] T059 Run the complete backend suite with `backend/.venv/bin/python -m pytest backend/tests -q` and use `specs/004-provider-keys/quickstart.md` to confirm all provider-key real-Supabase integration files execute rather than skip -- [ ] T060 [P] Run `npm run lint`, `npx tsc --noEmit`, `npm run build`, and `npx playwright test tests/e2e/provider-keys.spec.ts` from `frontend/`; resolve feature-related failures in the changed frontend files -- [ ] T061 [P] Run `supabase db lint --level warning` against `supabase/migrations/00016_create_provider_keys.sql` and re-run the catalog, SQLSTATE, RLS, backend-only table, Data API, and Vault privilege assertions in `backend/tests/integration/test_provider_key_rls.py` +- [X] T057 [P] Document in `backend/.env.example` that `DATABASE_URL` must use a private non-client role with forced-RLS bypass and only the required application/Vault privileges, without placing credentials or full example secrets in the file +- [X] T058 [P] Update `docs/docker.md` with hosted backend-role creation/verification expectations, startup failure troubleshooting, official provider outbound-host requirements, and safe handling of disposable validation keys +- [X] T059 Run the complete backend suite with `backend/.venv/bin/python -m pytest backend/tests -q` and use `specs/004-provider-keys/quickstart.md` to confirm all provider-key real-Supabase integration files execute rather than skip +- [X] T060 [P] Run `npm run lint`, `npx tsc --noEmit`, `npm run build`, and `npx playwright test tests/e2e/provider-keys.spec.ts` from `frontend/`; resolve feature-related failures in the changed frontend files +- [X] T061 [P] Run `supabase db lint --level warning` against `supabase/migrations/00016_create_provider_keys.sql` and re-run the catalog, SQLSTATE, RLS, backend-only table, Data API, and Vault privilege assertions in `backend/tests/integration/test_provider_key_rls.py` - [ ] T062 Perform the real disposable OpenAI and Gemini checks in Quickstart Scenario 2 steps 8-10 from `specs/004-provider-keys/quickstart.md`; require `valid` from each official model-list endpoint, confirm no generation occurs, and treat provider outage/temporary classification as blocked rather than passed -- [ ] T063 Audit `backend/app`, `backend/tests`, and captured logs using Quickstart Scenario 6 in `specs/004-provider-keys/quickstart.md`; confirm no raw key, label, hint, Vault UUID, decrypted value, authorization header, provider body, SQL bind, token, PII, or secret-bearing exception can cross response/log/test-output boundaries +- [X] T063 Audit `backend/app`, `backend/tests`, and captured logs using Quickstart Scenario 6 in `specs/004-provider-keys/quickstart.md`; confirm no raw key, label, hint, Vault UUID, decrypted value, authorization header, provider body, SQL bind, token, PII, or secret-bearing exception can cross response/log/test-output boundaries - [ ] T064 Execute all six end-to-end scenarios in `specs/004-provider-keys/quickstart.md`, including more-than-one-batch Storage cleanup, concurrent activation/validation/deletion, ambiguous commits, missing secrets, and auth-user deletion restrictions, and record any environment-only blocker before claiming completion - [ ] T065 Verify Constitution v2.0.0 Definition of Done against `specs/004-provider-keys/spec.md` and `specs/004-provider-keys/quickstart.md`: all acceptance layers, direct RLS/privilege tests, server ownership, secrecy/logging, physical DB/Vault/Storage deletion, and real OpenAI/Gemini behavior pass; document Brand Kit and generation/preset/PNG checks as N/A because their prerequisite capabilities do not exist and this feature does not consume them diff --git a/specs/004-provider-keys/verification.md b/specs/004-provider-keys/verification.md new file mode 100644 index 0000000..d39e574 --- /dev/null +++ b/specs/004-provider-keys/verification.md @@ -0,0 +1,41 @@ +# Provider Keys Verification + +**Date**: 2026-07-28 + +## Automated Results + +- Backend suite: 226 passed with no skipped tests. +- Provider-key RLS, catalog, Data API, backend-table, and Vault checks: 9 passed. +- Supabase schema lint: no errors at warning level. +- Frontend ESLint, TypeScript, and production build: passed. +- Provider-key Playwright flow: 2 passed. +- Production `linux/amd64` container image build: passed. +- Focused secrecy suites after diagnostic-boundary hardening: 172 passed. + +The automated suites cover safe add/list behavior, deterministic OpenAI and Gemini +classification, ownership and RLS, Vault isolation, activation and validation races, +idempotent and ambiguous database outcomes, individual cleanup retry, token-owned +Storage operations, multi-page cleanup, brand hard deletion, and auth-user deletion +restrictions. + +## Environment Blocker + +Quickstart Scenario 2 steps 8-10 require user-supplied disposable OpenAI and Gemini +credentials entered through the UI. No disposable provider credentials were available +in this verification environment. Those checks were not simulated or treated as +passing: real official model-list acceptance and the associated live-log review remain +blocked. + +Because real-provider verification is applicable to this feature, Quickstart Scenario 2, +the complete six-scenario run, and the final constitutional Definition of Done remain +open until both providers return `valid` through the UI and the live logs pass review. + +## Applicability + +- Brand Kit zero-answer and completed-kit checks are not applicable because Brand Kit + does not exist and provider-key behavior does not read or depend on it. +- Generation lifecycle, platform preset, and PNG-output checks are not applicable + because image generation does not exist and provider-key operations perform no + generation. +- OpenAI and Gemini behavior is applicable because this feature introduces both + integrations; deterministic checks pass, but the real-provider gate above remains.