gitea-saas-core#41: client-facing surface — self-serve funding and work submission #51

Merged
pbwraith merged 1 commits from issue-41 into main 2026-09-07 04:37:33 +02:00
Owner

gitea-saas-core#41: Client-facing surface — self-serve funding and work submission

Closes #41.

Implements the first genuinely externally-reachable surface, per the owner
decision (2026-09-05): no public launch, no real payments, no production
ledger changes — feature, tests, one PR.

What landed

  • Per-client API keys (ClientKeyStore, gitea_saas/client_api.py +
    schema.sql): random saas_... keys, scoped to exactly one client_id,
    stored ONLY as a SHA-256 hash (plaintext returned once at issuance, never
    written, never logged), revocable. Issued by the operator at
    POST /api/clients/<id>/keys (operator-guarded); revoke one or all.
    Hashing lives in auth.py (hash_client_key); verification is
    digest-equality against the key table — deliberately no second
    compare_digest call site (the #24 pin auth.py:70 / stripe_client.py:88
    is untouched, tests/test_auth.py unchanged).
  • Checkout POST /api/checkout (client key): Stripe Checkout URL via the
    existing mockable seam, rate-limited per client (429 + Retry-After).
  • Credit path POST /api/webhooks/stripe (signature-authenticated, in
    PUBLIC_PATHS): reuses stripe_client.apply_payment_event — the issue-#3
    idempotent credit. No second credit path; replayed events credit exactly
    once (asserted).
  • Client status GET /api/me: balance, tier, derived status
    (active/locked/overdue), queued items, in-flight items, delivered count —
    always the caller's own client_id.
  • Self-serve work submission POST /api/me/issues: submits as the
    caller's OWN client_id through the issue-#4 factory (funded →
    Gitea issue, unfunded/locked → queued). The factory module is untouched.
  • Tenancy (auth.py FORBIDDEN_BODY + compound assembly guard in
    client_api.register_client_routes): a valid client key returns the
    canonical 403 on every operator route (/api/clients, /api/factory/*,
    /dashboard, key issuance) and on other clients' resources; the operator
    key never works on the client surface (401); PUBLIC_PATHS liveness and
    the fail-closed 503-unconfigured gate behave exactly as before
    (equivalent operator-route semantics, same canonical 401).
  • Files touched are exactly the issue's owned set: client_api.py (new),
    auth.py (extend), schema.sql, tests/test_client_api.py (new, 18
    tests), app.py + tests/test_app.py (mount + assembled-app reachability;
    note the #38 assembly test file is named test_app.py in the merged tree).
    No other files changed.

Acceptance evidence

  1. Cross-tenant: client A's key → 403 on client B's /api/factory/*, B's
    /api/clients/<id> ledger/credits/keys, /api/clients, /api/factory/*,
    /dashboard (canonical FORBIDDEN body) — test_cross_tenant_and_operator_denials,
    test_assembled_app_authenticated_client_path_and_denials.
  2. Keys hashed at rest: plaintext never in the DB (full-blob scan asserted)
    — test_keys_stored_hashed_plaintext_never_in_db.
  3. Checkout → webhook → balance credit through the assembled app, replayed
    webhook credits exactly once — test_checkout_webhook_credit_exactly_once,
    test_assembled_app_....
  4. Full suite: 293 passed, 1 skipped (python -m pytest -q).

Also covered: checkout rate limit (429), webhook signature rejection (400,
no detail leak), Stripe-unconfigured fail-closed 503 (checkout + webhook),
revoked keys stop resolving, revoke is client-scoped, /api/me counts,
/api/me/issues validation, operator key never reaches the client surface,
no SAAS_API_KEY → client surface 503s too.

No keys, Stripe secrets, or webhook payloads are logged anywhere in the
new code.

## gitea-saas-core#41: Client-facing surface — self-serve funding and work submission Closes #41. Implements the first genuinely externally-reachable surface, per the owner decision (2026-09-05): no public launch, no real payments, no production ledger changes — feature, tests, one PR. ### What landed - **Per-client API keys** (`ClientKeyStore`, `gitea_saas/client_api.py` + `schema.sql`): random `saas_...` keys, scoped to exactly one `client_id`, stored ONLY as a SHA-256 hash (plaintext returned once at issuance, never written, never logged), revocable. Issued by the operator at `POST /api/clients/<id>/keys` (operator-guarded); revoke one or all. Hashing lives in `auth.py` (`hash_client_key`); verification is digest-equality against the key table — deliberately no second `compare_digest` call site (the #24 pin `auth.py:70` / `stripe_client.py:88` is untouched, `tests/test_auth.py` unchanged). - **Checkout** `POST /api/checkout` (client key): Stripe Checkout URL via the existing mockable seam, rate-limited per client (429 + Retry-After). - **Credit path** `POST /api/webhooks/stripe` (signature-authenticated, in `PUBLIC_PATHS`): reuses `stripe_client.apply_payment_event` — the issue-#3 idempotent credit. No second credit path; replayed events credit exactly once (asserted). - **Client status** `GET /api/me`: balance, tier, derived status (active/locked/overdue), queued items, in-flight items, delivered count — always the caller's own client_id. - **Self-serve work submission** `POST /api/me/issues`: submits as the caller's OWN client_id through the issue-#4 factory (funded → Gitea issue, unfunded/locked → queued). The factory module is untouched. - **Tenancy** (`auth.py` FORBIDDEN_BODY + compound assembly guard in `client_api.register_client_routes`): a valid client key returns the canonical 403 on every operator route (`/api/clients`, `/api/factory/*`, `/dashboard`, key issuance) and on other clients' resources; the operator key never works on the client surface (401); `PUBLIC_PATHS` liveness and the fail-closed 503-unconfigured gate behave exactly as before (equivalent operator-route semantics, same canonical 401). - Files touched are exactly the issue's owned set: `client_api.py` (new), `auth.py` (extend), `schema.sql`, `tests/test_client_api.py` (new, 18 tests), `app.py` + `tests/test_app.py` (mount + assembled-app reachability; note the #38 assembly test file is named `test_app.py` in the merged tree). No other files changed. ### Acceptance evidence 1. Cross-tenant: client A's key → 403 on client B's `/api/factory/*`, B's `/api/clients/<id>` ledger/credits/keys, `/api/clients`, `/api/factory/*`, `/dashboard` (canonical FORBIDDEN body) — `test_cross_tenant_and_operator_denials`, `test_assembled_app_authenticated_client_path_and_denials`. 2. Keys hashed at rest: plaintext never in the DB (full-blob scan asserted) — `test_keys_stored_hashed_plaintext_never_in_db`. 3. Checkout → webhook → balance credit through the assembled app, replayed webhook credits exactly once — `test_checkout_webhook_credit_exactly_once`, `test_assembled_app_...`. 4. Full suite: **293 passed, 1 skipped** (`python -m pytest -q`). Also covered: checkout rate limit (429), webhook signature rejection (400, no detail leak), Stripe-unconfigured fail-closed 503 (checkout + webhook), revoked keys stop resolving, revoke is client-scoped, `/api/me` counts, `/api/me/issues` validation, operator key never reaches the client surface, no `SAAS_API_KEY` → client surface 503s too. No keys, Stripe secrets, or webhook payloads are logged anywhere in the new code.
pbwraith added 1 commit 2026-09-05 11:35:39 +02:00
- client_api.py: ClientKeyStore (SHA-256 hashed at rest, scoped, revocable),
  checkout with per-client rate limit, /api/me status, /api/me/issues submit,
  signature-authenticated /api/webhooks/stripe reusing the issue-#3 seam, and
  operator key-issuance routes; compound assembly guard replaces the billing
  operator before_request with identical operator-route semantics so scoped
  client endpoints are reachable and valid client keys get 403 on operator
  routes (cross-tenant + operator denials).
- auth.py: PUBLIC_PATHS + /api/webhooks/stripe; FORBIDDEN_BODY/
  forbidden_response; hash_client_key (no new compare_digest call site).
- schema.sql: client_api_keys table (hash + prefix only).
- app.py: mounts the client surface (stripe_builder/gitea injectable).
- tests/test_client_api.py (18 tests) + assembled-app coverage in test_app.py.
  Full suite: 293 passed, 1 skipped.
pbwraith added the
in-review
label 2026-09-05 11:35:58 +02:00
Author
Owner

[worker] Implemented — PR #51 ready for manual review.

Branch: issue-41 @ f39343ea (base main 2ef1e66). Files: exactly the issue's owned set (client_api.py new, auth.py, schema.sql, app.py, tests/test_client_api.py new, tests/test_app.py). 6 files, +1467/-21.

Acceptance evidence (all hermetic, mock Stripe/Gitea, no network):

  1. Cross-tenant 403: client A's key on B's /api/factory/*, B's clients ledger/credits/keys, /api/clients, /api/factory/*, /dashboard → canonical FORBIDDEN body (tests/test_client_api.py::test_cross_tenant_and_operator_denials + test_app.py assembled-app test).
  2. Keys hashed at rest: full-DB blob scan asserts plaintext never present (test_keys_stored_hashed_plaintext_never_in_db).
  3. Checkout → webhook → credit exactly once through the real assembled app; replayed event does not double-credit (test_checkout_webhook_credit_exactly_once).
  4. Full suite: python -m pytest -q → 293 passed, 1 skipped.

Also: per-client checkout rate limit (429 + Retry-After), webhook signature rejection with no detail leak, Stripe-unconfigured fail-closed 503, revoked keys stop resolving, revoke is client-scoped, operator key 401s on the client surface, auth.py comparison pin (auth.py:70 / stripe_client.py:88) untouched, tests/test_auth.py unmodified.

Guard note for review: mounting the client surface replaces the billing app's operator before_request with a compound guard (client tenancy → operator check with identical semantics). No keys/Stripe secrets/webhook payloads are logged.

Test command: python -m pytest -q (293 passed, 1 skipped).

[worker] Implemented — PR #51 ready for manual review. Branch: `issue-41` @ f39343ea (base `main` 2ef1e66). Files: exactly the issue's owned set (client_api.py new, auth.py, schema.sql, app.py, tests/test_client_api.py new, tests/test_app.py). 6 files, +1467/-21. Acceptance evidence (all hermetic, mock Stripe/Gitea, no network): 1. Cross-tenant 403: client A's key on B's `/api/factory/*`, B's clients ledger/credits/keys, `/api/clients`, `/api/factory/*`, `/dashboard` → canonical FORBIDDEN body (tests/test_client_api.py::test_cross_tenant_and_operator_denials + test_app.py assembled-app test). 2. Keys hashed at rest: full-DB blob scan asserts plaintext never present (test_keys_stored_hashed_plaintext_never_in_db). 3. Checkout → webhook → credit exactly once through the real assembled app; replayed event does not double-credit (test_checkout_webhook_credit_exactly_once). 4. Full suite: `python -m pytest -q` → 293 passed, 1 skipped. Also: per-client checkout rate limit (429 + Retry-After), webhook signature rejection with no detail leak, Stripe-unconfigured fail-closed 503, revoked keys stop resolving, revoke is client-scoped, operator key 401s on the client surface, `auth.py` comparison pin (auth.py:70 / stripe_client.py:88) untouched, `tests/test_auth.py` unmodified. Guard note for review: mounting the client surface replaces the billing app's operator before_request with a compound guard (client tenancy → operator check with identical semantics). No keys/Stripe secrets/webhook payloads are logged. Test command: `python -m pytest -q` (293 passed, 1 skipped).
Author
Owner

Reviewed by Claude Opus (reviewer/merge gate).

Gate: PASS — pytest, run against the PR head in an isolated worktree.

Owner-approved (auth-touching change, escalated before merge).

Security review of auth.py: /api/webhooks/stripe joins PUBLIC_PATHS, correct only because the route is signature-authenticated — confirmed at stripe_client.py:155 via the official stripe.Webhook.construct_event(payload, signature, webhook_secret). Tests cover a wrong signature, a replay, and a 503 fail-closed when the secret is unconfigured. Per-client keys are SHA-256 hashed, never stored plaintext, and #24's single-compare_digest invariant is preserved.

Reviewed by Claude Opus (reviewer/merge gate). **Gate: PASS** — pytest, run against the PR head in an isolated worktree. **Owner-approved** (auth-touching change, escalated before merge). Security review of `auth.py`: `/api/webhooks/stripe` joins PUBLIC_PATHS, correct only because the route is signature-authenticated — confirmed at `stripe_client.py:155` via the official `stripe.Webhook.construct_event(payload, signature, webhook_secret)`. Tests cover a wrong signature, a replay, and a 503 fail-closed when the secret is unconfigured. Per-client keys are SHA-256 hashed, never stored plaintext, and #24's single-`compare_digest` invariant is preserved.
pbwraith merged commit d75c1cc235 into main 2026-09-07 04:37:33 +02:00
pbwraith removed the
in-review
label 2026-09-07 04:37:34 +02:00
Sign in to join this conversation.
No reviewers
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: pbwraith/gitea-saas-core#51
No description provided.