diff --git a/README.md b/README.md index eab0f02..11f91f6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This repository is split deliberately, by audience: Root-level documents: -- **[plan-remaining.md](plan-remaining.md)** — everything specified but not built yet: test-mode User expiry, the first-party web pages, Phase 3, dedicated tenancy infrastructure, scale-out triggers, and the rules every change follows. +- **[plan-remaining.md](plan-remaining.md)** — everything specified but not built yet: the first-party web pages, Phase 3, dedicated tenancy infrastructure, scale-out triggers, and the rules every change follows. - **[`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc)** — the one-time setup still to do by hand to go live, as numbered GitHub issues in the order to do them (#18–#28): the Lambda quota, redeploy, the superadmin password, alert emails, SES production access, Stripe, push credentials, registering Applications, the plan job, legal, and the optional domain transfer. - **[infra/README.md](infra/README.md)** — the deployment architecture as built: AWS infrastructure (cost-capped, Aurora Serverless v2, Lambda, no VPC/NAT at Tier 0), the account setup, Stripe Billing, email, webhook and push delivery, audit archival, and local development. - **[LICENSE](LICENSE)** — MIT. diff --git a/docs/api-reference/auth.md b/docs/api-reference/auth.md index 81981ee..14eeed4 100644 --- a/docs/api-reference/auth.md +++ b/docs/api-reference/auth.md @@ -194,7 +194,7 @@ Endpoints that take a token (`mfa/verify`, `token/refresh`, `password/reset`, `e Every test-mode User is **time-limited**. Its `expires_at` is set when it's created, and at that moment the account stops working. Live Users never expire (`expires_at` is always `null`). - **Default: 24 hours** after creation. A caller creating a test-mode User, through [`POST /users`](../users/#post-users) with a `satk_test_` key or through signup with `"test_mode": true`, may send `expires_at` to choose a different lifetime, to suit what's being tested: a short-lived account for one CI run, or one that lasts a week for a manual QA pass. -- **Range:** at least 15 minutes from now, and no later than **30 days after the User's `created_at`**, which is when its test data is purged. Anything outside that range is `422 validation_failed` (`details.reason: "expires_at_out_of_range"`). Sending `expires_at` for a live User is `422 validation_failed` (`details.reason: "live_user"`). +- **Range:** at least 15 minutes from now, and no later than **30 days after the User's `created_at`**, which is when its test data is purged. Anything outside that range is `422 validation_failed`, with the `expires_at` field's code `expires_at_out_of_range` in `details.fields`. Sending `expires_at` for a live User is `422 validation_failed` with the code `live_user`. - **Changing it:** `users.manage` can move `expires_at` with [`PATCH /users/{id}`](../users/#patch-usersid), within the same range, to extend a run, end it early, or bring back an expired User that hasn't been purged yet. - **At expiry:** login returns `403 test_user_expired`, and every token issued to the User stops working, whatever its own `exp`. That covers access, refresh, and app tokens. No token is issued with an `exp` later than the User's `expires_at`, so an Application that verifies app tokens locally against JWKS stops accepting them at the same moment. Within 5 minutes, the expiry sweep revokes the User's sessions (`revoked_reason: test_user_expired`), deactivates their push devices, fires `access.revoked` (reason `test_user_expired`) for every Application they had active access to, and writes the `user.expired` Audit Event. Like suspension, expiry leaves `status` and the User's Entitlements, Roles, and memberships unchanged, so an extension restores exactly what was there and fires `access.granted` again. - **Purge:** the nightly purge of test-mode rows older than 30 days is unchanged. The 30-day ceiling on `expires_at` means a test User has always expired before its data is purged. diff --git a/docs/changelog.md b/docs/changelog.md index a3a3efb..88b3e01 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,21 @@ What's changed in this specification over time. Every decision is written into t --- +## 2026-10-22 — Test-mode User expiry implemented + +[Test-mode Users → Lifetime](../api-reference/auth/#lifetime) is built, as specified on 2026-10-20. `openapi.yaml` is now at `1.0.0-draft.6`: + +- `User.expires_at`, and `expires_at` on `POST /auth/signup`, `POST /users`, and `PATCH /users/{id}`. +- `user.expired` in the webhook event and audit action enums, `test_user_expired` and `test_user_extended` in the access-event reasons, and `test_user_expired` in the device inactive reasons. +- `POST /auth/token/refresh` and `POST /auth/mfa/verify` now declare `403`, which they return for `test_user_expired` (and, for `mfa/verify`, `account_suspended`, which it already returned). + +A few behaviors the spec left implicit are now stated: + +- An out-of-range or live-User `expires_at` is reported like any other field error: `422 validation_failed` with the code in `details.fields`, not a separate `details.reason`. +- A Platform access token whose test-mode User has expired gets `401 session_revoked`, the same as a suspended User's. Sign-in, refresh, MFA verification, and app-token issuance return `403 test_user_expired`. +- Extending a User the expiry sweep hasn't processed yet fires no `access.granted`, since no `access.revoked` went out for them either. +- Migration `0009_test_user_expiry` sets every existing test-mode User's `expires_at` to `created_at` + 30 days, when the purge would have removed them anyway. + ## 2026-10-22 — Version header declared in `openapi.yaml`; 405 for a wrong method - **`openapi.yaml` declares `Substratal-Version`.** Every versioned operation now carries the shared `ApiVersion` header parameter (`Substratal-Version`, enum `1`) and documents its `400` (`api_version_required`, `unsupported_api_version`). Generated clients learn the header from the spec instead of needing it configured by hand. `GET /health` and `POST /auth/oauth/token` don't declare it, because they're exempt. Nothing changes on the wire: the API already required the header ([Conventions → Versioning](../api-reference/conventions/#versioning)). @@ -59,7 +74,7 @@ Test mode already never counted toward billing (decision #36, confirmed on 2026- - **At expiry** sign-in, refresh, and app-token issuance return the new `403 test_user_expired`, and every token already issued stops working. No token's `exp` is ever later than the User's `expires_at`. The 5-minute entitlement-expiry sweep then revokes sessions and devices (reason `test_user_expired`), fires `access.revoked` (reason `test_user_expired`) and the new `user.expired` webhook, and writes the `user.expired` Audit Event. `status`, Entitlements, Roles, and memberships are unchanged. An expired test User isn't a covered member of a test-mode subscription. - Test-mode API Keys, Organizations, and Applications don't expire. The nightly 30-day purge is unchanged. -Not yet built: until it is, test-mode Users last until the 30-day purge, as before. `openapi.yaml` gains `expires_at` in the same change as the code. The build steps are in the repository's `plan-remaining.md` → Test-mode User expiry. +Built on 2026-10-21; see the entry above. ## 2026-10-18 — Push notifications implemented diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e3d41fc..cf23049 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Substratal Apps — Platform API - version: "1.0.0-draft.5" + version: "1.0.0-draft.6" summary: >- User accounts, auth, roles, per-app entitlements (the on/off access switch), profiles, settings, organizations, tenancy, subscription billing, @@ -105,6 +105,7 @@ paths: display_name: { type: string, minLength: 1, maxLength: 100 } application_id: { type: string, description: "Informational: records which app the signup came from. Grants nothing." } test_mode: { type: boolean, default: false, description: "Create a test-mode User." } + expires_at: { type: string, format: date-time, description: "Test mode only: when the account stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user when test_mode is false." } responses: "400": { $ref: "#/components/responses/BadRequest" } "201": @@ -178,6 +179,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/AuthSession" } "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/TooManyRequests" } /auth/token/refresh: @@ -206,6 +208,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/AuthSession" } "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } /auth/logout: post: @@ -701,6 +704,7 @@ paths: display_name: { type: string, maxLength: 100 } status: { type: string, enum: [invited, active], default: invited } send_invitation: { type: boolean, default: true } + expires_at: { type: string, format: date-time, description: "Test-mode keys only: when the User stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user with a live key." } responses: "400": { $ref: "#/components/responses/BadRequest" } "201": @@ -750,6 +754,7 @@ paths: properties: email: { type: string, format: email } status: { type: string, enum: [active, suspended], description: "users.manage only. active on a deleted User restores it." } + expires_at: { type: string, format: date-time, description: "users.manage only, test-mode Users only. At least 15 minutes from now and at most 30 days after created_at. Moving it later on an expired User brings them back and fires access.granted (test_user_extended)." } responses: "400": { $ref: "#/components/responses/BadRequest" } "200": @@ -3229,6 +3234,7 @@ components: created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } last_login_at: { type: [string, "null"], format: date-time } + expires_at: { type: [string, "null"], format: date-time, description: "When a test-mode User stops working (Auth → Test-mode Users → Lifetime). Always null for live Users." } plan: { $ref: "#/components/schemas/Plan" } plan_sources: { type: array, items: { $ref: "#/components/schemas/PlanSource" }, description: "What gives this User Standard: their own subscription, an Organization's, or both. Empty on Free." } @@ -3775,6 +3781,7 @@ components: - user.suspended - user.reactivated - user.deleted + - user.expired - user.erasure_requested - user.erasure_cancelled - user.erased @@ -3889,6 +3896,7 @@ components: - user.suspended - user.reactivated - user.deleted + - user.expired - organization.member_added - organization.member_removed - application.review_status_changed @@ -3915,6 +3923,8 @@ components: - user_suspended - user_reactivated - user_deleted + - test_user_expired + - test_user_extended WebhookEventEnvelope: type: object @@ -4035,7 +4045,7 @@ components: DeviceInactiveReason: type: string - enum: [unregistered, session_revoked, user_suspended, user_deleted, stale, limit, credential_removed] + enum: [unregistered, session_revoked, user_suspended, user_deleted, test_user_expired, stale, limit, credential_removed] WebPushSubscription: type: object diff --git a/internal/api/gen/gen.go b/internal/api/gen/gen.go index 66e0fce..39a8896 100644 --- a/internal/api/gen/gen.go +++ b/internal/api/gen/gen.go @@ -28,6 +28,8 @@ const ( AccessEventReasonOrgGrantChanged AccessEventReason = "org_grant_changed" AccessEventReasonOrgMemberAdded AccessEventReason = "org_member_added" AccessEventReasonOrgMemberRemoved AccessEventReason = "org_member_removed" + AccessEventReasonTestUserExpired AccessEventReason = "test_user_expired" + AccessEventReasonTestUserExtended AccessEventReason = "test_user_extended" AccessEventReasonUserDeleted AccessEventReason = "user_deleted" AccessEventReasonUserReactivated AccessEventReason = "user_reactivated" AccessEventReasonUserSuspended AccessEventReason = "user_suspended" @@ -56,6 +58,10 @@ func (e AccessEventReason) Valid() bool { return true case AccessEventReasonOrgMemberRemoved: return true + case AccessEventReasonTestUserExpired: + return true + case AccessEventReasonTestUserExtended: + return true case AccessEventReasonUserDeleted: return true case AccessEventReasonUserReactivated: @@ -388,6 +394,7 @@ const ( AuditActionUserErased AuditAction = "user.erased" AuditActionUserErasureCancelled AuditAction = "user.erasure_cancelled" AuditActionUserErasureRequested AuditAction = "user.erasure_requested" + AuditActionUserExpired AuditAction = "user.expired" AuditActionUserMfaDisabled AuditAction = "user.mfa_disabled" AuditActionUserMfaEnabled AuditAction = "user.mfa_enabled" AuditActionUserMfaReset AuditAction = "user.mfa_reset" @@ -497,6 +504,8 @@ func (e AuditAction) Valid() bool { return true case AuditActionUserErasureRequested: return true + case AuditActionUserExpired: + return true case AuditActionUserMfaDisabled: return true case AuditActionUserMfaEnabled: @@ -640,6 +649,7 @@ const ( DeviceInactiveReasonLimit DeviceInactiveReason = "limit" DeviceInactiveReasonSessionRevoked DeviceInactiveReason = "session_revoked" DeviceInactiveReasonStale DeviceInactiveReason = "stale" + DeviceInactiveReasonTestUserExpired DeviceInactiveReason = "test_user_expired" DeviceInactiveReasonUnregistered DeviceInactiveReason = "unregistered" DeviceInactiveReasonUserDeleted DeviceInactiveReason = "user_deleted" DeviceInactiveReasonUserSuspended DeviceInactiveReason = "user_suspended" @@ -656,6 +666,8 @@ func (e DeviceInactiveReason) Valid() bool { return true case DeviceInactiveReasonStale: return true + case DeviceInactiveReasonTestUserExpired: + return true case DeviceInactiveReasonUnregistered: return true case DeviceInactiveReasonUserDeleted: @@ -1670,6 +1682,7 @@ const ( WebhookEventRoleAssigned WebhookEvent = "role.assigned" WebhookEventRoleRemoved WebhookEvent = "role.removed" WebhookEventUserDeleted WebhookEvent = "user.deleted" + WebhookEventUserExpired WebhookEvent = "user.expired" WebhookEventUserReactivated WebhookEvent = "user.reactivated" WebhookEventUserSuspended WebhookEvent = "user.suspended" WebhookEventWebhookTest WebhookEvent = "webhook.test" @@ -1718,6 +1731,8 @@ func (e WebhookEvent) Valid() bool { return true case WebhookEventUserDeleted: return true + case WebhookEventUserExpired: + return true case WebhookEventUserReactivated: return true case WebhookEventUserSuspended: @@ -3263,10 +3278,13 @@ type User struct { CreatedAt *time.Time `json:"created_at,omitempty"` Email *openapi_types.Email `json:"email,omitempty"` EmailVerified *bool `json:"email_verified,omitempty"` - Id *string `json:"id,omitempty"` - LastLoginAt *time.Time `json:"last_login_at,omitempty"` - MfaEnabled *bool `json:"mfa_enabled,omitempty"` - PendingEmail *openapi_types.Email `json:"pending_email,omitempty"` + + // ExpiresAt When a test-mode User stops working (Auth → Test-mode Users → Lifetime). Always null for live Users. + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Id *string `json:"id,omitempty"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + MfaEnabled *bool `json:"mfa_enabled,omitempty"` + PendingEmail *openapi_types.Email `json:"pending_email,omitempty"` // Plan Business and enterprise plans come later. Plan *Plan `json:"plan,omitempty"` @@ -3691,7 +3709,10 @@ type AuthSignupJSONBody struct { ApplicationId *string `json:"application_id,omitempty"` DisplayName *string `json:"display_name,omitempty"` Email openapi_types.Email `json:"email"` - Password string `json:"password"` + + // ExpiresAt Test mode only: when the account stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user when test_mode is false. + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Password string `json:"password"` // TestMode Create a test-mode User. TestMode *bool `json:"test_mode,omitempty"` @@ -3958,8 +3979,11 @@ type UsersListParamsStatus string // UsersCreateJSONBody defines parameters for UsersCreate. type UsersCreateJSONBody struct { - DisplayName *string `json:"display_name,omitempty"` - Email openapi_types.Email `json:"email"` + DisplayName *string `json:"display_name,omitempty"` + Email openapi_types.Email `json:"email"` + + // ExpiresAt Test-mode keys only: when the User stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user with a live key. + ExpiresAt *time.Time `json:"expires_at,omitempty"` SendInvitation *bool `json:"send_invitation,omitempty"` Status *UsersCreateJSONBodyStatus `json:"status,omitempty"` } @@ -3982,6 +4006,9 @@ type BillingCreateUserCheckoutSessionParams struct { type UsersUpdateJSONBody struct { Email *openapi_types.Email `json:"email,omitempty"` + // ExpiresAt users.manage only, test-mode Users only. At least 15 minutes from now and at most 30 days after created_at. Moving it later on an expired User brings them back and fires access.granted (test_user_extended). + ExpiresAt *time.Time `json:"expires_at,omitempty"` + // Status users.manage only. active on a deleted User restores it. Status *UsersUpdateJSONBodyStatus `json:"status,omitempty"` } diff --git a/internal/core/auth_flows.go b/internal/core/auth_flows.go index 577c26d..4f34837 100644 --- a/internal/core/auth_flows.go +++ b/internal/core/auth_flows.go @@ -68,6 +68,9 @@ func (c *Call) mintAppToken(u userRow, sessionID string, a appRow, orgID *string if u.Status == "suspended" { return "", httpx.E(403, "account_suspended", "This account is suspended.") } + if u.expired(c.now) { + return "", testUserExpired() + } if a.ReviewStatus != "approved" { return "", httpx.Conflict("application_not_available", "This Application isn't available."). With("review_status", a.ReviewStatus) @@ -108,7 +111,7 @@ func (c *Call) mintAppToken(u userRow, sessionID string, a appRow, orgID *string return auth.SignJWT(c.ctx, c.s.Signer, appTokenClaims{ Iss: c.s.Issuer, Aud: a.ID, Sub: u.ID, Sid: sessionID, OrgID: attributed, Email: u.Email, EmailVerified: u.EmailVerified, EntitlementStatus: "active", EffectivePermissions: perms, - TestMode: u.TestMode, Iat: c.now.Unix(), Exp: c.now.Add(appTokenTTL).Unix(), Jti: ids.New(ids.AppTokenJTI), + TestMode: u.TestMode, Iat: c.now.Unix(), Exp: c.now.Add(c.tokenTTL(u, appTokenTTL)).Unix(), Jti: ids.New(ids.AppTokenJTI), }) } @@ -145,7 +148,7 @@ func (s *Server) AuthCreateAppToken(w http.ResponseWriter, r *http.Request) { return err } return c.OK(map[string]any{"token_type": "Bearer", "app_token": tok, - "expires_in": int(appTokenTTL.Seconds()), "application_id": a.ID}) + "expires_in": int(c.tokenTTL(u, appTokenTTL).Seconds()), "application_id": a.ID}) }) } @@ -328,7 +331,7 @@ func (s *Server) AuthExchangeOAuthToken(w http.ResponseWriter, r *http.Request) if err != nil { return err } - return c.OK(oauthTokenResponse(tok, next, a.ID)) + return c.OK(oauthTokenResponse(tok, next, a.ID, c.tokenTTL(u, appTokenTTL))) } tok, err := c.mintAppToken(u, sessionID, a, nil) if err != nil { @@ -339,12 +342,12 @@ func (s *Server) AuthExchangeOAuthToken(w http.ResponseWriter, r *http.Request) db.Args{"h": ids.Hash(rtk), "s": sessionID, "a": a.ID}); err != nil { return err } - return c.OK(oauthTokenResponse(tok, rtk, a.ID)) + return c.OK(oauthTokenResponse(tok, rtk, a.ID, c.tokenTTL(u, appTokenTTL))) }) } -func oauthTokenResponse(tok, refresh, appID string) map[string]any { - return map[string]any{"token_type": "Bearer", "app_token": tok, "expires_in": int(appTokenTTL.Seconds()), +func oauthTokenResponse(tok, refresh, appID string, ttl time.Duration) map[string]any { + return map[string]any{"token_type": "Bearer", "app_token": tok, "expires_in": int(ttl.Seconds()), "refresh_token": refresh, "refresh_token_expires_in": int(sessionIdleTTL.Seconds()), "application_id": appID} } diff --git a/internal/core/auth_session.go b/internal/core/auth_session.go index b590adc..b36a2e5 100644 --- a/internal/core/auth_session.go +++ b/internal/core/auth_session.go @@ -40,12 +40,13 @@ func (c *Call) setAnyMode() error { return err } -// signAccess mints a platform access token for a session. -func (c *Call) signAccess(userID, sessionID string, amr []string, emailVerified, testMode bool) (string, error) { +// signAccess mints a platform access token for a session, never outliving +// a test-mode User's expires_at. +func (c *Call) signAccess(u userRow, sessionID string, amr []string) (string, error) { return auth.SignJWT(c.ctx, c.s.Signer, accessClaims{ - Iss: c.s.Issuer, Aud: platformAudience, Sub: userID, Sid: sessionID, AMR: amr, - EmailVerified: emailVerified, TestMode: testMode, - Iat: c.now.Unix(), Exp: c.now.Add(accessTTL).Unix(), Jti: ids.New(ids.AccessTokenJTI), + Iss: c.s.Issuer, Aud: platformAudience, Sub: u.ID, Sid: sessionID, AMR: amr, + EmailVerified: u.EmailVerified, TestMode: u.TestMode, + Iat: c.now.Unix(), Exp: c.now.Add(c.tokenTTL(u, accessTTL)).Unix(), Jti: ids.New(ids.AccessTokenJTI), }) } @@ -66,7 +67,7 @@ func (c *Call) issueSession(u userRow, amr []string, identityID string) (authSes values (:id, :u, :amr::text[], (case when :ip::text ~ '^[0-9a-fA-F:.]+$' then :ip::inet end), :ua, :tm, :idle::timestamptz, :abs::timestamptz)`, db.Args{"id": sid, "u": u.ID, "amr": db.TextArray(amr), "ip": ipArg, "ua": ua, "tm": u.TestMode, - "idle": db.Time(c.now.Add(sessionIdleTTL)), "abs": db.Time(c.now.Add(sessionAbsTTL))}); err != nil { + "idle": db.Time(c.now.Add(sessionIdleTTL)), "abs": db.Time(c.now.Add(c.tokenTTL(u, sessionAbsTTL)))}); err != nil { return authSession{}, err } if err := c.alertNewSignIn(u.ID, sid, ua, c.ip); err != nil { @@ -86,18 +87,18 @@ func (c *Call) issueSession(u userRow, amr []string, identityID string) (authSes return authSession{}, err } } - at, err := c.signAccess(u.ID, sid, amr, u.EmailVerified, u.TestMode) + at, err := c.signAccess(u, sid, amr) if err != nil { return authSession{}, err } - return authSession{TokenType: "Bearer", AccessToken: at, ExpiresIn: int(accessTTL.Seconds()), + return authSession{TokenType: "Bearer", AccessToken: at, ExpiresIn: int(c.tokenTTL(u, accessTTL).Seconds()), RefreshToken: rtk, RefreshTokenExpiresIn: int(sessionIdleTTL.Seconds()), SessionID: sid, UserID: u.ID, EmailVerified: u.EmailVerified}, nil } // revokeSessions revokes a User's sessions (all, or all but `except`), // and deactivates the push devices they registered. Suspension and -// deletion deactivate every device the User holds, including ones an app +// deletion (and test-mode expiry) deactivate every device the User holds, including ones an app // key registered with no session (Push Notifications → Sessions and // device lifecycle). func (c *Call) revokeSessions(userID, reason, except string) error { @@ -110,6 +111,10 @@ func (c *Call) revokeSessions(userID, reason, except string) error { if err := c.deactivateDevices("d.user_id = :u", db.Args{"u": userID}, "user_deleted"); err != nil { return err } + case "test_user_expired": + if err := c.deactivateDevices("d.user_id = :u", db.Args{"u": userID}, "test_user_expired"); err != nil { + return err + } } sids, err := db.All[string](c.ctx, c.q, `update sessions set revoked_at = now(), revoked_reason = :r where user_id = :u and revoked_at is null and id <> :except returning to_jsonb(id)`, @@ -145,11 +150,12 @@ func weakPassword(err error) error { func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) { s.serve(w, r, opts{op: "auth.signup", public: true, idem: idemOptional}, func(c *Call) error { var in struct { - Email string `json:"email"` - Password string `json:"password"` - DisplayName *string `json:"display_name"` - ApplicationID *string `json:"application_id"` - TestMode bool `json:"test_mode"` + Email string `json:"email"` + Password string `json:"password"` + DisplayName *string `json:"display_name"` + ApplicationID *string `json:"application_id"` + TestMode bool `json:"test_mode"` + ExpiresAt *time.Time `json:"expires_at"` } if err := c.Decode(&in); err != nil { return err @@ -170,6 +176,9 @@ func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) { if in.DisplayName != nil && (len([]rune(*in.DisplayName)) < 1 || len([]rune(*in.DisplayName)) > 100) { f.Max("display_name", "too_long", 100) } + if in.ExpiresAt != nil { + c.checkExpiresAt(&f, *in.ExpiresAt, in.TestMode, c.now) + } if in.ApplicationID != nil { if _, err := c.loadApp(*in.ApplicationID); err == db.ErrNotFound { f.Add("application_id", "unknown_value") @@ -204,7 +213,7 @@ func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) { return err } u, err := c.createUser(newUser{Email: addr, Status: "active", DisplayName: deref(in.DisplayName), - SignupApp: in.ApplicationID, PasswordHash: hash}) + SignupApp: in.ApplicationID, PasswordHash: hash, ExpiresAt: in.ExpiresAt}) if err != nil { return err } @@ -311,6 +320,9 @@ func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) { if u.Status != "active" { return invalidCredentials() } + if u.expired(c.now) { + return testUserExpired() + } if ident.MFAEnabled { tok, err := c.issueToken("mfa_challenge", ids.MFAChallenge, u.ID, mfaChallengeTTL, map[string]any{"identity_id": ident.ID}) @@ -487,6 +499,9 @@ func (s *Server) AuthVerifyMfa(w http.ResponseWriter, r *http.Request) { } return tokenInvalid } + if u.expired(c.now) { + return testUserExpired() + } if err := c.consumeToken(t.TokenHash); err != nil { return err } @@ -595,15 +610,18 @@ func (s *Server) AuthRefreshToken(w http.ResponseWriter, r *http.Request) { if u.Status == "suspended" || u.Status == "deleted" { return httpx.ErrSessionRevoked() } + if u.expired(c.now) { + return testUserExpired() + } next, err := c.rotateRefresh(rt) if err != nil { return err } - at, err := c.signAccess(u.ID, rt.SessionID, rt.AMR, u.EmailVerified, u.TestMode) + at, err := c.signAccess(u, rt.SessionID, rt.AMR) if err != nil { return err } - return c.OK(authSession{TokenType: "Bearer", AccessToken: at, ExpiresIn: int(accessTTL.Seconds()), + return c.OK(authSession{TokenType: "Bearer", AccessToken: at, ExpiresIn: int(c.tokenTTL(u, accessTTL).Seconds()), RefreshToken: next, RefreshTokenExpiresIn: int(sessionIdleTTL.Seconds()), SessionID: rt.SessionID, UserID: u.ID, EmailVerified: u.EmailVerified}) }) diff --git a/internal/core/common.go b/internal/core/common.go index 62c86eb..0756b80 100644 --- a/internal/core/common.go +++ b/internal/core/common.go @@ -160,10 +160,35 @@ type userRow struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LastLoginAt *time.Time `json:"last_login_at"` + ExpiresAt *time.Time `json:"expires_at"` Plan string `json:"plan"` PlanSources []planSource `json:"plan_sources"` Version int `json:"-"` DeletedAt *time.Time `json:"-"` + ExpiredProcessedAt *time.Time `json:"-"` +} + +// expired reports whether a test-mode User's expires_at has passed +// (Auth → Test-mode Users → Lifetime). Live Users never expire. +func (u userRow) expired(now time.Time) bool { + return u.ExpiresAt != nil && !u.ExpiresAt.After(now) +} + +// testUserExpired is the error sign-in, refresh, and app-token issuance +// return for an expired test-mode User. +func testUserExpired() error { + return httpx.E(403, "test_user_expired", "This test-mode User has expired.") +} + +// tokenTTL caps a token's lifetime at the User's expires_at, so no token +// outlives a test-mode User (Auth → Test-mode Users → Lifetime). +func (c *Call) tokenTTL(u userRow, ttl time.Duration) time.Duration { + if u.ExpiresAt != nil { + if left := u.ExpiresAt.Sub(c.now); left < ttl { + return max(left, 0) + } + } + return ttl } // userRowSQL selects a user with its computed mfa_enabled, as the API @@ -171,12 +196,13 @@ type userRow struct { const userRowSQL = `select to_jsonb(u) - 'stripe_customer_id' || jsonb_build_object( 'mfa_enabled', exists (select 1 from user_identities i where i.user_id = u.id and i.mfa_enabled), 'plan_sources', ` + userPlanSQL + `, - 'version_', u.version, 'deleted_at_', u.deleted_at) from users u` + 'version_', u.version, 'deleted_at_', u.deleted_at, 'expired_processed_at_', u.expired_processed_at) from users u` type userRowDB struct { userRow - V int `json:"version_"` - DA *time.Time `json:"deleted_at_"` + V int `json:"version_"` + DA *time.Time `json:"deleted_at_"` + EPA *time.Time `json:"expired_processed_at_"` } func decodeUser(raw []byte) (userRow, error) { @@ -184,7 +210,7 @@ func decodeUser(raw []byte) (userRow, error) { if err := json.Unmarshal(raw, &r); err != nil { return userRow{}, err } - r.userRow.Version, r.userRow.DeletedAt = r.V, r.DA + r.userRow.Version, r.userRow.DeletedAt, r.userRow.ExpiredProcessedAt = r.V, r.DA, r.EPA if r.userRow.PlanSources == nil { r.userRow.PlanSources = []planSource{} } diff --git a/internal/core/principal.go b/internal/core/principal.go index a893c70..a7c3d18 100644 --- a/internal/core/principal.go +++ b/internal/core/principal.go @@ -139,13 +139,15 @@ func (s *Server) authenticateUser(c *Call, tok string) (*Principal, error) { AbsExpires time.Time `json:"absolute_expires_at"` UserStatus string `json:"user_status"` TestMode bool `json:"test_mode"` + ExpiresAt *time.Time `json:"expires_at"` EmailVerified bool `json:"email_verified"` Perms []string `json:"perms"` } err := db.One(c.ctx, c.q, &row, `select jsonb_build_object( 'revoked_at', s.revoked_at, 'idle_expires_at', s.idle_expires_at, 'absolute_expires_at', s.absolute_expires_at, - 'user_status', u.status, 'test_mode', u.test_mode, 'email_verified', u.email_verified, + 'user_status', u.status, 'test_mode', u.test_mode, 'expires_at', u.expires_at, + 'email_verified', u.email_verified, 'perms', coalesce((select array_agg(distinct perm) from user_role_assignments a join roles r on r.id = a.role_id, unnest(r.permissions) perm where a.user_id = u.id and r.scope = 'platform'), '{}')) @@ -160,7 +162,10 @@ func (s *Server) authenticateUser(c *Call, tok string) (*Principal, error) { if row.TestMode != cl.TestMode { return nil, httpx.ErrUnauthenticated() } - if row.RevokedAt != nil || !row.AbsExpires.After(c.now) || row.UserStatus == "suspended" || row.UserStatus == "deleted" { + // An expired test-mode User's tokens stop working at expires_at, to + // the second, before the sweep has revoked anything. + if row.RevokedAt != nil || !row.AbsExpires.After(c.now) || row.UserStatus == "suspended" || row.UserStatus == "deleted" || + (row.ExpiresAt != nil && !row.ExpiresAt.After(c.now)) { return nil, httpx.ErrSessionRevoked() } p := &Principal{Type: "user", ID: cl.Sub, UserID: cl.Sub, SessionID: cl.Sid, AMR: cl.AMR, diff --git a/internal/core/resolve.go b/internal/core/resolve.go index 57f577d..2d2e944 100644 --- a/internal/core/resolve.go +++ b/internal/core/resolve.go @@ -49,6 +49,7 @@ func (c *Call) resolvePairs(pairs []pair) (map[pair]resolved, error) { select jsonb_build_object( 'user_id', req.u, 'application_id', req.a, 'user_status', (select status from users where id = req.u), + 'user_expires_at', (select expires_at from users where id = req.u), 'tenant_id', (select tenant_id from applications where id = req.a), 'paths', coalesce((select jsonb_agg(p) from ( select e.id as entitlement_id, e.source, e.status, null::text as organization_id, @@ -68,11 +69,12 @@ func (c *Call) resolvePairs(pairs []pair) (map[pair]resolved, error) { } for _, raw := range rows { var r struct { - UserID string `json:"user_id"` - AppID string `json:"application_id"` - UserStatus *string `json:"user_status"` - TenantID *string `json:"tenant_id"` - Paths []pathRow `json:"paths"` + UserID string `json:"user_id"` + AppID string `json:"application_id"` + UserStatus *string `json:"user_status"` + ExpiresAt *time.Time `json:"user_expires_at"` + TenantID *string `json:"tenant_id"` + Paths []pathRow `json:"paths"` } if err := json.Unmarshal(raw, &r); err != nil { return nil, err @@ -91,8 +93,11 @@ func (c *Call) resolvePairs(pairs []pair) (map[pair]resolved, error) { if r.UserStatus != nil { status = *r.UserStatus } + // An expired test-mode User has no access, like a suspended one. + expired := r.ExpiresAt != nil && !r.ExpiresAt.After(c.now) out[pair{r.UserID, r.AppID}] = resolved{ - UserStatus: status, Res: res, Paths: paths, Allowed: access.Allowed(status, res), TenantID: deref(r.TenantID), + UserStatus: status, Res: res, Paths: paths, Allowed: access.Allowed(status, res) && !expired, + TenantID: deref(r.TenantID), } } return out, nil diff --git a/internal/core/subscriptions.go b/internal/core/subscriptions.go index 5864ff1..4ea25a8 100644 --- a/internal/core/subscriptions.go +++ b/internal/core/subscriptions.go @@ -114,11 +114,12 @@ const userPlanSQL = `(select coalesce(jsonb_agg(jsonb_build_object('type', src.t ) src)` // coveredMembersSQL counts an Organization subscription's covered members: -// active Users with an active membership, included by the member -// selection, in the subscription's own mode. +// active, unexpired Users with an active membership, included by the +// member selection, in the subscription's own mode. const coveredMembersSQL = `select to_jsonb(count(*)) from subscriptions s join organization_memberships m on m.organization_id = s.organization_id and m.status = 'active' join users u on u.id = m.user_id and u.status = 'active' and u.test_mode = s.test_mode + and (u.expires_at is null or u.expires_at > :now::timestamptz) where s.id = :s and (s.member_scope = 'all_members' or (s.member_scope = 'allowlist' and m.user_id = any(s.member_overrides)) @@ -126,7 +127,7 @@ const coveredMembersSQL = `select to_jsonb(count(*)) from subscriptions s func (c *Call) coveredMembers(subID string) (int, error) { var n int - err := db.One(c.ctx, c.q, &n, coveredMembersSQL, db.Args{"s": subID}) + err := db.One(c.ctx, c.q, &n, coveredMembersSQL, db.Args{"s": subID, "now": db.Time(c.now)}) return n, err } diff --git a/internal/core/system.go b/internal/core/system.go index b700700..df71a73 100644 --- a/internal/core/system.go +++ b/internal/core/system.go @@ -123,6 +123,63 @@ func (s *Server) SweepEntitlements(ctx context.Context) (expired, started int, e return } +// ── Test-mode User expiry (runs with the entitlement sweep) ─────────── + +// ExpireTestUsers processes every test-mode User whose expires_at has +// passed, exactly once each (Auth → Test-mode Users → Lifetime). Their +// tokens already stopped working at expires_at; this revokes sessions and +// devices, fires access.revoked and user.expired, and writes the audit +// event. status, Entitlements, Roles, and memberships are untouched. +func (s *Server) ExpireTestUsers(ctx context.Context) (n int, err error) { + err = s.system(ctx, true, db.Settings{}, func(c *Call) error { + uids, err := db.All[string](ctx, c.q, `select to_jsonb(id) from users + where test_mode and expires_at <= :now::timestamptz and expired_processed_at is null + order by expires_at limit 500 for update skip locked`, db.Args{"now": db.Time(c.now)}) + if err != nil { + return err + } + for _, id := range uids { + u, err := c.findUser("u.id = :id", db.Args{"id": id}) + if err != nil { + return err + } + pairs, err := c.userAppPairs(u.ID) + if err != nil { + return err + } + // Resolution already treats the User as expired, so "before" + // is resolved as of the instant before expires_at. + now := c.now + c.now = u.ExpiresAt.Add(-time.Nanosecond) + watch, err := c.watchAccess(pairs) + c.now = now + if err != nil { + return err + } + if _, err := c.q.Exec(ctx, `update users set expired_processed_at = :now::timestamptz where id = :u`, + db.Args{"u": u.ID, "now": db.Time(c.now)}); err != nil { + return err + } + if err := c.revokeSessions(u.ID, "test_user_expired", ""); err != nil { + return err + } + if err := c.flip(watch, "test_user_extended", "test_user_expired"); err != nil { + return err + } + if err := c.emit("user.expired", nil, map[string]any{"user_id": u.ID, "expires_at": u.ExpiresAt}); err != nil { + return err + } + if err := c.audit(Audit{Action: "user.expired", TargetType: "user", TargetID: u.ID, TargetUserID: &u.ID, + After: map[string]any{"expires_at": u.ExpiresAt}}); err != nil { + return err + } + n++ + } + return nil + }) + return +} + // ── Erasure cascade (NFR → Hard-delete cascade) ──────────────────────── // RunErasures runs every scheduled erasure whose 7 days have passed. Each diff --git a/internal/core/test_user_expiry_test.go b/internal/core/test_user_expiry_test.go new file mode 100644 index 0000000..594d8f5 --- /dev/null +++ b/internal/core/test_user_expiry_test.go @@ -0,0 +1,236 @@ +package core_test + +import ( + "context" + "testing" + "time" + + "github.com/CompositeCode/substratalapps.com/internal/db" + "github.com/CompositeCode/substratalapps.com/internal/testenv" +) + +const testUserPass = "a long enough test passphrase" + +// fieldCode is the code of a 422's first field error. +func fieldCode(t *testing.T, r *testenv.Resp) string { + t.Helper() + fields := r.JSON()["error"].(map[string]any)["details"].(map[string]any)["fields"].([]any) + return fields[0].(map[string]any)["code"].(string) +} + +// setExpiry moves a User's expires_at directly, so a test can put it in +// the past without waiting. +func setExpiry(t *testing.T, e *testenv.Env, userID string, at time.Time) { + t.Helper() + err := e.DB.Tx(context.Background(), db.Settings{AllModes: true}, func(q db.Querier) error { + _, err := q.Exec(context.Background(), `update users set expires_at = :t::timestamptz where id = :u`, + db.Args{"u": userID, "t": db.Time(at)}) + return err + }) + if err != nil { + t.Fatalf("set expiry: %v", err) + } +} + +// userEvents is the outbox events of a type about one User. +func userEvents(e *testenv.Env, eventType, userID string) []map[string]any { + var out []map[string]any + for _, ev := range e.Events(eventType) { + if ev["payload"].(map[string]any)["user_id"] == userID { + out = append(out, ev) + } + } + return out +} + +func expiresAt(t *testing.T, u map[string]any) time.Time { + t.Helper() + s, ok := u["expires_at"].(string) + if !ok { + t.Fatalf("expires_at = %v", u["expires_at"]) + } + at, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t.Fatalf("expires_at: %v", err) + } + return at +} + +// Auth → Test-mode Users → Lifetime: creation defaults and range checks. +func TestTestUserExpiryCreation(t *testing.T) { + e := testenv.New(t) + testKey := e.Post("/api-keys", e.Admin, map[string]any{"name": "qa", "scope": "platform", "mode": "test", + "permissions": []string{"users.manage", "users.list"}}).Expect(201).Str("secret") + liveKey := e.Post("/api-keys", e.Admin, map[string]any{"name": "live", "scope": "platform", + "permissions": []string{"users.manage", "users.list"}}).Expect(201).Str("secret") + addr := func() string { return "qa+" + testenv.Unique() + "@example.com" } + + // Default: 24 hours, through signup and through POST /users. + s := e.Post("/auth/signup", "", map[string]any{"email": addr(), "password": testUserPass, "test_mode": true}).Expect(201) + if d := time.Until(expiresAt(t, e.Get("/users/me", s.Token()).Expect(200).JSON())); d < 23*time.Hour || d > 24*time.Hour { + t.Fatalf("signup default lifetime = %v", d) + } + u := e.Post("/users", testKey, map[string]any{"email": addr(), "status": "active", "send_invitation": false}).Expect(201) + if d := time.Until(expiresAt(t, u.JSON())); d < 23*time.Hour || d > 24*time.Hour { + t.Fatalf("create default lifetime = %v", d) + } + // Live Users never expire. + if live := e.Signup(); e.Get("/users/me", live.Token).JSON()["expires_at"] != nil { + t.Fatal("a live User has expires_at") + } + + // A chosen lifetime is kept. + want := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second) + u = e.Post("/users", testKey, map[string]any{"email": addr(), "status": "active", "send_invitation": false, + "expires_at": want.Format(time.RFC3339)}).Expect(201) + if got := expiresAt(t, u.JSON()); !got.Equal(want) { + t.Fatalf("expires_at = %v, want %v", got, want) + } + + // Out of range, either way, and on a live User. + soon := time.Now().Add(5 * time.Minute).Format(time.RFC3339) + late := time.Now().Add(31 * 24 * time.Hour).Format(time.RFC3339) + r := e.Post("/auth/signup", "", map[string]any{"email": addr(), "password": testUserPass, "test_mode": true, + "expires_at": soon}).ExpectErr(422, "validation_failed") + if c := fieldCode(t, r); c != "expires_at_out_of_range" { + t.Fatalf("too soon: %s", c) + } + r = e.Post("/users", testKey, map[string]any{"email": addr(), "expires_at": late}).ExpectErr(422, "validation_failed") + if c := fieldCode(t, r); c != "expires_at_out_of_range" { + t.Fatalf("too late: %s", c) + } + r = e.Post("/users", liveKey, map[string]any{"email": addr(), "expires_at": want.Format(time.RFC3339)}). + ExpectErr(422, "validation_failed") + if c := fieldCode(t, r); c != "live_user" { + t.Fatalf("live create: %s", c) + } + r = e.Post("/auth/signup", "", map[string]any{"email": addr(), "password": testUserPass, + "expires_at": want.Format(time.RFC3339)}).ExpectErr(422, "validation_failed") + if c := fieldCode(t, r); c != "live_user" { + t.Fatalf("live signup: %s", c) + } +} + +// Expiry: tokens capped and then rejected, the sweep's effects exactly +// once, and extension bringing the User back. +func TestTestUserExpiry(t *testing.T) { + e := testenv.New(t) + ctx := context.Background() + app := e.App("usr_01JAG0SYSTEM00000000000000", nil) + testKey := e.Post("/api-keys", e.Admin, map[string]any{"name": "qa", "scope": "platform", "mode": "test", + "permissions": []string{"users.manage", "users.list", "entitlements.manage"}}).Expect(201).Str("secret") + addr := "qa+" + testenv.Unique() + "@example.com" + s := e.Post("/auth/signup", "", map[string]any{"email": addr, "password": testUserPass, "test_mode": true}).Expect(201) + uid := s.Str("user_id") + e.PostIdem("/users/"+uid+"/entitlements", testKey, testenv.Unique(), + map[string]any{"application_id": app, "source": "admin_grant"}).Expect(201) + e.Post("/auth/app-tokens", s.Token(), map[string]any{"application_id": app}).Expect(200) + + // Close to expiry, no token outlives the User. + near := time.Now().Add(2 * time.Minute).UTC().Truncate(time.Second) + setExpiry(t, e, uid, near) + r := e.Post("/auth/token/refresh", "", map[string]any{"refresh_token": s.Str("refresh_token")}).Expect(200) + if in := int(r.JSON()["expires_in"].(float64)); in > 120 { + t.Fatalf("access expires_in = %d", in) + } + at, refresh := r.Token(), r.Str("refresh_token") + appTok := e.Post("/auth/app-tokens", at, map[string]any{"application_id": app}).Expect(200) + if exp := int64(decodeJWT(t, e, appTok.Str("app_token"))["exp"].(float64)); exp > near.Unix() { + t.Fatalf("app token exp %d is after expires_at %d", exp, near.Unix()) + } + + // At expiry every token stops working, before any sweep. + setExpiry(t, e, uid, time.Now()) + e.Get("/users/me", at).ExpectErr(401, "session_revoked") + e.Post("/auth/login", "", map[string]any{"email": addr, "password": testUserPass, "test_mode": true}). + ExpectErr(403, "test_user_expired") + e.Post("/auth/token/refresh", "", map[string]any{"refresh_token": refresh}).ExpectErr(403, "test_user_expired") + if eff := e.Get("/users/"+uid+"/apps/"+app+"/effective-permissions", testKey).Expect(200).JSON(); eff["allowed"] != false { + t.Fatalf("introspection after expiry = %v", eff) + } + + // The sweep's effects happen exactly once. + for range 2 { + if _, err := e.Core.ExpireTestUsers(ctx); err != nil { + t.Fatalf("sweep: %v", err) + } + } + revoked := userEvents(e, "access.revoked", uid) + if len(revoked) != 1 || revoked[0]["payload"].(map[string]any)["reason"] != "test_user_expired" { + t.Fatalf("access.revoked = %v", revoked) + } + if ev := userEvents(e, "user.expired", uid); len(ev) != 1 || ev[0]["test_mode"] != true { + t.Fatalf("user.expired = %v", ev) + } + var state struct { + Live int `json:"live_sessions"` + Expired int `json:"expired_sessions"` + Audit int `json:"audit"` + } + err := e.DB.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + return db.One(ctx, q, &state, `select jsonb_build_object( + 'live_sessions', (select count(*) from sessions where user_id = :u and revoked_at is null), + 'expired_sessions', (select count(*) from sessions where user_id = :u and revoked_reason = 'test_user_expired'), + 'audit', (select count(*) from audit_events where target_id = :u and action = 'user.expired' and actor_type = 'system'))`, + db.Args{"u": uid}) + }) + if err != nil || state.Live != 0 || state.Expired == 0 || state.Audit != 1 { + t.Fatalf("after sweep: %+v %v", state, err) + } + + // A self-service caller can't move expires_at; users.manage can, and + // moving it later brings the User back. + later := time.Now().Add(time.Hour).UTC().Truncate(time.Second).Format(time.RFC3339) + u := e.Patch("/users/"+uid, testKey, map[string]any{"expires_at": later}).Expect(200).JSON() + if expiresAt(t, u).Format(time.RFC3339) != later { + t.Fatalf("expires_at = %v", u["expires_at"]) + } + granted := userEvents(e, "access.granted", uid) + if len(granted) == 0 || granted[0]["payload"].(map[string]any)["reason"] != "test_user_extended" { + t.Fatalf("access.granted = %v", granted) + } + back := e.Post("/auth/login", "", map[string]any{"email": addr, "password": testUserPass, "test_mode": true}).Expect(200) + e.Post("/auth/app-tokens", back.Token(), map[string]any{"application_id": app}).Expect(200) + e.Patch("/users/me", back.Token(), map[string]any{"expires_at": later}).ExpectErr(403, "status_change_forbidden") + + // The sweep leaves an extended User alone. + if _, err := e.Core.ExpireTestUsers(ctx); err != nil { + t.Fatalf("sweep: %v", err) + } + if n := len(userEvents(e, "user.expired", uid)); n != 1 { + t.Fatalf("user.expired fired %d times", n) + } +} + +// Migration 0009's backfill: existing test Users expire when the 30-day +// purge would have removed them, and live Users never do. +func TestTestUserExpiryBackfill(t *testing.T) { + d := testenv.DatabaseThrough(t, "0008") + ctx := context.Background() + created := time.Now().Add(-48 * time.Hour).UTC().Truncate(time.Second) + err := d.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + _, err := q.Exec(ctx, `insert into users (id, email, status, test_mode, created_at) values + ('usr_backfill_test', 'backfill-test@example.com', 'active', true, :c::timestamptz), + ('usr_backfill_live', 'backfill-live@example.com', 'active', false, :c::timestamptz)`, + db.Args{"c": db.Time(created)}) + return err + }) + if err != nil { + t.Fatalf("seed: %v", err) + } + testenv.MigrateRest(t, d) + var got map[string]*time.Time + err = d.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + return db.One(ctx, q, &got, `select jsonb_object_agg(id, expires_at) from users + where id in ('usr_backfill_test', 'usr_backfill_live')`, nil) + }) + if err != nil { + t.Fatalf("read: %v", err) + } + if want := created.Add(30 * 24 * time.Hour); got["usr_backfill_test"] == nil || !got["usr_backfill_test"].Equal(want) { + t.Fatalf("test User expires_at = %v, want %v", got["usr_backfill_test"], want) + } + if got["usr_backfill_live"] != nil { + t.Fatalf("live User expires_at = %v", got["usr_backfill_live"]) + } +} diff --git a/internal/core/users.go b/internal/core/users.go index 9312006..afedbbc 100644 --- a/internal/core/users.go +++ b/internal/core/users.go @@ -120,10 +120,11 @@ func (s *Server) UsersCreate(w http.ResponseWriter, r *http.Request, params gen. return err } var in struct { - Email string `json:"email"` - DisplayName *string `json:"display_name"` - Status *string `json:"status"` - SendInvitation *bool `json:"send_invitation"` + Email string `json:"email"` + DisplayName *string `json:"display_name"` + Status *string `json:"status"` + SendInvitation *bool `json:"send_invitation"` + ExpiresAt *time.Time `json:"expires_at"` } if err := c.Decode(&in); err != nil { return err @@ -145,6 +146,9 @@ func (s *Server) UsersCreate(w http.ResponseWriter, r *http.Request, params gen. if in.DisplayName != nil && len([]rune(*in.DisplayName)) > 100 { f.Max("display_name", "too_long", 100) } + if in.ExpiresAt != nil { + c.checkExpiresAt(&f, *in.ExpiresAt, c.testMode(), c.now) + } if err := f.Err(); err != nil { return err } @@ -153,7 +157,8 @@ func (s *Server) UsersCreate(w http.ResponseWriter, r *http.Request, params gen. } else if taken { return httpx.Conflict("email_taken", "A user with this email already exists.") } - u, err := c.createUser(newUser{Email: addr, Status: status, DisplayName: deref(in.DisplayName)}) + u, err := c.createUser(newUser{Email: addr, Status: status, DisplayName: deref(in.DisplayName), + ExpiresAt: in.ExpiresAt}) if err != nil { return err } @@ -213,8 +218,9 @@ func (s *Server) UsersUpdate(w http.ResponseWriter, r *http.Request, id gen.User return err } var f httpx.Fields - p.RequireNonNull(&f, "email", "status") + p.RequireNonNull(&f, "email", "status", "expires_at") var newEmail, newStatus string + var newExpires *time.Time if p.Has("email") && !p.IsNull("email") { var e string if p.Get("email", &e, &f) { @@ -237,6 +243,16 @@ func (s *Server) UsersUpdate(w http.ResponseWriter, r *http.Request, id gen.User } } } + if p.Has("expires_at") && !p.IsNull("expires_at") { + if !admin { + return httpx.E(403, "status_change_forbidden", "Only users.manage can change a User's expires_at.") + } + var t time.Time + if p.Get("expires_at", &t, &f) { + c.checkExpiresAt(&f, t, u.TestMode, u.CreatedAt) + newExpires = &t + } + } if err := f.Err(); err != nil { return err } @@ -295,6 +311,11 @@ func (s *Server) UsersUpdate(w http.ResponseWriter, r *http.Request, id gen.User "A change of your account's email address to "+newEmail+" was requested. If this wasn't you, contact support.") } } + if newExpires != nil && !newExpires.Equal(*u.ExpiresAt) { + if err := c.setUserExpiry(u, *newExpires); err != nil { + return err + } + } after, err := c.findUser("u.id = :id", db.Args{"id": u.ID}) if err != nil { return err @@ -304,6 +325,36 @@ func (s *Server) UsersUpdate(w http.ResponseWriter, r *http.Request, id gen.User }) } +// setUserExpiry moves a test-mode User's expires_at (already range-checked). +// The new value is always in the future, so a User the expiry sweep has +// already processed is brought back: access.granted fires for every +// Application they still have active access to (Auth → Test-mode Users → +// Lifetime). One that expired but wasn't swept yet never saw +// access.revoked, so it gets no access.granted either. +func (c *Call) setUserExpiry(u userRow, t time.Time) error { + var watch *accessWatch + if u.ExpiredProcessedAt != nil { + pairs, err := c.userAppPairs(u.ID) + if err != nil { + return err + } + if watch, err = c.watchAccess(pairs); err != nil { + return err + } + } + if _, err := c.q.Exec(c.ctx, `update users set expires_at = :t::timestamptz, expired_processed_at = null where id = :u`, + db.Args{"u": u.ID, "t": db.Time(t)}); err != nil { + return err + } + if watch != nil { + if err := c.flip(watch, "test_user_extended", "test_user_expired"); err != nil { + return err + } + } + return c.audit(Audit{Action: "user.updated", TargetType: "user", TargetID: u.ID, TargetUserID: &u.ID, + Before: map[string]any{"expires_at": u.ExpiresAt}, After: map[string]any{"expires_at": t}}) +} + // transitionUser applies a users.manage status change (Users → PATCH // transition table), with its sessions, events, and audit. // guardProtected refuses any change that would lock out the protected diff --git a/internal/core/users_create.go b/internal/core/users_create.go index a0e18cf..14ccafb 100644 --- a/internal/core/users_create.go +++ b/internal/core/users_create.go @@ -3,6 +3,7 @@ package core import ( "net/mail" "strings" + "time" "github.com/CompositeCode/substratalapps.com/internal/db" "github.com/CompositeCode/substratalapps.com/internal/httpx" @@ -20,6 +21,26 @@ type newUser struct { PasswordHash string ActorType string // system for signup ActorID string + ExpiresAt *time.Time // test mode only; nil means the default lifetime +} + +// Test-mode User lifetime (Auth → Test-mode Users → Lifetime). +const ( + testUserDefaultTTL = 24 * time.Hour + testUserMinTTL = 15 * time.Minute + testUserMaxAge = 30 * 24 * time.Hour // the test-data purge horizon +) + +// checkExpiresAt validates a requested expires_at for a User created at +// createdAt (now, for a new User): live Users can't have one, and a test +// User's must be at least 15 minutes away and within 30 days of creation. +func (c *Call) checkExpiresAt(f *httpx.Fields, t time.Time, testMode bool, createdAt time.Time) { + switch { + case !testMode: + f.Add("expires_at", "live_user") + case t.Before(c.now.Add(testUserMinTTL)) || t.After(createdAt.Add(testUserMaxAge)): + f.Add("expires_at", "expires_at_out_of_range") + } } // validEmail checks an address's syntax and normalizes it. @@ -53,9 +74,19 @@ func (c *Call) createUser(n newUser) (userRow, error) { display = n.Email[:strings.Index(n.Email, "@")] } tm := c.testMode() - if _, err := c.q.Exec(c.ctx, `insert into users (id, email, email_verified, status, signup_application_id, test_mode) - values (:id, :e, :v, :st, :app, :tm)`, - db.Args{"id": id, "e": n.Email, "v": n.EmailVerify, "st": n.Status, "app": n.SignupApp, "tm": tm}); err != nil { + var expires any + if tm { + t := c.now.Add(testUserDefaultTTL) + if n.ExpiresAt != nil { + t = *n.ExpiresAt + } + expires = db.Time(t) + } + if _, err := c.q.Exec(c.ctx, `insert into users (id, email, email_verified, status, signup_application_id, test_mode, + created_at, expires_at) + values (:id, :e, :v, :st, :app, :tm, :now::timestamptz, :exp::timestamptz)`, + db.Args{"id": id, "e": n.Email, "v": n.EmailVerify, "st": n.Status, "app": n.SignupApp, "tm": tm, + "now": db.Time(c.now), "exp": expires}); err != nil { if _, ok := db.UniqueViolation(err); ok { return userRow{}, httpx.Conflict("email_taken", "A user with this email already exists.") } diff --git a/internal/core/webhooks.go b/internal/core/webhooks.go index 618cc9a..49aabc4 100644 --- a/internal/core/webhooks.go +++ b/internal/core/webhooks.go @@ -21,7 +21,7 @@ var WebhookEventTypes = []string{ "access.granted", "access.revoked", "entitlement.granted", "entitlement.disabled", "entitlement.revoked", "entitlement.expired", "entitlement.updated", "entitlement.member_scope_changed", "entitlement.deleted", - "role.assigned", "role.removed", "user.suspended", "user.reactivated", "user.deleted", + "role.assigned", "role.removed", "user.suspended", "user.reactivated", "user.deleted", "user.expired", "organization.member_added", "organization.member_removed", "application.review_status_changed", "device.registered", "device.deactivated", "device.deleted", "notification.push_failed", "push_credential.failed", "webhook.test", diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index f1ac082..42d013e 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -45,6 +45,11 @@ func (r *Runner) Jobs() map[string]func(context.Context) error { "entitlement-sweep": func(ctx context.Context) error { exp, started, err := r.Core.SweepEntitlements(ctx) r.Base.Log.Info("entitlement sweep", "expired", exp, "started", started) + if err != nil { + return err + } + users, err := r.Core.ExpireTestUsers(ctx) + r.Base.Log.Info("test user expiry", "expired", users) return err }, // every minute: the safety net under SQS nudges, for webhooks and diff --git a/internal/mcp/tools.json b/internal/mcp/tools.json index 35653b9..229f0c4 100644 --- a/internal/mcp/tools.json +++ b/internal/mcp/tools.json @@ -1024,6 +1024,7 @@ "user.suspended", "user.reactivated", "user.deleted", + "user.expired", "user.erasure_requested", "user.erasure_cancelled", "user.erased", @@ -1987,6 +1988,11 @@ "format": "email", "type": "string" }, + "expires_at": { + "description": "Test mode only: when the account stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user when test_mode is false.", + "format": "date-time", + "type": "string" + }, "password": { "format": "password", "maxLength": 128, @@ -5811,6 +5817,11 @@ "format": "email", "type": "string" }, + "expires_at": { + "description": "Test-mode keys only: when the User stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user with a live key.", + "format": "date-time", + "type": "string" + }, "send_invitation": { "default": true, "type": "boolean" @@ -6169,6 +6180,11 @@ "format": "email", "type": "string" }, + "expires_at": { + "description": "users.manage only, test-mode Users only. At least 15 minutes from now and at most 30 days after created_at. Moving it later on an expired User brings them back and fires access.granted (test_user_extended).", + "format": "date-time", + "type": "string" + }, "status": { "description": "users.manage only. active on a deleted User restores it.", "enum": [ @@ -6244,6 +6260,7 @@ "user.suspended", "user.reactivated", "user.deleted", + "user.expired", "organization.member_added", "organization.member_removed", "application.review_status_changed", diff --git a/internal/testenv/testenv.go b/internal/testenv/testenv.go index 5b2bafe..95c8774 100644 --- a/internal/testenv/testenv.go +++ b/internal/testenv/testenv.go @@ -87,7 +87,11 @@ func withDB(u, name string) string { } // newDatabase creates and migrates a fresh database. -func newDatabase() (string, error) { +func newDatabase() (string, error) { return newDatabaseThrough("") } + +// newDatabaseThrough creates a fresh database migrated through version +// ("" for every migration). +func newDatabaseThrough(version string) (string, error) { ctx := context.Background() admin, err := pgx.Connect(ctx, baseURL()) if err != nil { @@ -110,12 +114,51 @@ func newDatabase() (string, error) { if err != nil { return "", err } + if version != "" { + var through []migrate.Migration + for _, m := range all { + if m.Version <= version { + through = append(through, m) + } + } + all = through + } if _, err := migrate.Up(ctx, d, all); err != nil { return "", err } return u, nil } +// DatabaseThrough opens a fresh database migrated only through version, to +// test a later migration's backfill. MigrateRest applies the others. The +// database is dropped when the test ends. +func DatabaseThrough(t testing.TB, version string) db.DB { + t.Helper() + u, err := newDatabaseThrough(version) + if err != nil { + t.Fatalf("%v", err) + } + t.Cleanup(func() { dropDatabase(u) }) + d, err := pg.Open(context.Background(), u) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(d.Close) + return d +} + +// MigrateRest applies every migration not yet applied to d. +func MigrateRest(t testing.TB, d db.DB) { + t.Helper() + all, err := migrate.Load(migrations.FS) + if err != nil { + t.Fatalf("%v", err) + } + if _, err := migrate.Up(context.Background(), d, all); err != nil { + t.Fatalf("migrate: %v", err) + } +} + func dropDatabase(u string) { ctx := context.Background() admin, err := pgx.Connect(ctx, baseURL()) diff --git a/migrations/0009_test_user_expiry.sql b/migrations/0009_test_user_expiry.sql new file mode 100644 index 0000000..24c3efb --- /dev/null +++ b/migrations/0009_test_user_expiry.sql @@ -0,0 +1,51 @@ +-- 0009_test_user_expiry: test-mode Users are time-limited +-- (Auth → Test-mode Users → Lifetime). +-- +-- expires_at is set on every test-mode User and null on every live one. +-- Tokens stop working at expires_at (checked per request); the 5-minute +-- entitlement-sweep then revokes sessions and devices, fires the events, +-- and sets expired_processed_at so it does that exactly once. + +-- Numbered after 0008_scim_tokens, which reached production first. The +-- audit action list is the full set, including actions from migrations +-- that may land after this one (self-registered apps, audit exports), so +-- merge order can't drop any. + +-- users has forced Row-Level Security; see both modes for the backfill. +select set_config('app.current_test_mode', 'any', true); + +alter table users add column expires_at timestamptz, add column expired_processed_at timestamptz; + +-- Existing test Users expire when the 30-day purge would have removed +-- them anyway, so nothing changes for them. +update users set expires_at = created_at + interval '30 days' where test_mode; + +alter table users + add constraint users_expires_at_test_mode check ((expires_at is not null) = test_mode), + add constraint users_expires_at_max check (expires_at <= created_at + interval '30 days'); + +create index users_test_expiry on users (expires_at) where test_mode and expired_processed_at is null; + +alter table devices drop constraint devices_inactive_reason_check; +alter table devices add constraint devices_inactive_reason_check check (inactive_reason in + ('unregistered','session_revoked','user_suspended','user_deleted','test_user_expired','stale','limit','credential_removed')); + +alter table audit_events drop constraint audit_events_action_check; +alter table audit_events add constraint audit_events_action_check check (action in ( + 'user.created','user.updated','user.email_changed','user.suspended','user.reactivated', + 'user.deleted','user.expired','user.erasure_requested','user.erasure_cancelled','user.erased', + 'user.password_changed','user.password_reset','user.mfa_enabled','user.mfa_disabled','user.mfa_reset', + 'entitlement.granted','entitlement.disabled','entitlement.revoked','entitlement.expired', + 'entitlement.updated','entitlement.member_scope_changed','entitlement.deleted', + 'role.created','role.updated','role.deleted','role.assigned','role.removed', + 'profile.updated','settings.updated', + 'application.created','application.updated','application.review_status_changed', + 'organization.created','organization.updated','organization.member_invited','organization.member_added', + 'organization.member_removed','organization.member_role_changed', + 'organization.scim_token_created','organization.scim_token_revoked', + 'subscription.created','subscription.status_changed','subscription.members_changed', + 'tenant.tier_change_requested','tenant.tier_change_request_updated','tenant.tier_changed', + 'api_key.created','api_key.updated','api_key.rotated','api_key.revoked','api_key.restrict_destructive_disabled', + 'webhook.created','webhook.updated','webhook.deleted','webhook.secret_rotated', + 'push_credential.created','push_credential.updated','push_credential.deleted','device.deleted', + 'audit.exported')) not valid; diff --git a/plan-remaining.md b/plan-remaining.md index eb3dcf6..0d87ea9 100644 --- a/plan-remaining.md +++ b/plan-remaining.md @@ -6,20 +6,19 @@ This replaces `PLAN.md`, `DEPLOYMENT.md`, and `ORIGINAL-SPEC-DRAFT.md`, which we 1. [Status](#status) 2. [Verify the build in production](#verify-the-build-in-production) -3. [Test-mode User expiry](#test-mode-user-expiry) -4. [First-party web pages](#first-party-web-pages) -5. [Phase 3: marketplace and scale features](#phase-3-marketplace-and-scale-features) -6. [Dedicated tenancy infrastructure](#dedicated-tenancy-infrastructure) -7. [Growth trajectory](#growth-trajectory) -8. [Scale-out](#scale-out) -9. [Scaling the MCP server](#scaling-the-mcp-server) -10. [Rules for every change](#rules-for-every-change) +3. [First-party web pages](#first-party-web-pages) +4. [Phase 3: marketplace and scale features](#phase-3-marketplace-and-scale-features) +5. [Dedicated tenancy infrastructure](#dedicated-tenancy-infrastructure) +6. [Growth trajectory](#growth-trajectory) +7. [Scale-out](#scale-out) +8. [Scaling the MCP server](#scaling-the-mcp-server) +9. [Rules for every change](#rules-for-every-change) --- ## Status -As of 2026-10-20, every operation in `openapi.yaml` is implemented, including subscriber billing, push notifications, and the hosted PKCE endpoints. SSO returns `501 not_implemented` until the broker integration, as specified. Production infrastructure exists in AWS (`substratal-prod`, `us-east-1`); the first deploy is finishing (see the [`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc)). No product decisions are open: each is written into the spec page it governs and recorded in the [Changelog](https://compositecode.github.io/substratalapps.com/changelog/). +As of 2026-10-22, every operation in `openapi.yaml` is implemented, including subscriber billing, push notifications, the hosted PKCE endpoints, and test-mode User expiry. SSO returns `501 not_implemented` until the broker integration, as specified. Production infrastructure exists in AWS (`substratal-prod`, `us-east-1`); the first deploy is finishing (see the [`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc)). No product decisions are open: each is written into the spec page it governs and recorded in the [Changelog](https://compositecode.github.io/substratalapps.com/changelog/). ## Verify the build in production @@ -29,21 +28,6 @@ The phase exit criteria have only been checked against the local devserver. Once - **Phase 1:** the [Quickstart](https://compositecode.github.io/substratalapps.com/quickstart/) runs end to end against production: create a user, grant an app, check access, turn it off. Run `scripts/quickstart.sh` with `SUBSTRATAL_API=https://api.substratalapps.com` and a test-mode key. - **Phase 2:** a third-party-shaped app signs a user in through the embedded flow, receives `access.revoked` and kills the session, calls introspection, has its `settings_schema` validated and typed on write, and receives a push sent through its app key. A test-mode User and a test-mode Organization subscribe to Standard through Checkout (needs a Stripe test-mode key in a non-production environment, or temporarily in production), gain the standard Applications, and lapse back to Free. -## Test-mode User expiry - -Specified 2026-10-20 ([Auth → Test-mode Users → Lifetime](https://compositecode.github.io/substratalapps.com/api-reference/auth/#lifetime), [Changelog](https://compositecode.github.io/substratalapps.com/changelog/)); **not yet built**. Each step can land and be tested on its own, in this order: - -1. **Migration `0005_test_user_expiry`** (`0004` is taken by `0004_protected_superadmin`). Add `users.expires_at` and `users.expired_processed_at`. Backfill existing test-mode Users with `created_at + interval '30 days'`, which is when the purge would have removed them anyway, so nothing changes for them. Then add `check ((expires_at is not null) = test_mode)` and `check (expires_at <= created_at + interval '30 days')`, and the partial index `(expires_at) where test_mode and expired_processed_at is null`. Add `test_user_expired` to the `sessions.revoked_reason` and `devices.inactive_reason` checks. -2. **Creation** (`internal/core/users_create.go`, `createUser`). Test-mode Users get `expires_at` from the request, or now + 24 hours by default. Validate the range (≥ 15 minutes from now, ≤ `created_at` + 30 days) as `422 validation_failed` with `expires_at_out_of_range`, and reject it on a live create with `live_user`. This covers both `POST /users` and test-mode signup. -3. **Enforcement at the edge** (`internal/core/principal.go`, the per-request User lookup). Load `expires_at` alongside `status`, and reject an expired test-mode User's tokens the same way a suspended User's are. Login, `token/refresh`, `app-tokens`, `oauth/token`, and the `mfa/verify` step return `403 test_user_expired`. This is what makes expiry exact to the second. The sweep below only tidies up. -4. **Token lifetimes** (`internal/core/auth_session.go`). Cap the access token's `exp`, the app token's `exp`, and the session's `absolute_expires_at` at the User's `expires_at`, so Applications that verify app tokens locally against JWKS stop accepting them on time without calling the API. -5. **`PATCH /users/{id}` `expires_at`** (`users.manage` only; self → `403 status_change_forbidden`). Same range check. Moving it later on an expired User clears `expired_processed_at` and fires `access.granted` (reason `test_user_extended`) for every Application they still have active access to. Writes `user.updated`. -6. **Expiry sweep**, in the existing `entitlement-sweep` job (every 5 minutes, no new schedule). Select `test_mode and expires_at <= now() and expired_processed_at is null`. For each, in one transaction: revoke sessions (`test_user_expired`), deactivate devices, fire `access.revoked` (reason `test_user_expired`) per Application with active access, enqueue the `user.expired` webhook, write the `user.expired` Audit Event (actor `system`), and set `expired_processed_at`. Reuse the suspension path's revoke-and-notify code, with the reason passed in. -7. **Subscriptions.** The test-mode covered-member count excludes expired Users (`internal/core/subscriptions.go`). Live counts are unaffected, because they never include test-mode Users. -8. **OpenAPI.** Add `expires_at` to `User`, to the `POST /users` and signup request bodies, and to `PATCH /users/{id}`. Add `test_user_expired` to the error, revoke-reason, and device-reason enums, and `user.expired` to the webhook and audit enums. Bump `info.version`, then run `make gen`. -9. **Tests.** Integration tests for each of these: the 24-hour default, the range errors, a token rejected after `expires_at` (with a test clock), `exp` capping, the sweep's events firing exactly once, extension restoring access, a live create rejecting `expires_at`, and the backfill leaving existing test Users working. Update the Quickstart acceptance test if it relies on a test User living longer than 24 hours. -10. **Docs.** Remove the "Not yet built" line from the 2026-10-20 Changelog entry and add a short "built" entry, as was done for push notifications. - ## First-party web pages The API is complete, but the user-facing pages its emails and redirects point to don't exist yet. Both are clients of the API, not part of it: