Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ jobs:
ARN=$(terraform output -raw db_cluster_arn 2>/dev/null || true)
SECRET=$(terraform output -raw db_secret_arn 2>/dev/null || true)
if [ -n "$ARN" ] && [ -n "$SECRET" ]; then
cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate
cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" \
SUPERADMIN_EMAIL="${{ vars.SUPERADMIN_EMAIL }}" go run ./cmd/migrate
echo "done=true" >> "$GITHUB_OUTPUT"
else
echo "done=false" >> "$GITHUB_OUTPUT" # first deploy: the cluster doesn't exist yet
Expand All @@ -122,7 +123,8 @@ jobs:
working-directory: infra/terraform/prod
run: |
ARN=$(terraform output -raw db_cluster_arn) SECRET=$(terraform output -raw db_secret_arn)
cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate
cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" \
SUPERADMIN_EMAIL="${{ vars.SUPERADMIN_EMAIL }}" go run ./cmd/migrate

- name: Smoke test
if: steps.ready.outputs.ready == 'true'
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ The steps above assume these exist. They're part of [PLAN.md → Phase 0](PLAN.m
3. In GitHub: create the `production` environment (deployments from `main` only), add the `main` ruleset, and set repository **variables** from the bootstrap outputs: `AWS_PLAN_ROLE_ARN`, `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `TF_STATE_BUCKET`, `ARTIFACT_BUCKET`, plus `API_DOMAIN` (`api.substratalapps.com`). None are secrets; OIDC needs no stored credentials.
4. Merge into `main`. The first deploy creates the stack and then migrates the new cluster.
5. Put the Stripe keys into the `substratal-prod/app` secret (`stripe_secret_key`, `stripe_webhook_secret`; Terraform never overwrites them), run `make stripe-catalog` against test mode first, and register `https://api.substratalapps.com/internal/stripe/webhook` in Stripe. Request SES production access (about a day).
6. Seed the first `superadmin`, which the public API can't create by design: `DATABASE_BACKEND=dataapi DB_CLUSTER_ARN=… DB_SECRET_ARN=… go run ./cmd/admin bootstrap -email you@example.com` (ARNs from `terraform output`).
6. The first `superadmin` is the platform owner's account, which the public API can't create by design. Set the repository variable `SUPERADMIN_EMAIL`: every deploy then makes sure that account exists as a **protected** superadmin that can't be suspended, deleted, erased, or demoted ([Users → The protected superadmin](https://compositecode.github.io/substratalapps.com/api-reference/users/#the-protected-superadmin)). It's created without a password. Set one once, from your machine: `DATABASE_BACKEND=dataapi DB_CLUSTER_ARN=… DB_SECRET_ARN=… go run ./cmd/admin bootstrap -email <SUPERADMIN_EMAIL>` (ARNs from `terraform output`, AWS credentials for the production account).
7. Optional: seed a smoke-test User and Application, and set `SMOKE_USER_ID`, `SMOKE_APP_ID` (variables) and `SMOKE_API_KEY` (secret) so every deploy also checks `effective-permissions` through the Data API.

`main` stays the repository's default branch. It's what's in production, and links throughout the docs point at `blob/main/...`. Local checkouts and day-to-day work use `dev`.
Expand Down
18 changes: 18 additions & 0 deletions cmd/migrate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
// go run ./cmd/migrate # local Postgres (DATABASE_URL)
// DATABASE_BACKEND=dataapi go run ./cmd/migrate # Aurora via the Data API (deploy.yml)
// go run ./cmd/migrate -new add_widgets # create the next numbered file
//
// With SUPERADMIN_EMAIL set, it then makes sure that account exists as the
// protected superadmin (migration 0004), so every deploy re-asserts it.
package main

import (
Expand Down Expand Up @@ -66,4 +69,19 @@ func main() {
if len(done) == 0 {
fmt.Println("up to date")
}
if email := os.Getenv("SUPERADMIN_EMAIL"); email != "" {
base, err := wire.NewBase(ctx)
if err != nil {
log.Fatal(err)
}
id, created, err := base.Server(nil).EnsureProtectedSuperadmin(ctx, email)
if err != nil {
log.Fatal(err)
}
if created {
fmt.Println("superadmin created", id, "(no password yet: run cmd/admin bootstrap or use password reset)")
} else {
fmt.Println("superadmin ensured", id)
}
}
}
11 changes: 11 additions & 0 deletions docs/api-reference/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ Rejected transitions return `409 invalid_status_transition`. A self-service call

Soft-delete: sets `status: deleted` and `deleted_at`, revokes every session, deactivates every push [device](../notifications/#sessions-and-device-lifecycle), fires `access.revoked` (reason `user_deleted`) for every Application the user had active access to, and from then on `GET /v1/users/{id}` 404s for everyone but `users.manage`, who can still fetch, restore, or request erasure for the account. Self, or `users.manage`. Entitlements, Roles, Profile, and Settings are retained, not removed — hard deletion is the separate erasure process below. The email address becomes reusable by a new signup immediately. Writes `user.deleted`.

## The protected superadmin

The platform owner's account is **protected**. Every deploy makes sure it exists as an active, verified `superadmin` (the `SUPERADMIN_EMAIL` deploy variable), and nothing can lock it out:

- `DELETE /v1/users/{id}`, `POST /v1/users/{id}/suspend`, a `PATCH` that changes `status` away from `active` or changes `email`, `POST /v1/users/{id}/erasure-requests`, and `DELETE /v1/users/{id}/roles/role_platform_superadmin` all return `409 user_protected`.
- The database enforces the same rule for every caller, including a direct SQL session (see [Database Schema → users](../../domain-model/database-schema/#users)). Ops can lift it for one transaction to hand ownership to another account.
- If the account is somehow missing, demoted, or unprotected, the next deploy restores it. Its password is never changed by a deploy.

Everything else works normally: it can change its own password, profile, and settings, enable MFA, and sign out.

## `POST /v1/users/{id}/suspend`

```json
Expand Down Expand Up @@ -264,5 +274,6 @@ Calling this for an already-scheduled user returns the existing request (`202`,
| `invalid_status_transition` | 409 | A `status` change not in the table above, or an invitation re-send for a non-`invited` user. |
| `erasure_not_scheduled` | 404 | Cancelling an erasure that isn't pending. |
| `erasure_scheduled` | 409 | Restoring a soft-deleted User while their erasure request is still scheduled. |
| `user_protected` | 409 | Suspending, deleting, erasing, re-addressing, or demoting the [protected superadmin](#the-protected-superadmin). |
| `user_token_required` | 400 | `me` used with an API Key. |
| `validation_failed` | 422 | Malformed email, or an unknown `status` value. |
3 changes: 3 additions & 0 deletions docs/domain-model/database-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ The entity pages describe *what* each field means to an API caller. This page is
| `created_at`, `updated_at`, `last_login_at` | `timestamptz` | `last_login_at` nullable |
| `version` | `integer` | not null, default `1` |
| `deleted_at` | `timestamptz` | nullable — soft-delete |
| `protected` | `boolean` | not null, default `false` — the platform owner's account; see below |

**Indexes:** `unique (email, test_mode) where deleted_at is null` (a deleted user's email should be reusable by a new signup, which a plain unique index would block, and test-mode signups mustn't collide with live ones); `(lower(email) text_pattern_ops)` for the `q` prefix search; `(status)` for admin filtering. No `organization_id` here — see `organization_memberships` below; a single column on `users` would cap a User at one Organization. No `auth`/password column here either — see `user_identities` below; a User can hold more than one login method, per [Auth → Native auth and per-Organization SSO](../../api-reference/auth/#native-auth-and-per-organization-sso).

**Protected superadmin.** Triggers keep a `protected` User (the platform owner, re-asserted by every deploy) from being locked out, for every caller: an `UPDATE` that would clear `protected`, change `status` from `active`, set `deleted_at`, or change `email` or `test_mode` is rejected, as is a `DELETE`; `user_role_assignments` refuses to drop its `role_platform_superadmin` row; and `erasure_requests` refuses a scheduled erasure for it. Setting `app.protected_user_maintenance = 'on'` for one transaction lifts all three, for ops handing ownership to another account. See [Users → The protected superadmin](../../api-reference/users/#the-protected-superadmin).

## user_identities, sso_connections

```sql
Expand Down
2 changes: 1 addition & 1 deletion docs/non-functional-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ nav_order: 10
- **MFA:** optional TOTP (RFC 6238) for `password` identities, with 10 single-use recovery codes. TOTP secrets are encrypted with KMS at rest. An SSO identity's MFA belongs to its IdP.
- **Service-to-service:** billing → hub, app → hub, and any other backend caller authenticates with a scoped [API Key](../api-reference/api-keys/), never user credentials. mTLS isn't offered.
- **Secrets at rest:** every bearer credential (refresh tokens, API Key secrets, emailed tokens, recovery codes) is stored only as a hash. Webhook signing secrets, TOTP secrets, and [push credentials](../api-reference/notifications/#push-credentials) must be usable by the server, so they're KMS-encrypted instead. So are push device tokens: they aren't credentials, but they're personal data the server has to send to.
- **Bootstrapping:** the very first `superadmin` cannot be created through the public API — nothing can call `POST /v1/users/{id}/roles/{roleId}` with `roles.manage` before a `superadmin` exists to grant it. It's seeded directly against the database (or via a one-time, non-API admin CLI command) as part of standing up a new environment, not specified as an endpoint.
- **Bootstrapping:** the very first `superadmin` cannot be created through the public API — nothing can call `POST /v1/users/{id}/roles/{roleId}` with `roles.manage` before a `superadmin` exists to grant it. It's seeded directly against the database, not specified as an endpoint. Every deploy makes sure the platform owner's account (the `SUPERADMIN_EMAIL` deploy variable) exists as a **protected superadmin**: active, verified, holding `superadmin`, and impossible to suspend, delete, erase, re-address, or demote, through the API ([`409 user_protected`](../api-reference/users/#the-protected-superadmin)) or directly in the database. Its password is set with the one-time admin CLI (`cmd/admin bootstrap`) or the password-reset flow.
- **MCP:** the [MCP Server](../mcp-server/) is not a separate auth surface — it carries and forwards the same Bearer credential (user token or API Key) as any other caller. No new rule in this section applies to it that doesn't already apply here.

## CORS
Expand Down
2 changes: 1 addition & 1 deletion docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2695,7 +2695,7 @@ components:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Conflict:
description: "State conflict (version_conflict, idempotency_key_reused, entitlement_already_exists, entitlement_platform_managed, subscription_exists, invalid_status_transition, …)"
description: "State conflict (version_conflict, idempotency_key_reused, entitlement_already_exists, entitlement_platform_managed, subscription_exists, user_protected, invalid_status_transition, …)"
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Expand Down
49 changes: 49 additions & 0 deletions internal/core/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,53 @@ func (s *Server) BootstrapSuperadmin(ctx context.Context, email, password string
return userID, err
}

// EnsureProtectedSuperadmin makes the platform's owner account exist, as an
// active superadmin that can never be removed (migration 0004). It runs on
// every deploy, after migrations (cmd/migrate with SUPERADMIN_EMAIL set), so
// the account is always there and always protected:
//
// - If no live User has the email, it's created active and verified, with
// no password. Set one with cmd/admin bootstrap, or the password-reset
// flow.
// - If the User exists, it's made active and verified again, marked
// protected, and given back the superadmin Role if it was missing.
//
// It never changes an existing password, and writes audit events only for
// what it actually changed. It reports whether it created the User.
func (s *Server) EnsureProtectedSuperadmin(ctx context.Context, email string) (userID string, created bool, err error) {
addr, ok := validEmail(email)
if !ok {
return "", false, fmt.Errorf("superadmin: invalid email %q", email)
}
err = s.system(ctx, false, db.Settings{}, func(c *Call) error {
u, err := c.findUser("u.email = :e and u.deleted_at is null", db.Args{"e": addr})
switch err {
case nil:
userID = u.ID
case db.ErrNotFound:
nu, err := c.createUser(newUser{Email: addr, Status: "active", EmailVerify: true,
DisplayName: "Superadmin", ActorType: "system", ActorID: "system"})
if err != nil {
return err
}
userID, created = nu.ID, true
default:
return err
}
if _, err := c.q.Exec(ctx, `update users set status = 'active', email_verified = true, protected = true
where id = :u and (status <> 'active' or not email_verified or not protected)`, db.Args{"u": userID}); err != nil {
return err
}
n, err := c.q.Exec(ctx, `insert into user_role_assignments (user_id, role_id, application_id, assigned_by_type, assigned_by_id)
values (:u, 'role_platform_superadmin', null, 'system', 'system') on conflict do nothing`, db.Args{"u": userID})
if err != nil || n == 0 {
return err
}
return c.audit(Audit{Action: "role.assigned", TargetType: "role_assignment", TargetID: userID + ":role_platform_superadmin",
TargetUserID: &userID, ActorType: "system", ActorID: "system",
After: map[string]any{"user_id": userID, "role_id": "role_platform_superadmin", "application_id": nil}})
})
return userID, created, err
}

var _ = ids.New
5 changes: 5 additions & 0 deletions internal/core/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,11 @@ func (s *Server) RolesRemove(w http.ResponseWriter, r *http.Request, id gen.User
if x.ID == "role_platform_member" {
return httpx.Conflict("member_role_required", "Every User holds the member platform Role.")
}
if x.ID == "role_platform_superadmin" {
if err := c.guardProtected(uid); err != nil {
return err
}
}
n, err := c.q.Exec(c.ctx, `delete from user_role_assignments where user_id = :u and role_id = :r`,
db.Args{"u": uid, "r": x.ID})
if err != nil {
Expand Down
84 changes: 84 additions & 0 deletions internal/core/superadmin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package core_test

import (
"context"
"testing"

"github.com/CompositeCode/substratalapps.com/internal/db"
"github.com/CompositeCode/substratalapps.com/internal/testenv"
)

// The protected superadmin (migration 0004) can't be locked out through the
// API or directly in the database, and every deploy re-asserts it.
func TestProtectedSuperadmin(t *testing.T) {
e := testenv.NewWith(t, testenv.Options{OwnDatabase: true})
ctx := context.Background()
addr := "owner+" + testenv.Unique() + "@example.com"

id, created, err := e.Core.EnsureProtectedSuperadmin(ctx, addr)
if err != nil || !created {
t.Fatalf("first ensure: %s %v %v", id, created, err)
}
again, created, err := e.Core.EnsureProtectedSuperadmin(ctx, addr)
if err != nil || created || again != id {
t.Fatalf("second ensure should be a no-op: %s %v %v", again, created, err)
}
roles := e.Get("/v1/users/"+id+"/roles", e.Admin).Expect(200).JSON()["data"].([]any)
hasSuper := false
for _, r := range roles {
hasSuper = hasSuper || r.(map[string]any)["role_id"] == "role_platform_superadmin"
}
if !hasSuper {
t.Fatalf("roles = %v", roles)
}
if u := e.Get("/v1/users/"+id, e.Admin).JSON(); u["status"] != "active" || u["email_verified"] != true {
t.Fatalf("user = %v", u)
}

// Every API path that could lock the account out is refused.
up := "/v1/users/" + id
e.Delete(up, e.Admin).ExpectErr(409, "user_protected")
e.Post(up+"/suspend", e.Admin, map[string]any{"reason": "x"}).ExpectErr(409, "user_protected")
e.Patch(up, e.Admin, map[string]any{"status": "suspended"}).ExpectErr(409, "user_protected")
e.Patch(up, e.Admin, map[string]any{"email": "elsewhere+" + testenv.Unique() + "@example.com"}).ExpectErr(409, "user_protected")
e.Post(up+"/erasure-requests", e.Admin, map[string]any{}).ExpectErr(409, "user_protected")
e.Delete(up+"/roles/role_platform_superadmin", e.Admin).ExpectErr(409, "user_protected")
// Harmless changes still work.
e.Patch(up, e.Admin, map[string]any{"status": "active"}).Expect(200)

// The database refuses the same changes from any caller.
for _, q := range []string{
`update users set status = 'suspended' where id = :u`,
`update users set deleted_at = now() where id = :u`,
`update users set protected = false where id = :u`,
`delete from user_role_assignments where user_id = :u and role_id = 'role_platform_superadmin'`,
`insert into erasure_requests (user_id, requested_by_type, requested_by_id, scheduled_for)
values (:u, 'system', 'system', now())`,
} {
err := e.DB.Tx(ctx, db.Settings{}, func(tx db.Querier) error {
_, err := tx.Exec(ctx, q, db.Args{"u": id})
return err
})
if err == nil {
t.Fatalf("database allowed: %s", q)
}
}
// Ops can lift the guard for one transaction to hand ownership over.
err = e.DB.Tx(ctx, db.Settings{}, func(tx db.Querier) error {
if _, err := tx.Query(ctx, `select to_jsonb(set_config('app.protected_user_maintenance', 'on', true))`, nil); err != nil {
return err
}
_, err := tx.Exec(ctx, `update users set protected = false where id = :u`, db.Args{"u": id})
return err
})
if err != nil {
t.Fatalf("maintenance override: %v", err)
}
e.Delete(up+"/roles/role_platform_superadmin", e.Admin).Expect(204)

// The next deploy's ensure restores the role and the protection.
if _, _, err := e.Core.EnsureProtectedSuperadmin(ctx, addr); err != nil {
t.Fatal(err)
}
e.Delete(up+"/roles/role_platform_superadmin", e.Admin).ExpectErr(409, "user_protected")
}
Loading
Loading