Seed a staging admin
Clinloop authenticates passwordlessly: the user enters their email, the API
sends a 6-digit OTP via SES, and the user exchanges it for a JWT. Before
anyone can sign in to a fresh staging environment there must be a row in
users with role='admin' for their email — there is no bootstrap UI and
the dev seed is local-only.
The Seed Staging Admin GitHub Action creates that row safely.
When to use it
- Bootstrapping a freshly-deployed staging stack
- Adding an additional staging admin (engineering, QA, support)
- After a teardown/re-seed of the staging database
Do not use it for production. There is no production equivalent; production admins are provisioned via a separate, more locked-down path.
How it works
- You trigger the workflow from the Actions tab and pass an email.
- GitHub holds the run for approval (if the
stagingenvironment is configured with required reviewers — see Setup below). - The workflow assumes the existing
AWS_DEPLOY_ROLE_ARN_STAGINGOIDC role and runs a one-off ECS Fargate task using the current staging API task definition. The container command is overridden tonode node_modules/@clinloop/db/dist/seeds/admin-cli.js. - The CLI (
packages/db/src/seeds/admin-cli.ts) validates the email, opens a transaction, and:- inserts a row with
role='admin',is_active=true,email_verified=trueonly if no active user already exists for that email (idempotent — re-running is a no-op); - refuses to elevate an existing active user that holds a non-admin role;
- writes an
audit_eventsrow withevent_type='admin_account_seeded'and metadata containing the GHA actor and run ID.
- inserts a row with
- The workflow waits for the task to stop, prints the exit code and recent CloudWatch logs, and writes a summary to the GH run page.
- The newly-created admin signs in to the staging admin portal using their email; the API emails them an OTP, they enter it, done.
Triggering it
GitHub UI → Actions → Seed Staging Admin → Run workflow → enter the email → Run.
Why the environment: line is missing: the IAM deploy role’s OIDC trust
policy (infrastructure/modules/cicd/main.tf) only accepts the sub
claim repo:<repo>:ref:refs/heads/main. Declaring environment: would
rewrite that claim to repo:<repo>:environment:<name> and the role
assumption would fail. Re-enabling the gate is therefore a coordinated
two-part change — see below.
Verifying the result
The job summary shows:
Email,Actor,Task ARN,Exit code
The CLI also prints a JSON line with the resulting user row to CloudWatch
(/ecs/clinloop-staging-api):
{ "ok": true, "wasNew": true, "user": { "id": "...", "email": "...", "role": "admin", "createdAt": "..." }}To independently confirm:
SELECT id, email, role, is_active, created_atFROM usersWHERE LOWER(email) = LOWER('admin@example.com');
SELECT event_type, metadata, timestampFROM audit_eventsWHERE resource_type = 'users' AND event_type = 'admin_account_seeded'ORDER BY timestamp DESC LIMIT 5;Guardrails
| # | Guardrail | Where it lives |
|---|---|---|
| 1 | Manual trigger only | on: workflow_dispatch |
| 2 | Reviewer approval | not yet configured — see below |
| 3 | Branch lock | if: github.ref == 'refs/heads/main' |
| 4 | Concurrency lock | concurrency: seed-admin-staging |
| 5 | Cluster/task hardcoded to staging | workflow env: block |
| 6 | Email format validated twice | shell regex + EMAIL_RE in CLI |
| 7 | Role hardcoded to admin; refuses to elevate other roles | admin-cli.ts |
| 8 | Idempotent (NOT EXISTS gate) | admin-cli.ts |
| 9 | Parameterised queries | admin-cli.ts ($1 everywhere) |
| 10 | Transactional with rollback on error | BEGIN / COMMIT / ROLLBACK |
| 11 | Audit row with GHA actor + run ID | admin-cli.ts |
| 12 | DB password never leaves the task | env vars from task definition |
Adding the reviewer gate later
Not configured today, intentionally — see Triggering it above. When staging starts holding anything that matters, or when this workflow gets adapted for production, do all three of these together (skipping any one breaks the workflow):
-
Extend the IAM trust policy. In
infrastructure/modules/cicd/main.tf, expand thesubStringLikevalues to also accept the environment-scoped claim:values = ["repo:${var.github_repo}:ref:refs/heads/main","repo:${var.github_repo}:environment:staging",]Apply via the normal Deploy workflow (push to main).
-
Add the
environment:line back to the workflow. In.github/workflows/seed-admin.ymlunder theseedjob, addenvironment: staging. -
Configure the GitHub Environment.
- Repo → Settings → Environments → New environment →
staging. - Tick Required reviewers, add at least one teammate (not yourself).
- Optional: tick Deployment branches → Selected branches → main.
- Repo → Settings → Environments → New environment →
Order matters: do step 1 before step 2, otherwise the workflow’s next run
will fail with Not authorized to perform sts:AssumeRoleWithWebIdentity.
Removing an admin
Soft-delete via SQL (the unique index on LOWER(email) is partial on
deleted_at IS NULL, so soft-deleting frees the email for re-seeding):
UPDATE users SET deleted_at = NOW() WHERE email = 'admin@example.com';There is no workflow for this on purpose — admin removal should be deliberate and infrequent enough to do via a reviewed psql session.