feat(entitlements): per-product access control with admin + Stripe grants (BUNYIP-39) #51

Merged
nrupard merged 2 commits from feat/bunyip-39-per-product-entitlements into main 2026-06-03 16:33:10 +02:00
Owner

Closes BUNYIP-39. Per-product entitlements on top of the M1 single-plan membership gate.

Model

access(user, app) = user.is_access_allowed()                  -- membership gate (unchanged)
                    AND ( NOT app.requires_entitlement         -- product is open, OR
                          OR user is admin                     -- admins bypass, OR
                          OR active entitlement row exists )    -- explicitly granted

A product with requires_entitlement = false behaves exactly as before (open to every member). Flip it to true to sell it as a tiered add-on. Chosen via the BUNYIP-39 scoping decisions: per-product flag (not all-or-nothing), and a migration that preserves current access.

What ships (full feature)

Migration (20260605000010): applications.requires_entitlement (default false), application_entitlements (user x product, revoked rows kept for audit), stripe_price_entitlements (price -> product mapping). Backfill grants every current member every active product, so flipping a product restricted later never retroactively locks out existing members.

Domain: ApplicationEntitlement / UserEntitlementRow models; EntitlementRepository (is_entitled, idempotent grant/revoke, revoke_all_for_user_by_source, list_for_user, price-mapping CRUD); ApplicationRepository::set_requires_entitlement.

Enforcement (admins bypass everywhere): OCI token issuance + get_manifest + get_blob require an entitlement for restricted products; the binary-download handler returns 403; /v1/downloads and the per-app list hide restricted products the member is not entitled to. Denials audited (OciPullDeniedEntitlement, DownloadDeniedEntitlement).

Admin API: grant / revoke / list per-user entitlements, mark a product restricted, manage the Stripe price -> product mapping; audited (AdminEntitlementGranted / Revoked).

Stripe: subscription created/updated re-syncs Stripe-sourced entitlements to the subscription's current prices (handles plan add/remove); canceled/deleted revokes them. Admin-granted entitlements (source = 'admin') are never touched by Stripe sync, so a manual grant survives a billing event.

Web: admin Entitlements page (per-product restricted toggle) + per-user grant/revoke view; an Entitlements link on each user row.

Verification

rust-builder-glibc 1.94.1 container: clippy --workspace --all-targets -- -D warnings clean, fmt --all --check clean, cargo test --workspace --lib 206 passed.

Live dev stack (migration applied: table + indexes + 7 backfill rows; all 7 seeded products default to open). Full enforcement matrix via the OCI /auth/token endpoint for a freshly registered non-admin lifetime member (no backfill row):

State Result
product restricted, member NOT entitled token 403
product restricted, member granted token 200
product restricted, entitlement revoked token 403
product open (requires_entitlement=false) token 200

Notes / follow-ups

  • Repository methods use runtime sqlx queries (no .sqlx regen needed). The access decision is enforced per request (membership AND entitlement are both re-checked), so a revoke takes effect within the OCI token TTL.
  • The Stripe price -> product mapping is admin-managed (no UI for the mapping itself yet; the grant/revoke + restricted-toggle UI is included). A follow-up could add a mapping UI.
Closes BUNYIP-39. Per-product entitlements on top of the M1 single-plan membership gate. ## Model ``` access(user, app) = user.is_access_allowed() -- membership gate (unchanged) AND ( NOT app.requires_entitlement -- product is open, OR OR user is admin -- admins bypass, OR OR active entitlement row exists ) -- explicitly granted ``` A product with `requires_entitlement = false` behaves exactly as before (open to every member). Flip it to `true` to sell it as a tiered add-on. Chosen via the BUNYIP-39 scoping decisions: per-product flag (not all-or-nothing), and a migration that preserves current access. ## What ships (full feature) **Migration** (`20260605000010`): `applications.requires_entitlement` (default false), `application_entitlements` (user x product, revoked rows kept for audit), `stripe_price_entitlements` (price -> product mapping). Backfill grants every current member every active product, so flipping a product restricted later never retroactively locks out existing members. **Domain**: `ApplicationEntitlement` / `UserEntitlementRow` models; `EntitlementRepository` (is_entitled, idempotent grant/revoke, revoke_all_for_user_by_source, list_for_user, price-mapping CRUD); `ApplicationRepository::set_requires_entitlement`. **Enforcement** (admins bypass everywhere): OCI token issuance + `get_manifest` + `get_blob` require an entitlement for restricted products; the binary-download handler returns 403; `/v1/downloads` and the per-app list hide restricted products the member is not entitled to. Denials audited (`OciPullDeniedEntitlement`, `DownloadDeniedEntitlement`). **Admin API**: grant / revoke / list per-user entitlements, mark a product restricted, manage the Stripe price -> product mapping; audited (`AdminEntitlementGranted` / `Revoked`). **Stripe**: subscription created/updated re-syncs Stripe-sourced entitlements to the subscription's current prices (handles plan add/remove); canceled/deleted revokes them. Admin-granted entitlements (`source = 'admin'`) are never touched by Stripe sync, so a manual grant survives a billing event. **Web**: admin Entitlements page (per-product restricted toggle) + per-user grant/revoke view; an Entitlements link on each user row. ## Verification rust-builder-glibc 1.94.1 container: `clippy --workspace --all-targets -- -D warnings` clean, `fmt --all --check` clean, `cargo test --workspace --lib` 206 passed. Live dev stack (migration applied: table + indexes + 7 backfill rows; all 7 seeded products default to open). Full enforcement matrix via the OCI `/auth/token` endpoint for a freshly registered non-admin lifetime member (no backfill row): | State | Result | |-------|--------| | product restricted, member NOT entitled | token 403 | | product restricted, member granted | token 200 | | product restricted, entitlement revoked | token 403 | | product open (requires_entitlement=false) | token 200 | ## Notes / follow-ups - Repository methods use runtime sqlx queries (no `.sqlx` regen needed). The access decision is enforced per request (membership AND entitlement are both re-checked), so a revoke takes effect within the OCI token TTL. - The Stripe price -> product mapping is admin-managed (no UI for the mapping itself yet; the grant/revoke + restricted-toggle UI is included). A follow-up could add a mapping UI.
feat(entitlements): per-product access control with admin + Stripe grants (BUNYIP-39)
All checks were successful
Check / fmt / clippy / build / test (pull_request) Successful in 4m14s
6d09f672f4
M1 shipped a single-plan model: any member could pull and download every active product. This adds per-product entitlements on top of the membership gate, with no behaviour change for existing products.

Model (chosen for BUNYIP-39): access(user, app) = user.is_access_allowed() AND (NOT app.requires_entitlement OR user is admin OR an active entitlement row exists). A product with requires_entitlement = false stays open to all members (the default); flip it to true to sell it as a tiered add-on.

- Migration: applications.requires_entitlement (default false), application_entitlements (user x product grants, revoked rows kept for audit), stripe_price_entitlements (price -> product mapping). Backfill preserves current access: every member who has access today is granted every active product, so flipping a product to restricted later does not retroactively cut off existing members.
- Domain: ApplicationEntitlement / UserEntitlementRow models; EntitlementRepository (is_entitled, grant/revoke (idempotent upsert), revoke_all_for_user_by_source, list_for_user, price-mapping CRUD); ApplicationRepository::set_requires_entitlement.
- Enforcement: OCI token issuance + both registry handlers (get_manifest, get_blob) and the binary-download handler now require an entitlement for restricted products (admins bypass); denials are audited (OciPullDeniedEntitlement / DownloadDeniedEntitlement). The downloads list (/v1/downloads and the per-app list) hides restricted products the member is not entitled to.
- Admin API: grant / revoke / list per-user entitlements, mark a product restricted, manage the Stripe price -> product mapping; all audited (AdminEntitlementGranted / Revoked).
- Stripe: subscription created/updated re-syncs Stripe-sourced entitlements to the subscription's current prices (handles plan add/remove); canceled/deleted revokes them. Admin-granted entitlements (source 'admin') are never touched by Stripe sync.
- Web: admin Entitlements page (per-product restricted toggle) + per-user grant/revoke view, plus an Entitlements link on each user row.

Verified in the rust-builder 1.94.1 container: clippy -D warnings clean, fmt clean, 206 workspace lib tests pass. Live dev stack: migration applied (table + indexes + backfill), and the full enforcement matrix confirmed via the OCI token endpoint for a non-admin lifetime member - restricted+unentitled 403, granted 200, revoked 403, open product 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: nrupard <natrsmith11@gmail.com>
fix(entitlements): address PR #51 review findings
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt / clippy / build / test (pull_request) Successful in 1m10s
0ea20b150f
- Consolidate the per-product access decision into one place: Application::entitlement_satisfied(is_admin, is_entitled) + EntitlementRepository::is_allowed(pool, user_id, is_admin, app) (short-circuits open products/admins with no DB hit). All five gates (OCI token issuance, get_manifest, get_blob, download_asset, the downloads list, and the catalog get_application) now call the shared decision instead of re-implementing requires_entitlement && !admin && !entitled with three different admin spellings.
- Preserve current access on flip: set_application_restricted now backfills the product to every member who can access it right now when an open product is flipped to restricted, so members who joined after the migration are not retroactively cut off (the migration only snapshotted deploy-time members). New EntitlementRepository::backfill_for_application mirrors the migration predicate.
- Gate get_application: a restricted product now 404s for any caller who is not an admin or actively entitled (anonymous included), instead of leaking its metadata/existence.
- Uniform denial code: unentitled OCI (token + manifest/blob) and download_asset now return NameUnknown / not-found (404), matching the not-pullable/unknown paths, so a restricted product's existence cannot be enumerated by 403-vs-404.
- Stripe webhook robustness: sync/revoke entitlement helpers now return Result and the handlers propagate it, so a transient failure makes Stripe retry (the revoke-all-then-grant op is idempotent and self-heals) instead of silently leaving a paying member under-granted. Entitlement grants are gated on an explicit active-status allowlist (active/trialing/past_due); any other status (unpaid, incomplete_expired, ...) revokes the Stripe-sourced grants rather than re-granting via the membership catch-all.
- Downloads N+1 removed: list_all_downloads fetches the caller's active entitlement set once (active_application_ids) and filters in memory instead of one is_entitled query per restricted product.
- Dedicated audit actions AdminApplicationRestrictionChanged / AdminStripePriceMappingChanged (in is_admin_action) replace the generic ApplicationUpdated for restriction and price-mapping changes. Removed dead list_for_application.

Not changed: the seed-catalog migration's stale comment is left as-is because editing an already-applied migration breaks sqlx's checksum on deployed DBs; the new model is documented in the entitlements migration instead.

Verified in the rust-builder 1.94.1 container: clippy -D warnings clean, fmt clean, 208 lib tests pass (incl. 2 new entitlement_satisfied tests). Live dev stack: OCI token + catalog get_application matrix - restricted+unentitled 404, granted 200, revoked 404, open 200 (member and anonymous).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: nrupard <natrsmith11@gmail.com>
nrupard deleted branch feat/bunyip-39-per-product-entitlements 2026-06-03 16:33:10 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
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
psa-systems/bunyip!51
No description provided.