diff --git a/.env.example b/.env.example index e32b7e1..04c77ef 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,11 @@ BETTER_AUTH_SECRET=replace-with-a-random-secret-at-least-32-characters # PN_ENTRA_TENANT_ID= # Microsoft Entra security group used by the backend for PN members (the Soci group). PN_ENTRA_MEMBER_GROUP_ID=1c68dbb8-4ac3-4569-a886-283b5a825cbd -# Membership is checked with Microsoft Graph again after this interval. +# Microsoft Entra security group whose direct members hold the built-in Direttivo role. +# Unset: nobody is inferred as Direttivo. +# PN_ENTRA_DIRETTIVO_GROUP_ID= +# Lifetime of persisted sign-in evidence. Authorization independently rechecks Graph +# using a fixed maximum 60-second cache; this setting cannot extend RBAC access. PN_ENTRA_MEMBER_REFRESH_HOURS=24 # Google login. @@ -36,9 +40,10 @@ PN_ENTRA_MEMBER_REFRESH_HOURS=24 AZURE_EMAIL_SENDER=noreply@polinetwork.org STUDENT_VERIFICATION_TTL_DAYS=365 -# Who may manage OIDC clients at /applications. By default every signed-in PN Entra -# account can. Set this to a stricter Microsoft Entra group (object ID) to limit it -# to that group's direct members; the PN_ENTRA app checks it through Graph. +# Who holds the built-in Master Admin role, which carries every permission. Configure +# this Microsoft Entra administrators group (object ID) or a nonempty allowlist below. +# Missing both stops startup. Group membership is verified by the PN_ENTRA app +# through Graph. Everyone else is administered through roles at /access. # PN_ENTRA_OIDC_ADMIN_GROUP_ID= -# Comma-separated local user IDs that may always manage OIDC clients (break-glass). +# Comma-separated local user IDs that are always Master Admin (break-glass). IDP_ADMIN_USER_IDS= diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile index cffb908..f714482 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,7 @@ COPY --from=build --chown=node:node /app/.output ./.output COPY --from=build --chown=node:node /app/drizzle ./drizzle COPY --from=build --chown=node:node /app/scripts/migrate.mjs ./scripts/migrate.mjs COPY --from=build --chown=node:node /app/scripts/start.mjs ./scripts/start.mjs +COPY --from=build --chown=node:node /app/scripts/security-config.mjs ./scripts/security-config.mjs USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ diff --git a/README.md b/README.md index 0f51163..6696d0d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A standalone TanStack Start and Better Auth identity provider. The backend remai Use Node and pnpm through Vite+. 1. Run `vp install`. -2. Copy `.env.example` to `.env.local`, set a random secret, and point the `DB_*` variables at a **new, separate PostgreSQL database**. +2. Copy `.env.example` to `.env.local`, set a random secret, point `DB_*` at a **new, separate PostgreSQL database**, and configure an explicit admin group or `IDP_ADMIN_USER_IDS` bootstrap allowlist. 3. Set `BETTER_AUTH_URL=http://localhost:3000` for local development. 4. Run `vp run db:migrate` to apply the checked-in migration to that database. 5. Run `vp run dev` and open the origin set in `BETTER_AUTH_URL`. @@ -18,10 +18,37 @@ The included `Dockerfile` builds the app and runs the same migration-first start Google and PoliNetwork Entra create accounts. Once signed in, users can add a passkey from the account page and use it for future logins. Signed-out visitors see a login form with configured providers and passkey sign-in. Email/password login is disabled. The server rejects direct Telegram sign-in requests and protects the last Google or PoliNetwork Entra account from being disconnected, including when passkeys or verifier accounts remain linked. -Passkeys require the checked-in `0003` database migration. Run `vp run db:migrate` before using them. Their relying-party ID and origin come from `BETTER_AUTH_URL`; use that exact origin in your browser, with HTTPS in production or localhost in development. Register a passkey after signing in with Google or PoliNetwork Entra. The account page lists and removes registered passkeys. +Roles and permissions require the checked-in `0004` through `0007` migrations, which also move each account's single proven state into a list so one Entra identity can prove both Socio and Direttivo. `0004` carries the old `state` column into the new `states` list and seeds the built-in roles before `0005` drops it, so apply them in order and never `0005` alone. `0006` adds Master Admin and the `idp:*` permissions. `0007` adds immutable RBAC audit history and rejects/quarantines unsafe managed-role links. Passkeys require the checked-in `0003` database migration. Run `vp run db:migrate` before using them. Their relying-party ID and origin come from `BETTER_AUTH_URL`; use that exact origin in your browser, with HTTPS in production or localhost in development. Register a passkey after signing in with Google or PoliNetwork Entra. The account page lists and removes registered passkeys. New registrations send `PoliNetwork Auth` as the relying-party name. The username uses the user's real email, then an email from stored Google or Microsoft ID-token claims, and falls back to the user's name if neither is available. These claims are display metadata only. Passkey labels use the authenticator's AAGUID to recognize password managers such as 1Password; unknown authenticators display `Passkey`. Existing default labels are resolved when listed, while custom names are preserved. Password managers control their own vault item titles and may still show `localhost` during development. Previously saved vault metadata is not updated by the app. +### Upgrading an existing deployment to RBAC + +Merge #6 into #4 before merging #4 to `main`, and deploy the resulting code together. +The base feature alone does not include the security fixes. + +Back up the database, configure the admin bootstrap, stop every old replica, and then +start the new release with the normal migration-first command. This upgrade requires a +maintenance window: migration `0005` removes the `state` column still used by the old +server, so a mixed-version rolling deployment is incompatible. Rollback requires restoring +the database backup as well as the old image. The migration lock prevents simultaneous +migrators; it does not make old server code compatible with the new schema. + +The environment changes are: + +| Setting | RBAC behavior | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PN_ENTRA_DIRETTIVO_GROUP_ID` | New, optional group object ID for Direttivo. Unset grants nobody that evidence-backed role. | +| `PN_ENTRA_OIDC_ADMIN_GROUP_ID` | Existing setting now grants Master Admin. Without it, PN accounts are not administrators. Requires complete PN tenant/client credentials when set. | +| `IDP_ADMIN_USER_IDS` | Existing comma-separated local user IDs remain the explicit break-glass administrators. Configure at least this or the admin group before startup. | +| `PN_ENTRA_MEMBER_REFRESH_HOURS` | Still controls persisted sign-in evidence, defaults to 24. It no longer determines authorization freshness. | + +Authorization uses a fixed one-minute Graph cache and five-second lookup deadline, with +no new environment knobs. Configure the PN application with Graph `GroupMember.Read.All` +application permission and tenant admin consent. A failed lookup grants no group access; +the local break-glass IDs remain usable. Partial provider/mail credentials and malformed +security settings now fail validation before migrations run. + ## Connect identities Sign in with Google or PoliNetwork Entra, then connect Telegram and a Polimi student email from the account page. Accounts are keyed by verified issuer and subject, with a database uniqueness constraint. Matching emails never merge users. Account links can have different email addresses. The last login method cannot be disconnected. @@ -38,25 +65,133 @@ Register these callback URLs, replacing the origin with your deployment: PoliNetwork Entra uses a tenant-specific registration, not the `common` tenant. Google and PoliNetwork Entra are login providers. Telegram uses the official OIDC authorization-code flow with PKCE and RS256 ID tokens, but the server only permits it through the account-linking flow. Configure its allowed origin and callback in BotFather. Its bot user ID comes from the signed `id` claim, separately from its OIDC `sub`. -Polimi verification accepts only the exact `mail.polimi.it` domain. Codes contain six digits, expire after 10 minutes, allow five attempts, and cannot be resent for 60 seconds. The database stores only an HMAC of each code. Successful verification creates a `polimi-email` account link and grants student status for `STUDENT_VERIFICATION_TTL_DAYS`. +Polimi verification accepts only the exact `mail.polimi.it` domain. Codes contain six digits, expire after 10 minutes, allow five attempts, and cannot be resent for 60 seconds. The cooldown applies to both the user and recipient and survives failed guesses, consumption, and failed delivery. The database stores only an HMAC of each code. Successful verification creates a `polimi-email` account link and grants student status for `STUDENT_VERIFICATION_TTL_DAYS`. Email delivery uses the same Microsoft Graph client-credential setup as the current backend. The Azure application needs the Graph `Mail.Send` application permission and permission to send as `AZURE_EMAIL_SENDER`. These Azure credentials belong to the mail sender; they do not require access to Polimi Entra. -## States and permissions - -| Evidence | State | Permission | -| ----------------------------------------------- | ------------------ | ---------------------------------- | -| PN Entra account in the configured `Soci` group | `socio` | `membership:read` | -| Code sent to an `@mail.polimi.it` address | `student` | `student:verified` | -| Telegram identity | Linked Telegram ID | No automatic moderation permission | - -States accumulate independently. A socio is not automatically a student. Signature, issuer, audience, expiration, and Entra tenant are checked before recording evidence. Evidence contributes only when joined to an account owned by the user. - -`PN_ENTRA_MEMBER_GROUP_ID` identifies the Microsoft Entra `Soci` group used by the backend. Membership is checked through Microsoft Graph using `PN_ENTRA_TENANT_ID`, `PN_ENTRA_CLIENT_ID`, and `PN_ENTRA_CLIENT_SECRET`. Grant Microsoft Graph **application** permission `GroupMember.Read.All` and admin consent on that PN app registration. `AZURE_*` credentials are only used for email delivery. The check reads direct group members across all result pages, matching the backend's membership rule. A Graph failure is logged and grants no new membership evidence; it is never cached as a confirmed nonmember or replaced by a token group claim. - -Microsoft membership is rechecked on login and on the first identity request after `PN_ENTRA_MEMBER_REFRESH_HOURS`, which defaults to 24 hours. This is a cache interval, not the duration of someone's membership. Expired evidence grants no state if Graph cannot verify it, and the next request retries. Polimi student verification lasts for `STUDENT_VERIFICATION_TTL_DAYS`, which defaults to 365 days. The user must verify the address again after that. The Telegram ownership link persists until disconnected. Already issued OIDC tokens expire after five minutes, so consumers must account for that revocation delay; `/api/identity` and UserInfo compute current evidence on each request. - -OIDC client administration is a separate permission from membership. Anyone signed in with a PoliNetwork Entra account (the `pn-entra` provider, verified against `PN_ENTRA_TENANT_ID`) can currently manage applications. To restrict it to a stricter Microsoft 365 group than Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep access. Graph answers are cached for 15 minutes per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers this permission by itself. Existing backend Telegram roles and group assignments remain authoritative and are not copied or queried by this prototype. +## Roles and permissions + +Access is modelled as permissions bundled into roles. A **permission** is one thing an +application can check for, addressed by a key such as `membership:read`. A **role** is a +named bundle of permissions that someone can hold. Administrators create both at `/access`, +and both support a hierarchy: + +- A permission can **also grant** other permissions. Holding `membership:write` can grant + `membership:read` without listing it everywhere. +- A role can **inherit from** other roles. It then carries every permission of its parents, + including what those inherit in turn. + +Both hierarchies are transitive, and the editor refuses an edge that would make two roles +inherit from each other or two permissions grant each other. + +### Roles the identity provider defines itself + +Four roles always exist and are never created, deleted, or handed out by an administrator. +Their membership is conferred by the identity provider itself: + +| Role | Key | Granted by | +| -------------- | -------------- | --------------------------------------------------------------------------------------------------- | +| `Master Admin` | `master-admin` | `IDP_ADMIN_USER_IDS`; otherwise the configured administrators group, never an unconfigured fallback | +| `Socio` | `socio` | Direct membership of the `Soci` group in PoliNetwork Entra ID | +| `Direttivo` | `direttivo` | Direct membership of `PN_ENTRA_DIRETTIVO_GROUP_ID` in PoliNetwork Entra ID | +| `Student` | `student` | A verification code delivered to an `@mail.polimi.it` address | + +**Master Admin holds every permission that exists**, including ones created after it was +last looked at, because it is a wildcard rather than a stored list. It therefore has no +grant list of its own to edit, and no role may inherit from it: that would launder a +wildcard nobody can be given into a role an administrator could hand to anyone. +Unlike the other three it is not proven by identity evidence and never appears among the +`states`: it comes from the deployment's own configuration, which is what keeps the service +from being locked out of its own administration. `IDP_ADMIN_USER_IDS` is always honored. +Set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to limit everyone else to that Microsoft Entra group. +If the group is unset, only the explicit allowlist can confer Master Admin. Startup fails +without either an admin group plus complete PN Entra credentials or a nonempty allowlist. + +What the other three grant is still yours to choose: give them permissions, rename them, +describe them, and place them in the hierarchy like any other role. Only their key, their +deletion, and who holds them are fixed. The checked-in migrations seed them alongside the +two permissions this service already issued, so existing consumers keep working: `socio` +grants `membership:read` and `student` grants `student:verified`. + +`PN_ENTRA_DIRETTIVO_GROUP_ID` is optional and has no default. Until you set it to the +board's Entra group object ID, nobody is inferred as Direttivo. Both group checks reuse the +`PN_ENTRA_*` Graph credentials. Authorization rechecks membership with a fixed 60-second +cache measured from lookup start. Stored sign-in evidence and `PN_ENTRA_MEMBER_REFRESH_HOURS` +do not extend authorization. Failed or overlong checks grant nothing. + +Membership of the built-in roles is not a role assignment: nothing is written to +`user_role` for them, and evidence contributes only when joined to an account owned by the +user. States accumulate independently, so a socio is not automatically a student. Signature, +issuer, audience, expiration, and Entra tenant are checked before evidence is recorded. + +Note that inheritance crosses this line in one direction. If you make a role you created +inherit from `Socio`, everyone holding your role also reports the `socio` role and its +permissions, whether or not Entra says they are a member. Inherit from a built-in role only +when that is what you mean. + +### Permissions the identity provider defines itself + +Administering this service is expressed as permissions like any other capability, so it can +be delegated to a role instead of being wired to a single group. These seven always exist +and can never be created, deleted, or rekeyed, because the code checks for these exact +keys; which roles carry them is entirely up to you. + +| Permission | Covers | +| ------------------------ | ---------------------------------------------------------- | +| `idp:people:read` | Searching the people registered here | +| `idp:permissions:read` | Seeing permissions in the `/access` section | +| `idp:permissions:write` | Creating, changing, and deleting permissions | +| `idp:roles:read` | Seeing roles, what they grant, and who holds them | +| `idp:roles:write` | Creating and changing roles, and giving them to people | +| `idp:applications:read` | Seeing the OIDC applications at `/applications` | +| `idp:applications:write` | Registering and editing applications, and rotating secrets | + +They use the permission hierarchy themselves: each `write` grants its `read`, +`idp:roles:write` also grants `idp:people:read` so a role manager can find who to give a +role to, and `idp:roles:read` grants `idp:permissions:read` because a role is meaningless +without seeing the permissions it carries. A role with `idp:roles:write` therefore ends up +with four permissions and still cannot touch applications. `idp:roles:write` granting +`idp:roles:read` and `idp:permissions:write` granting `idp:permissions:read` are fixed in +code: they apply even if the stored edge is missing, and cannot be removed, because +changing either without seeing what already exists makes no sense. The other implications +are seeded defaults you can edit. + +Every administration endpoint and every page checks the specific permission it needs, and +the navigation only offers what you hold. Because Master Admin is a wildcard over every +permission, whoever the deployment configures as an administrator holds all of these, which +is the bootstrap and break-glass path: there is no second kind of check beside RBAC. + +Write permissions authorize bounded delegation. Only Master Admin can edit managed roles +or permissions, including through custom ancestors or implications. Other writers can +change, assign, revoke or delete only access within their current effective permissions; +neither writer permission permits self-escalation. A new permission definition confers +nothing: Master Admin must first grant it before others can delegate it. All checks use +current authority inside the same serialized transaction as the mutation. Graph lookups +finish before a database transaction starts. Inside the transaction, authorization rereads +the actor's accounts, evidence, assigned roles and graph, using only still-valid cached +membership answers. An account unlinked while Graph is pending cannot authorize the write. + +Every RBAC mutation records its actor, operation, target and before/after state in +`rbac_audit_event`. These events commit atomically with the change and reject updates, +deletes and truncation. Database owners remain trusted and can disable triggers; export +audit events to separately controlled storage if protection from database owners is needed. + +### Assigning a role + +Roles you create are given to people from the role's page at `/access/roles`, which lists +who holds it in pages of 100 and searches for someone to add. Searching requires +`idp:people:read`; removing an existing member does not. An assignment lasts until it is removed. +Deleting a role removes it from everyone who held it and from every role that inherited it. + +Changes take effect on the next token. Already-issued OIDC tokens expire after five +minutes, so consumers must account for that revocation delay; `/api/identity` and UserInfo +compute current access on each request. The role graph and assignments are read from one committed snapshot without a catalog +cache. Requests starting after a database revocation commits see it. Group removal takes +at most 60 seconds to affect new authorization decisions (subject to Graph propagation); +a token issued just before expiry can remain valid for another five minutes. + +A linked Telegram identity grants no role and no permission. Existing backend Telegram +roles and group assignments remain authoritative and are not copied or queried here. ## OIDC clients @@ -68,16 +203,27 @@ Supported scopes are `openid`, `profile`, `polinetwork:identity`, and `offline_a { "https://auth.polinetwork.org/api/identity": { "states": ["socio", "student"], + "roles": ["socio", "student"], "permissions": ["membership:read", "student:verified"], "telegramId": "123456789" }, "polinetwork_states": "socio student", + "polinetwork_roles": "socio student", "polinetwork_permissions": "membership:read student:verified", "polinetwork_telegram_id": "123456789" } ``` -The string `polinetwork_states` and `polinetwork_permissions` claims use spaces between values. They are empty strings when no values apply, as is `polinetwork_telegram_id` when no Telegram account is linked. The `/api/identity` response keeps the object format shown inside the URL-named claim. +`states` is the raw evidence: what the person's linked accounts proved. `roles` and +`permissions` are the result of resolving that evidence and their assignments through both +hierarchies, so `roles` includes inherited parent roles and `permissions` includes +everything granted indirectly. Applications should check `permissions` for a specific +capability and treat `roles` as a coarser label. The string `polinetwork_*` claims use +spaces between values and are empty strings when no values apply, as is +`polinetwork_telegram_id` when no Telegram account is linked. The `/api/identity` response +keeps the object format shown inside the URL-named claim. + +Managing applications needs the `idp:applications:write` permission, so it can be given to any role. Master Admin holds it only through explicit deployment configuration. To use a Microsoft 365 administrators group distinct from Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep it. Graph answers are cached for at most 60 seconds from lookup start per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers administration by itself. Dynamic registration and client-credentials grants are disabled. Administrators manage clients at `/applications`: create web or native apps as confidential (secret shown once) or public (PKCE only) clients, edit redirect URIs and allowed scopes, rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules: web apps need `https` on a public host, native apps may use `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme. Custom routes under `/api/oidc/` back the pages; creation, deletion, and secret rotation go through the Better Auth client endpoints, which enforce the same administrator check. @@ -109,8 +255,14 @@ IDENTITY_TEST_SECRET=your-test-server-secret \ vp test ``` -The integration suite covers discovery, anonymous rejection, current identity claims, denied client registration, unique account ownership, unlink revocation, and last-account protection. Live Google, Entra, Telegram, and Microsoft Graph email delivery require actual app registrations and have not been validated here. +The integration suite covers discovery, anonymous rejection, current identity claims, denied client registration, unique account ownership, unlink revocation, and last-account protection. Role and permission resolution, hierarchy expansion, cycle refusal, and the protections around the built-in roles are covered by the unit tests in `src/auth/rbac.test.ts`. Live Google, Entra, Telegram, and Microsoft Graph email delivery require actual app registrations and have not been validated here. The auth schema was generated with the Better Auth CLI and includes the `account.issuer` field and issuer/subject unique index required by installed Better Auth 1.7.2. Review regeneration diffs: older CLI core schemas omit that field. Generate Drizzle SQL with `vp run db:generate` after any schema change. References: [Better Auth OAuth provider](https://better-auth.com/docs/plugins/oauth-provider), [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth), [Telegram OIDC](https://core.telegram.org/bots/telegram-login). + +The full RBAC security audit, findings, deployment changes and verification limits are in +[docs/rbac-security-review.md](docs/rbac-security-review.md). The additional PostgreSQL suite +runs when `RBAC_TEST_DATABASE_URL` points to a disposable migrated database. To run all tests +without skips, provide that variable together with the HTTP integration variables above and +the matching `DB_*`, `BETTER_AUTH_URL`, `BETTER_AUTH_SECRET` and admin bootstrap configuration. diff --git a/docker/write-runtime-package.mjs b/docker/write-runtime-package.mjs index 293de93..2dacd77 100644 --- a/docker/write-runtime-package.mjs +++ b/docker/write-runtime-package.mjs @@ -8,7 +8,7 @@ // the versions bundled into .output, which matters for Sentry in particular. import { readFileSync, writeFileSync } from "node:fs"; -const runtimePackages = ["@sentry/tanstackstart-react", "drizzle-orm", "pg"]; +const runtimePackages = ["@sentry/tanstackstart-react", "drizzle-orm", "pg", "zod"]; const dependencies = Object.fromEntries( runtimePackages.map((name) => { diff --git a/docs/rbac-security-review.md b/docs/rbac-security-review.md new file mode 100644 index 0000000..42afd54 --- /dev/null +++ b/docs/rbac-security-review.md @@ -0,0 +1,84 @@ +# RBAC security remediation for PR #6 + +Audited base: `d3e466d9e56952dc423e6d3468557503d6ae918b`, the final stacked state of #6 on #4. Read the owner’s [security review](https://github.com/PoliNetworkOrg/auth/pull/6#issuecomment-5714698921), all issue comments and reviews on #4/#6, and the inline discussions. The owner approved replacing the documented root-equivalent writer behavior with bounded delegation during this remediation. + +## 1. Findings + +Locations below refer to the remediated source. “Review” identifies findings from the owner’s comment; the other rows come from the independent audit. + +| Issue | Severity | Location | Concrete exploit path | Status | +| ------------------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Review: fail-open Master Admin bootstrap | Critical | `src/auth/oidc-admin.ts:33`; `scripts/security-config.mjs:74` | A user with a formerly linked PN account signs in through Google/passkey while the admin group is unset and obtains every permission. | Fixed: explicit group/allowlist required; invalid deployment settings stop startup before migrations. | +| Legacy inherited wildcard | Critical | `src/auth/rbac.ts:220`; `drizzle/0007_mushy_the_fury.sql:20` | A holder of a previously created custom role inheriting Master Admin obtains every present and future permission despite new-write validation. | Fixed: resolution ignores that edge; migration records/removes unsafe edges and assignments; database rejects new ones. | +| Review: role-writer self-escalation and above-authority delegation | High | `src/auth/rbac-delegation.ts:36`; `src/auth/rbac-store.ts:235` | A role writer creates/edits a role with application administration, then assigns it to themselves or an accomplice. | Fixed: effective permissions before and after, including inheritance, must stay within the actor’s authority; managed changes require Master Admin. | +| Review: permission-implication self-escalation | High | `src/auth/rbac-delegation.ts:24` | A permission writer makes a held permission transitively imply application administration, acquiring it on their next request. | Fixed: managed permissions require Master Admin; custom implication/rename changes cannot increase the actor’s authority. | +| Indirect managed-role/permission changes | High | `src/auth/rbac-delegation.ts:44` | A delegated writer edits a custom ancestor or implied permission used by a managed role, changing managed grants without editing the managed row itself. | Fixed: compare affected effective role grants and managed permission closures across both graphs. | +| Mutation/revocation race and unguarded store methods | High | `src/auth/rbac-store.ts:183`; `src/db/security-lock.ts:5` | A writer passes the HTTP guard, waits behind a revocation, then uses stale authority to grant another role; an internal caller could also omit the guard entirely. | Fixed: actor required by repository APIs; fresh authorization, dominance and writes share one READ COMMITTED transaction and advisory lock, including grant/revoke. | +| Mixed-version graph resolution | High | `src/auth/rbac-store.ts:135`; `src/auth/rbac-store.ts:502` | A user races token issuance with a graph replacement and combines an old role grant with a new implication that never coexisted in a committed graph. | Fixed: graph, assignments and linked identity read from a REPEATABLE READ snapshot; no graph cache. | +| Concurrent student-code guesses/replay | High | `src/auth/student-verification.ts:116` | An ordinary user submits concurrent guesses that overwrite the attempt counter, exceeds five guesses and can obtain Student and its configured permissions; concurrent valid requests can replay consumption. | Fixed: verification, attempts, code consumption and evidence creation share a serialized transaction; failed attempts commit before denial. | +| Review: 24-hour group-backed authorization; separate 15-minute root cache | Medium | `src/auth/identity-subject.ts:11`; `src/auth/oidc-admin.ts:46` | A removed Socio/Direttivo/admin group member keeps using an existing session or minting fresh privileged tokens from stale membership. | Fixed: 60-second maximum application cache measured from lookup start; failed/overlong/superseded checks deny. | +| Evidence from a former/different configured tenant | Medium | `src/auth/identity-subject.ts:18` | After a tenant change, an old linked account’s still-valid `pn-entra` evidence continues conferring membership without matching the new tenant. | Fixed: issuer and provider must match the configured trust boundary; current group verification is required. | +| Restart restores revoked implications | Medium | `src/auth/rbac-store.ts:135` | A write-only actor reaches a cold replica after an operator removed a default read implication; runtime seeding recreates it and restores access. | Fixed: authorization reads never seed or repair the graph; regression exercises a fresh process. | +| Review: no durable mutation audit / denial logging | Medium | `src/auth/rbac-store.ts:255`; `src/auth/denial-log.ts:2`; `drizzle/0007_mushy_the_fury.sql:12` | A compromised writer grants access, uses it and removes the grant, erasing attribution with the live assignment. | Fixed: actor, operation, target and before/after state recorded atomically; UPDATE/DELETE/TRUNCATE rejected; denials omit bodies, tokens and query strings. | +| Write-only member disclosure | Medium | `src/routes/api/rbac/role-members.ts:36` | A writer whose read implication was removed mutates an empty role and receives all members’ names/emails in the response without role-read permission. | Fixed: separate read authorization, including inside the repository; write-only responses contain no member data. | +| Unconfigured resource-policy SDK privileges | Medium, internal only | `src/auth/index.ts:118` | An internal caller passes an ordinary authenticated session to the provider’s resource-administration SDK and changes token policy because its absent privilege hook defaults to allow. | Fixed: unsupported resource-policy administration explicitly denies; real SDK denial regression. These endpoints were already server-only, not an anonymous/public HTTP exploit. | +| Concurrent resend / last-account removal | Medium | `src/auth/student-verification.ts:53`; `src/auth/accounts.ts:18` | A user races resend requests to bypass the cooldown, or races two unlink requests so each sees the other login method and both are removed. | Fixed: read/check/write operations serialized; ownership remains derived from the authenticated subject. | + +No finding from the owner’s review was silently skipped or treated as a non-issue. + +## 2. Rebuttals and preserved decisions + +- **#4’s original cycle race was already fixed for graph saves at the reviewed commit.** `withRbacWriteLock` already took a transaction advisory lock and reread the graph. The missing piece was authorization and assignment/revocation under that same serialization. Concurrent opposite-edge regression tests verify exactly one edge commits. +- **#4’s new-write Master Admin inheritance path was already blocked.** `validateRoleDraft` rejected Master Admin as a parent for every role, including managed roles. That restriction remains; only the resolver’s treatment of historical edges still needed fixing. +- **The application pool is intentionally global, not user-owned tenancy.** `OIDC_CLIENT_REFERENCE` is the fixed `polinetwork` pool; custom SQL predicates and the installed provider’s client-reference checks reject a different pool. Real HTTP tests cover both ordinary/read-only rejection and cross-pool writer rejection. Per-user application ownership was not invented. +- **Provider resource administration was not remotely exposed.** Installed provider endpoints carry `metadata: { SERVER_ONLY: true }`; an ordinary authenticated HTTP request returns 404. The separate internal SDK default was still explicitly closed. +- **No prefix or wildcard permission matching exists.** Stored permission checks use exact keys. Unknown required keys and empty required sets deny. Master Admin expands only to catalog entries, and only direct configuration-derived membership activates it. +- **Raw client claims are not proof.** PN/Telegram linking verifies signature, issuer, audience, expiry and tenant before recording evidence (`src/auth/providers.ts`). Authorization then joins evidence to an account owned by the persisted subject. Telegram provides no permission. The additional issuer check protects stored evidence after configuration changes. +- **There is no RBAC “remove the last Master Admin” endpoint.** Managed roles cannot be assigned, unassigned or deleted; Master Admin membership lives in deployment configuration/Entra. Changing that external bootstrap remains an operator responsibility. Self-revocation of a delegated role remains allowed. +- **Prior UI fixes remain intact.** The #4 discussion’s server-error display, synthetic preview key, busy controls, failed-load screens and permitted Access tab routing were checked in the full files. They were already addressed and were not reverted. #6’s independent catalog/read-only application boundaries remain in place. + +## 3. Deferred and limits + +No known code-remediable blocker in the audited RBAC paths is deliberately deferred. These boundaries remain: + +- Microsoft Graph propagation and real upstream provider availability are outside this repository. No live tenant, Google, Telegram or mail-delivery integration was exercised. Membership tests simulate positive, negative, expired, failed and superseded checks. A Graph outage loses group-derived access after the short cache expires; the explicit break-glass allowlist remains available. +- The 60 seconds is the application cache bound for new authorization decisions, not a promise to cancel requests already in progress. Already-issued five-minute OIDC tokens remain valid until expiry. A token minted near the cache deadline can therefore retain group-derived rights for approximately six minutes, plus upstream propagation and consumer clock tolerance. Consumers requiring immediate revocation must use fresh authorization rather than offline token claims. +- PostgreSQL owners and deployment operators are trusted. They can disable triggers/change configuration. Audit events resist application UPDATE/DELETE/TRUNCATE, but protection from database owners requires external, separately administered audit retention. Console denial logs likewise require an operational log sink. +- A custom role may still inherit Socio/Direttivo/Student permissions without conferring the corresponding raw identity state. This is documented product behavior; downstream applications should authorize by permissions and use `states` when they need evidence of actual membership. +- Bounded delegation protects the stored graph, not arbitrary downstream interpretations of new permission names or future edits made by Master Admin. Master Admin remains intentionally omnipotent. +- Built-in OAuth client SDK actions check current authorization at their provider hook; an action already authorized before revocation may finish. Local RBAC mutations additionally reauthorize inside their serialized mutation transaction. There is no claim of distributed cancellation across the upstream identity provider and the database. + +## 4. Deployment and changed flows + +1. Configure **either** `PN_ENTRA_OIDC_ADMIN_GROUP_ID` with complete PN tenant/client credentials **or** a nonempty `IDP_ADMIN_USER_IDS` containing intended local user IDs. Missing/empty/malformed bootstrap settings now stop startup. Partial provider/mail credentials and malformed security settings also fail at startup. +2. Apply migration `0007_mushy_the_fury.sql` before serving the new code. It creates append-only audit storage, rejects managed-role assignments/root inheritance, and records/removes existing unsafe links. The normal migration-first start command does this automatically. Runtime authorization no longer repairs missing seed rows: use the checked-in migrations. +3. Role/permission writers are **no longer root-equivalent**, as explicitly approved. Ask Master Admin to edit managed access, grant new capabilities initially, rename a permission into an unheld capability, or change a graph affecting more privileged/managed roles. Existing delegates can continue delegating access they already hold. +4. A linked PN account alone no longer grants administration. Former-tenant or malformed stored evidence grants nothing. Group-derived access now requires reachable Graph checks with a 60-second cache; `PN_ENTRA_MEMBER_REFRESH_HOURS` controls persisted sign-in evidence only. +5. A write-only role membership mutation no longer returns member identities. Repository functions now require actor IDs, so internal callers must pass their authenticated actor rather than calling unguarded helpers. +6. Unsupported resource-policy administration via the server SDK is denied. Standard shared-pool application management retains its existing permissions and UI. +7. Concurrent verification attempts count individually; consumed codes cannot be replayed; concurrent resends and last-account removals are denied. + +Changed pre-existing authorization tests are deliberate: `oidc-admin.test.ts` now expects missing-group denial; `rbac.test.ts` now rejects a legacy inherited wildcard; `identity.integration.test.ts` no longer treats the fabricated issuer `pn-entra` as verified tenant evidence. Its Telegram fixture uses the canonical issuer, and valid student verification remains covered. No guard was weakened to satisfy those tests. + +## 5. Follow-up review of the complete stack + +The fresh review started at `87280c2`, compared the complete #4/#6 stack with `main`, and rechecked viganogabriele's report against the code. The four original security controls remain in place. Additional findings and fixes: + +- **Graph I/O blocked unrelated security writes.** Membership refresh now happens before opening a transaction. Transactional authorization rereads current owned accounts/evidence and checks only unexpired cached group facts. It cannot authorize an account unlinked during the lookup. Lookups have a five-second deadline and share in-flight work per group/person. Concurrent valid administrators no longer supersede and deny one another's checks. +- **Successful saves could return another transaction's state or fail after committing.** Role and permission saves now return their own transactional summary. +- **Self-revocation could return a misleading 403 after success.** Membership writes return an acknowledgement without member data. The UI refreshes access separately. +- **Unbounded member responses replaced the old silent cutoff.** The endpoint and UI now use cursor pagination with 100 members per page. A 505-person regression verifies complete traversal without duplicates. +- **Confirmation could reset email resend throttling.** Consumed, exhausted and failed-delivery challenges retain their send timestamps; mismatched emails do not delete the original challenge. Five database regressions cover throttling, replay and delayed delivery failures. +- **Delegated writers saw controls the server would always reject.** The UI now distinguishes Master Admin, makes built-in and above-authority objects read-only, limits grant choices, and respects the separate people-search permission. The server still validates the complete proposed graph. +- **Rollout documentation omitted a schema compatibility break.** The README now requires stopping old replicas before migration `0005` removes their `state` column. Merge #6 into #4 first and deploy the combined release. Rollback requires the database backup and old image. + +No additional environment variables or migrations are needed for these follow-up fixes. The stack still requires explicit admin bootstrap and migrations `0004` through `0007`. The [Microsoft Graph SDK cancellation guidance](https://github.com/microsoftgraph/msgraph-sdk-javascript/wiki/Microsoft-Graph-JavaScript-SDK-V3.0-Upgrade-Guide) documents the request signal used for the lookup deadline. + +Validation: formatting, lint, TypeScript, production build, Docker image build/startup, and the full suite against disposable PostgreSQL and the compiled HTTP server: **152 passed, zero skipped**. Upgrade rehearsals from `main` and the original RBAC branch preserve existing states and record/remove unsafe legacy links. Missing bootstrap configuration stops the container before migrations. Browser checks cover delegated read-only controls, Master Admin editing and page navigation. The integration tests include a blocked Graph lookup concurrent with an unrelated write and account unlink, proving both progress and denial of stale authority. The limits in section 3 still apply. + +## Authorization model and coverage notes + +- Resolution order: persisted user → owned accounts and trusted evidence → current group checks/student evidence → configured Master Admin + manual custom roles → role inheritance → transitive permission implications. There are no explicit deny rules or client-selected tenants. Roles/permissions use normalized lowercase keys; reads/checks use exact matching. Only Master Admin has wildcard behavior. +- HTTP entry points audited: `/api/identity`, `/api/idp/access`, every `/api/rbac/*` and `/api/oidc/*` route, account unlink, student verification, providers, and the `/api/auth/$` dispatcher. Also inspected OAuth client hooks, internal/admin SDK methods, token/UserInfo claim callbacks, provider verification and direct repository callers. No bulk or webhook RBAC mutation endpoint exists. +- Database enforcement: PKs/unique keys, issuer-subject ownership uniqueness, edge/assignment FKs and delete cascades; new triggers forbid managed assignments, Master Admin parents and audit rewrites. Cycle/dominance rules remain application-level but execute under the shared advisory lock. Account unlink and student evidence mutation use that lock too. +- Test infrastructure: disposable PostgreSQL 17 on loopback, no production data; real signed-cookie tests run against the compiled server. The additional repository/route integration suite substitutes session authentication and Graph/mail responses but uses real authorization code, SQL, transactions and constraints. Group cache tests use controlled time. Startup configuration denial was also checked before migrations. diff --git a/drizzle/0004_overjoyed_mordo.sql b/drizzle/0004_overjoyed_mordo.sql new file mode 100644 index 0000000..46f18c1 --- /dev/null +++ b/drizzle/0004_overjoyed_mordo.sql @@ -0,0 +1,80 @@ +CREATE TABLE "permission" ( + "id" text PRIMARY KEY NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "description" text, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "permission_implication" ( + "permission_id" text NOT NULL, + "implied_permission_id" text NOT NULL, + CONSTRAINT "permission_implication_permission_id_implied_permission_id_pk" PRIMARY KEY("permission_id","implied_permission_id") +); +--> statement-breakpoint +CREATE TABLE "role" ( + "id" text PRIMARY KEY NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "description" text, + "managed" boolean DEFAULT false NOT NULL, + "source_state" text, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "role_parent" ( + "role_id" text NOT NULL, + "parent_role_id" text NOT NULL, + CONSTRAINT "role_parent_role_id_parent_role_id_pk" PRIMARY KEY("role_id","parent_role_id") +); +--> statement-breakpoint +CREATE TABLE "role_permission" ( + "role_id" text NOT NULL, + "permission_id" text NOT NULL, + CONSTRAINT "role_permission_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id") +); +--> statement-breakpoint +CREATE TABLE "user_role" ( + "user_id" text NOT NULL, + "role_id" text NOT NULL, + "assigned_by" text, + "assignedAt" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_role_user_id_role_id_pk" PRIMARY KEY("user_id","role_id") +); +--> statement-breakpoint +ALTER TABLE "identity_evidence" ADD COLUMN "states" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "permission_implication" ADD CONSTRAINT "permission_implication_permission_id_permission_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "permission_implication" ADD CONSTRAINT "permission_implication_implied_permission_id_permission_id_fk" FOREIGN KEY ("implied_permission_id") REFERENCES "public"."permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_parent" ADD CONSTRAINT "role_parent_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_parent" ADD CONSTRAINT "role_parent_parent_role_id_role_id_fk" FOREIGN KEY ("parent_role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permission" ADD CONSTRAINT "role_permission_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permission" ADD CONSTRAINT "role_permission_permission_id_permission_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_role" ADD CONSTRAINT "user_role_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_role" ADD CONSTRAINT "user_role_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "permission_key_uidx" ON "permission" USING btree ("key");--> statement-breakpoint +CREATE INDEX "permissionImplication_implied_idx" ON "permission_implication" USING btree ("implied_permission_id");--> statement-breakpoint +CREATE UNIQUE INDEX "role_key_uidx" ON "role" USING btree ("key");--> statement-breakpoint +CREATE INDEX "roleParent_parent_idx" ON "role_parent" USING btree ("parent_role_id");--> statement-breakpoint +CREATE INDEX "rolePermission_permission_idx" ON "role_permission" USING btree ("permission_id");--> statement-breakpoint +CREATE INDEX "userRole_role_idx" ON "user_role" USING btree ("role_id");--> statement-breakpoint +-- Carry the single state each account proved into the new list before 0005 drops it. +UPDATE "identity_evidence" SET "states" = ARRAY["state"] WHERE "state" IS NOT NULL;--> statement-breakpoint +-- The roles the identity provider defines itself. Membership follows identity evidence: +-- the application never writes user_role rows for these. Names, descriptions, permissions, +-- and hierarchy are administrator-editable from here on, so this seed never runs again. +INSERT INTO "role" ("id", "key", "name", "description", "managed", "source_state") VALUES + ('static-role-socio', 'socio', 'Socio', 'Member of PoliNetwork APS.', true, 'socio'), + ('static-role-direttivo', 'direttivo', 'Direttivo', 'Member of the PoliNetwork APS board.', true, 'direttivo'), + ('static-role-student', 'student', 'Student', 'Verified Politecnico di Milano student.', true, 'student') +ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint +-- The two permissions this service already put in tokens, so existing consumers keep working. +INSERT INTO "permission" ("id", "key", "name", "description") VALUES + ('seed-permission-membership-read', 'membership:read', 'Read membership', 'See that someone is a member of PoliNetwork APS.'), + ('seed-permission-student-verified', 'student:verified', 'Verified student', 'See that someone verified a Politecnico di Milano student email.') +ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint +INSERT INTO "role_permission" ("role_id", "permission_id") VALUES + ('static-role-socio', 'seed-permission-membership-read'), + ('static-role-student', 'seed-permission-student-verified') +ON CONFLICT DO NOTHING; diff --git a/drizzle/0005_left_lizard.sql b/drizzle/0005_left_lizard.sql new file mode 100644 index 0000000..27ebc64 --- /dev/null +++ b/drizzle/0005_left_lizard.sql @@ -0,0 +1 @@ +ALTER TABLE "identity_evidence" DROP COLUMN "state"; \ No newline at end of file diff --git a/drizzle/0006_volatile_pandemic.sql b/drizzle/0006_volatile_pandemic.sql new file mode 100644 index 0000000..ff7d86c --- /dev/null +++ b/drizzle/0006_volatile_pandemic.sql @@ -0,0 +1,31 @@ +ALTER TABLE "permission" ADD COLUMN "managed" boolean DEFAULT false NOT NULL;--> statement-breakpoint +-- Master Admin holds every permission that exists, as a wildcard rather than a stored +-- grant list, so it keeps covering permissions created later. Its membership comes from +-- IDP_ADMIN_USER_IDS or the Entra administration policy rather than from identity evidence. +-- Master Admin requires an explicitly configured administrators group or user allowlist. +-- It has no source_state because deployment configuration provides the bootstrap path +-- independently of the editable role graph. +INSERT INTO "role" ("id", "key", "name", "description", "managed", "source_state") VALUES + ('static-role-master-admin', 'master-admin', 'Master Admin', 'Complete control of this identity provider.', true, NULL) +ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint +-- The permissions covering this identity provider's own administration. The code checks +-- for these exact keys, so they can never be created or deleted by hand; which roles carry +-- them is entirely up to the administrator. +INSERT INTO "permission" ("id", "key", "name", "description", "managed") VALUES + ('managed-permission-idp-people-read', 'idp:people:read', 'Find people', 'Search the people registered with this identity provider.', true), + ('managed-permission-idp-permissions-read', 'idp:permissions:read', 'View permissions', 'See the permissions this identity provider defines.', true), + ('managed-permission-idp-permissions-write', 'idp:permissions:write', 'Manage permissions', 'Create, change, and delete permissions, and choose what each one also grants.', true), + ('managed-permission-idp-roles-read', 'idp:roles:read', 'View roles', 'See roles, what they grant, and who holds them.', true), + ('managed-permission-idp-roles-write', 'idp:roles:write', 'Manage roles', 'Create, change, and delete roles, and give them to people.', true), + ('managed-permission-idp-applications-read', 'idp:applications:read', 'View applications', 'See the applications that sign people in with PoliNetwork Identity.', true), + ('managed-permission-idp-applications-write', 'idp:applications:write', 'Manage applications', 'Register applications, edit their redirect URIs and scopes, rotate secrets, and delete them.', true) +ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint +-- Writing implies reading, managing roles implies finding the people to give them to, and +-- understanding a role means being able to see the permissions it carries. +INSERT INTO "permission_implication" ("permission_id", "implied_permission_id") VALUES + ('managed-permission-idp-permissions-write', 'managed-permission-idp-permissions-read'), + ('managed-permission-idp-roles-read', 'managed-permission-idp-permissions-read'), + ('managed-permission-idp-roles-write', 'managed-permission-idp-roles-read'), + ('managed-permission-idp-roles-write', 'managed-permission-idp-people-read'), + ('managed-permission-idp-applications-write', 'managed-permission-idp-applications-read') +ON CONFLICT DO NOTHING; diff --git a/drizzle/0007_mushy_the_fury.sql b/drizzle/0007_mushy_the_fury.sql new file mode 100644 index 0000000..f271cc0 --- /dev/null +++ b/drizzle/0007_mushy_the_fury.sql @@ -0,0 +1,51 @@ +CREATE TABLE "rbac_audit_event" ( + "id" text PRIMARY KEY NOT NULL, + "actor_id" text NOT NULL, + "operation" text NOT NULL, + "target_id" text NOT NULL, + "before" jsonb NOT NULL, + "after" jsonb NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE FUNCTION reject_rbac_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'RBAC audit events are append-only'; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER rbac_audit_append_only BEFORE UPDATE OR DELETE OR TRUNCATE ON rbac_audit_event +FOR EACH STATEMENT EXECUTE FUNCTION reject_rbac_audit_mutation(); +--> statement-breakpoint +CREATE FUNCTION guard_managed_role_links() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_TABLE_NAME = 'user_role' THEN + IF EXISTS (SELECT 1 FROM role WHERE id = NEW.role_id AND managed) THEN + RAISE EXCEPTION 'Managed roles cannot be assigned'; + END IF; + ELSE + IF EXISTS (SELECT 1 FROM role WHERE id = NEW.parent_role_id AND key = 'master-admin') THEN + RAISE EXCEPTION 'Master Admin cannot be inherited'; + END IF; + END IF; + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER user_role_unmanaged_only BEFORE INSERT OR UPDATE ON user_role +FOR EACH ROW EXECUTE FUNCTION guard_managed_role_links(); +--> statement-breakpoint +CREATE TRIGGER role_parent_no_master BEFORE INSERT OR UPDATE ON role_parent +FOR EACH ROW EXECUTE FUNCTION guard_managed_role_links(); +--> statement-breakpoint +-- Existing unsafe edges/assignments are quarantined in the audit record before removal. +INSERT INTO rbac_audit_event (id, actor_id, operation, target_id, before, after) +SELECT 'migration-0007-unsafe-links', 'system:migration:0007', 'quarantine', 'managed-role-links', +jsonb_build_object( + 'parents', (SELECT coalesce(jsonb_agg(p), '[]') FROM role_parent p JOIN role r ON r.id = p.parent_role_id WHERE r.key = 'master-admin'), + 'assignments', (SELECT coalesce(jsonb_agg(a), '[]') FROM user_role a JOIN role r ON r.id = a.role_id WHERE r.managed) +), '{}'::jsonb; +--> statement-breakpoint +DELETE FROM role_parent WHERE parent_role_id IN (SELECT id FROM role WHERE key = 'master-admin'); +--> statement-breakpoint +DELETE FROM user_role WHERE role_id IN (SELECT id FROM role WHERE managed); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..e60b76e --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2246 @@ +{ + "id": "a98f0cd1-6a05-43c5-b237-221ada57650b", + "prevId": "ad684e52-2757-47da-9c8b-5a401e669d8e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_issuer_subject_uidx": { + "name": "account_issuer_subject_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_authorizationCodeId_idx": { + "name": "oauthAccessToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClientResource_clientId_resourceId_uidx": { + "name": "oauthClientResource_clientId_resourceId_uidx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_clientId_idx": { + "name": "oauthClientResource_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_resourceId_idx": { + "name": "oauthClientResource_resourceId_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_authorizationCodeId_idx": { + "name": "oauthRefreshToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_evidence": { + "name": "identity_evidence", + "schema": "", + "columns": { + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "states": { + "name": "states", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "identity_evidence_issuer_subject_pk": { + "name": "identity_evidence_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.student_verification_challenge": { + "name": "student_verification_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "studentVerificationChallenge_email_uidx": { + "name": "studentVerificationChallenge_email_uidx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "studentVerificationChallenge_expiresAt_idx": { + "name": "studentVerificationChallenge_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "student_verification_challenge_user_id_user_id_fk": { + "name": "student_verification_challenge_user_id_user_id_fk", + "tableFrom": "student_verification_challenge", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission": { + "name": "permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_key_uidx": { + "name": "permission_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_implication": { + "name": "permission_implication", + "schema": "", + "columns": { + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "implied_permission_id": { + "name": "implied_permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "permissionImplication_implied_idx": { + "name": "permissionImplication_implied_idx", + "columns": [ + { + "expression": "implied_permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_implication_permission_id_permission_id_fk": { + "name": "permission_implication_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_implication_implied_permission_id_permission_id_fk": { + "name": "permission_implication_implied_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["implied_permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "permission_implication_permission_id_implied_permission_id_pk": { + "name": "permission_implication_permission_id_implied_permission_id_pk", + "columns": ["permission_id", "implied_permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_state": { + "name": "source_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_key_uidx": { + "name": "role_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_parent": { + "name": "role_parent", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_role_id": { + "name": "parent_role_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "roleParent_parent_idx": { + "name": "roleParent_parent_idx", + "columns": [ + { + "expression": "parent_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_parent_role_id_role_id_fk": { + "name": "role_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_parent_parent_role_id_role_id_fk": { + "name": "role_parent_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["parent_role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_parent_role_id_parent_role_id_pk": { + "name": "role_parent_role_id_parent_role_id_pk", + "columns": ["role_id", "parent_role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission": { + "name": "role_permission", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rolePermission_permission_idx": { + "name": "rolePermission_permission_idx", + "columns": [ + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permission_permission_id_permission_id_fk": { + "name": "role_permission_permission_id_permission_id_fk", + "tableFrom": "role_permission", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permission_role_id_permission_id_pk": { + "name": "role_permission_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_role": { + "name": "user_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "userRole_role_idx": { + "name": "userRole_role_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_role_user_id_user_id_fk": { + "name": "user_role_user_id_user_id_fk", + "tableFrom": "user_role", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_role_role_id_role_id_fk": { + "name": "user_role_role_id_role_id_fk", + "tableFrom": "user_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_role_user_id_role_id_pk": { + "name": "user_role_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..061ec68 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2240 @@ +{ + "id": "38e3db88-4f08-43e5-96b0-0c9d8264b0a5", + "prevId": "a98f0cd1-6a05-43c5-b237-221ada57650b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_issuer_subject_uidx": { + "name": "account_issuer_subject_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_authorizationCodeId_idx": { + "name": "oauthAccessToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClientResource_clientId_resourceId_uidx": { + "name": "oauthClientResource_clientId_resourceId_uidx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_clientId_idx": { + "name": "oauthClientResource_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_resourceId_idx": { + "name": "oauthClientResource_resourceId_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_authorizationCodeId_idx": { + "name": "oauthRefreshToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_evidence": { + "name": "identity_evidence", + "schema": "", + "columns": { + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "states": { + "name": "states", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "identity_evidence_issuer_subject_pk": { + "name": "identity_evidence_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.student_verification_challenge": { + "name": "student_verification_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "studentVerificationChallenge_email_uidx": { + "name": "studentVerificationChallenge_email_uidx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "studentVerificationChallenge_expiresAt_idx": { + "name": "studentVerificationChallenge_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "student_verification_challenge_user_id_user_id_fk": { + "name": "student_verification_challenge_user_id_user_id_fk", + "tableFrom": "student_verification_challenge", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission": { + "name": "permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_key_uidx": { + "name": "permission_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_implication": { + "name": "permission_implication", + "schema": "", + "columns": { + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "implied_permission_id": { + "name": "implied_permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "permissionImplication_implied_idx": { + "name": "permissionImplication_implied_idx", + "columns": [ + { + "expression": "implied_permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_implication_permission_id_permission_id_fk": { + "name": "permission_implication_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_implication_implied_permission_id_permission_id_fk": { + "name": "permission_implication_implied_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["implied_permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "permission_implication_permission_id_implied_permission_id_pk": { + "name": "permission_implication_permission_id_implied_permission_id_pk", + "columns": ["permission_id", "implied_permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_state": { + "name": "source_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_key_uidx": { + "name": "role_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_parent": { + "name": "role_parent", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_role_id": { + "name": "parent_role_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "roleParent_parent_idx": { + "name": "roleParent_parent_idx", + "columns": [ + { + "expression": "parent_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_parent_role_id_role_id_fk": { + "name": "role_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_parent_parent_role_id_role_id_fk": { + "name": "role_parent_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["parent_role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_parent_role_id_parent_role_id_pk": { + "name": "role_parent_role_id_parent_role_id_pk", + "columns": ["role_id", "parent_role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission": { + "name": "role_permission", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rolePermission_permission_idx": { + "name": "rolePermission_permission_idx", + "columns": [ + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permission_permission_id_permission_id_fk": { + "name": "role_permission_permission_id_permission_id_fk", + "tableFrom": "role_permission", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permission_role_id_permission_id_pk": { + "name": "role_permission_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_role": { + "name": "user_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "userRole_role_idx": { + "name": "userRole_role_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_role_user_id_user_id_fk": { + "name": "user_role_user_id_user_id_fk", + "tableFrom": "user_role", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_role_role_id_role_id_fk": { + "name": "user_role_role_id_role_id_fk", + "tableFrom": "user_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_role_user_id_role_id_pk": { + "name": "user_role_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..2a9f965 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2247 @@ +{ + "id": "219ad6aa-b78e-444a-8d4e-cddd7b31830a", + "prevId": "38e3db88-4f08-43e5-96b0-0c9d8264b0a5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_issuer_subject_uidx": { + "name": "account_issuer_subject_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_authorizationCodeId_idx": { + "name": "oauthAccessToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClientResource_clientId_resourceId_uidx": { + "name": "oauthClientResource_clientId_resourceId_uidx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_clientId_idx": { + "name": "oauthClientResource_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_resourceId_idx": { + "name": "oauthClientResource_resourceId_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_authorizationCodeId_idx": { + "name": "oauthRefreshToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_evidence": { + "name": "identity_evidence", + "schema": "", + "columns": { + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "states": { + "name": "states", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "identity_evidence_issuer_subject_pk": { + "name": "identity_evidence_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.student_verification_challenge": { + "name": "student_verification_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "studentVerificationChallenge_email_uidx": { + "name": "studentVerificationChallenge_email_uidx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "studentVerificationChallenge_expiresAt_idx": { + "name": "studentVerificationChallenge_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "student_verification_challenge_user_id_user_id_fk": { + "name": "student_verification_challenge_user_id_user_id_fk", + "tableFrom": "student_verification_challenge", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission": { + "name": "permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_key_uidx": { + "name": "permission_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_implication": { + "name": "permission_implication", + "schema": "", + "columns": { + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "implied_permission_id": { + "name": "implied_permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "permissionImplication_implied_idx": { + "name": "permissionImplication_implied_idx", + "columns": [ + { + "expression": "implied_permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_implication_permission_id_permission_id_fk": { + "name": "permission_implication_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_implication_implied_permission_id_permission_id_fk": { + "name": "permission_implication_implied_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["implied_permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "permission_implication_permission_id_implied_permission_id_pk": { + "name": "permission_implication_permission_id_implied_permission_id_pk", + "columns": ["permission_id", "implied_permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_state": { + "name": "source_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_key_uidx": { + "name": "role_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_parent": { + "name": "role_parent", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_role_id": { + "name": "parent_role_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "roleParent_parent_idx": { + "name": "roleParent_parent_idx", + "columns": [ + { + "expression": "parent_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_parent_role_id_role_id_fk": { + "name": "role_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_parent_parent_role_id_role_id_fk": { + "name": "role_parent_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["parent_role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_parent_role_id_parent_role_id_pk": { + "name": "role_parent_role_id_parent_role_id_pk", + "columns": ["role_id", "parent_role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission": { + "name": "role_permission", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rolePermission_permission_idx": { + "name": "rolePermission_permission_idx", + "columns": [ + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permission_permission_id_permission_id_fk": { + "name": "role_permission_permission_id_permission_id_fk", + "tableFrom": "role_permission", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permission_role_id_permission_id_pk": { + "name": "role_permission_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_role": { + "name": "user_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "userRole_role_idx": { + "name": "userRole_role_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_role_user_id_user_id_fk": { + "name": "user_role_user_id_user_id_fk", + "tableFrom": "user_role", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_role_role_id_role_id_fk": { + "name": "user_role_role_id_role_id_fk", + "tableFrom": "user_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_role_user_id_role_id_pk": { + "name": "user_role_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..a5d8838 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2303 @@ +{ + "id": "163aaf0e-49be-401c-9cee-918d5f96c037", + "prevId": "219ad6aa-b78e-444a-8d4e-cddd7b31830a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_issuer_subject_uidx": { + "name": "account_issuer_subject_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_authorizationCodeId_idx": { + "name": "oauthAccessToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauthClientResource_clientId_resourceId_uidx": { + "name": "oauthClientResource_clientId_resourceId_uidx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_clientId_idx": { + "name": "oauthClientResource_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthClientResource_resourceId_idx": { + "name": "oauthClientResource_resourceId_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_authorizationCodeId_idx": { + "name": "oauthRefreshToken_authorizationCodeId_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_evidence": { + "name": "identity_evidence", + "schema": "", + "columns": { + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "states": { + "name": "states", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "identity_evidence_issuer_subject_pk": { + "name": "identity_evidence_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.student_verification_challenge": { + "name": "student_verification_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "studentVerificationChallenge_email_uidx": { + "name": "studentVerificationChallenge_email_uidx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "studentVerificationChallenge_expiresAt_idx": { + "name": "studentVerificationChallenge_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "student_verification_challenge_user_id_user_id_fk": { + "name": "student_verification_challenge_user_id_user_id_fk", + "tableFrom": "student_verification_challenge", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission": { + "name": "permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_key_uidx": { + "name": "permission_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_implication": { + "name": "permission_implication", + "schema": "", + "columns": { + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "implied_permission_id": { + "name": "implied_permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "permissionImplication_implied_idx": { + "name": "permissionImplication_implied_idx", + "columns": [ + { + "expression": "implied_permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_implication_permission_id_permission_id_fk": { + "name": "permission_implication_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_implication_implied_permission_id_permission_id_fk": { + "name": "permission_implication_implied_permission_id_permission_id_fk", + "tableFrom": "permission_implication", + "tableTo": "permission", + "columnsFrom": ["implied_permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "permission_implication_permission_id_implied_permission_id_pk": { + "name": "permission_implication_permission_id_implied_permission_id_pk", + "columns": ["permission_id", "implied_permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_audit_event": { + "name": "rbac_audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed": { + "name": "managed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_state": { + "name": "source_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_key_uidx": { + "name": "role_key_uidx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_parent": { + "name": "role_parent", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_role_id": { + "name": "parent_role_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "roleParent_parent_idx": { + "name": "roleParent_parent_idx", + "columns": [ + { + "expression": "parent_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_parent_role_id_role_id_fk": { + "name": "role_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_parent_parent_role_id_role_id_fk": { + "name": "role_parent_parent_role_id_role_id_fk", + "tableFrom": "role_parent", + "tableTo": "role", + "columnsFrom": ["parent_role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_parent_role_id_parent_role_id_pk": { + "name": "role_parent_role_id_parent_role_id_pk", + "columns": ["role_id", "parent_role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission": { + "name": "role_permission", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rolePermission_permission_idx": { + "name": "rolePermission_permission_idx", + "columns": [ + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permission_permission_id_permission_id_fk": { + "name": "role_permission_permission_id_permission_id_fk", + "tableFrom": "role_permission", + "tableTo": "permission", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permission_role_id_permission_id_pk": { + "name": "role_permission_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_role": { + "name": "user_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "userRole_role_idx": { + "name": "userRole_role_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_role_user_id_user_id_fk": { + "name": "user_role_user_id_user_id_fk", + "tableFrom": "user_role", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_role_role_id_role_id_fk": { + "name": "user_role_role_id_role_id_fk", + "tableFrom": "user_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_role_user_id_role_id_pk": { + "name": "user_role_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3ccd586..c99d0e9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,34 @@ "when": 1788738508125, "tag": "0003_awesome_edwin_jarvis", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1789442003962, + "tag": "0004_overjoyed_mordo", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1789442009569, + "tag": "0005_left_lizard", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1789444594426, + "tag": "0006_volatile_pandemic", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1789650344269, + "tag": "0007_mushy_the_fury", + "breakpoints": true } ] } diff --git a/scripts/security-config.d.mts b/scripts/security-config.d.mts new file mode 100644 index 0000000..d18314e --- /dev/null +++ b/scripts/security-config.d.mts @@ -0,0 +1,3 @@ +export function validateSecurityConfiguration( + environment: Record, +): void; diff --git a/scripts/security-config.mjs b/scripts/security-config.mjs new file mode 100644 index 0000000..7db582f --- /dev/null +++ b/scripts/security-config.mjs @@ -0,0 +1,81 @@ +import { z } from "zod"; + +const optional = (schema) => + z.preprocess((value) => (value === "" ? undefined : value), schema.optional()); +const schema = z + .object({ + BETTER_AUTH_URL: z + .url() + .default("https://auth.polinetwork.org") + .refine((value) => { + const url = new URL(value); + if (url.username || url.password) return false; + // Cleartext HTTP would expose sessions and identity claims, so it is only ever + // tolerated for local development. + if (url.protocol === "http:") + return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + return url.protocol === "https:"; + }, "BETTER_AUTH_URL must be an HTTPS URL without credentials, or HTTP on localhost."), + BETTER_AUTH_SECRET: z.string().trim().min(32), + PN_ENTRA_MEMBER_GROUP_ID: optional(z.uuid()), + PN_ENTRA_DIRETTIVO_GROUP_ID: optional(z.uuid()), + PN_ENTRA_MEMBER_REFRESH_HOURS: optional(z.coerce.number().int().positive()), + STUDENT_VERIFICATION_TTL_DAYS: optional(z.coerce.number().int().positive()), + GOOGLE_CLIENT_ID: optional(z.string().trim().min(1)), + GOOGLE_CLIENT_SECRET: optional(z.string().trim().min(1)), + TELEGRAM_CLIENT_ID: optional(z.string().trim().min(1)), + TELEGRAM_CLIENT_SECRET: optional(z.string().trim().min(1)), + AZURE_TENANT_ID: optional(z.string().trim().min(1)), + AZURE_CLIENT_ID: optional(z.string().trim().min(1)), + AZURE_CLIENT_SECRET: optional(z.string().trim().min(1)), + AZURE_EMAIL_SENDER: optional(z.email()), + PN_ENTRA_TENANT_ID: optional(z.uuid()), + PN_ENTRA_CLIENT_ID: optional(z.string().trim().min(1)), + PN_ENTRA_CLIENT_SECRET: optional(z.string().trim().min(1)), + PN_ENTRA_OIDC_ADMIN_GROUP_ID: optional(z.uuid()), + IDP_ADMIN_USER_IDS: z + .string() + .default("") + .transform((value) => (value.trim() === "" ? [] : value.split(",").map((id) => id.trim()))) + .pipe(z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/))), + }) + .superRefine((config, context) => { + for (const keys of [ + ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], + ["TELEGRAM_CLIENT_ID", "TELEGRAM_CLIENT_SECRET"], + ["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"], + ]) { + if (keys.some((key) => config[key]) && !keys.every((key) => config[key])) + context.addIssue({ code: "custom", message: `Configure ${keys.join(", ")} together.` }); + } + const credentials = [ + config.PN_ENTRA_TENANT_ID, + config.PN_ENTRA_CLIENT_ID, + config.PN_ENTRA_CLIENT_SECRET, + ]; + if ( + (credentials.some(Boolean) || + config.PN_ENTRA_OIDC_ADMIN_GROUP_ID || + config.PN_ENTRA_DIRETTIVO_GROUP_ID) && + !credentials.every(Boolean) + ) + context.addIssue({ + code: "custom", + message: "PN Entra requires tenant, client ID and client secret together.", + }); + if (!config.PN_ENTRA_OIDC_ADMIN_GROUP_ID && config.IDP_ADMIN_USER_IDS.length === 0) + context.addIssue({ + code: "custom", + message: + "Configure PN_ENTRA_OIDC_ADMIN_GROUP_ID or a nonempty IDP_ADMIN_USER_IDS break-glass allowlist.", + }); + }); + +/** Validate before migrations or serving requests; never include configuration values in errors. */ +export function validateSecurityConfiguration(environment) { + const result = schema.safeParse(environment); + if (!result.success) + throw new Error( + `Invalid security configuration: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, + ); +} diff --git a/scripts/start.mjs b/scripts/start.mjs index d376a08..101daa6 100644 --- a/scripts/start.mjs +++ b/scripts/start.mjs @@ -1,5 +1,9 @@ import { fileURLToPath } from "node:url"; +import { validateSecurityConfiguration } from "./security-config.mjs"; + +validateSecurityConfiguration(process.env); + import { migrateDatabase } from "./migrate.mjs"; function requiredEnvironmentVariable(name) { diff --git a/src/auth/accounts.ts b/src/auth/accounts.ts index ea9c6c1..6cf09b6 100644 --- a/src/auth/accounts.ts +++ b/src/auth/accounts.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { account } from "../db/auth-schema"; import { db } from "../db/index"; +import { authorizationMutationLock } from "../db/security-lock"; import { isLoginProvider } from "./policy"; export class AccountError extends Error { @@ -13,17 +14,25 @@ export class AccountError extends Error { } export async function disconnectAccount(userId: string, accountId: string) { - const accounts = await db - .select({ id: account.id, providerId: account.providerId }) - .from(account) - .where(eq(account.userId, userId)); - const selected = accounts.find((candidate) => candidate.id === accountId); - if (!selected) throw new AccountError(404, "Connected account not found."); - if ( - isLoginProvider(selected.providerId) && - accounts.filter((candidate) => isLoginProvider(candidate.providerId)).length <= 1 - ) { - throw new AccountError(400, "Connect another login method before disconnecting this one."); - } - await db.delete(account).where(and(eq(account.id, accountId), eq(account.userId, userId))); + await db.transaction( + async (transaction) => { + await transaction.execute(authorizationMutationLock); + const accounts = await transaction + .select({ id: account.id, providerId: account.providerId }) + .from(account) + .where(eq(account.userId, userId)); + const selected = accounts.find((candidate) => candidate.id === accountId); + if (!selected) throw new AccountError(404, "Connected account not found."); + if ( + isLoginProvider(selected.providerId) && + accounts.filter((candidate) => isLoginProvider(candidate.providerId)).length <= 1 + ) { + throw new AccountError(400, "Connect another login method before disconnecting this one."); + } + await transaction + .delete(account) + .where(and(eq(account.id, accountId), eq(account.userId, userId))); + }, + { isolationLevel: "read committed" }, + ); } diff --git a/src/auth/api-guard.test.ts b/src/auth/api-guard.test.ts new file mode 100644 index 0000000..81859f7 --- /dev/null +++ b/src/auth/api-guard.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, expect, it, vi } from "vite-plus/test"; +const mocks = vi.hoisted(() => ({ session: vi.fn(), permissions: vi.fn() })); +vi.mock("./index", () => ({ auth: { api: { getSession: mocks.session } } })); +vi.mock("./idp-access", () => ({ idpPermissions: mocks.permissions })); +vi.mock("../env", () => ({ env: { BETTER_AUTH_URL: "https://auth.example" } })); +import { requireAnyIdpPermission, requireIdpPermission } from "./api-guard"; +const request = () => + new Request("https://auth.example/api/rbac/role-save?token=never-log", { + method: "POST", + headers: { origin: "https://auth.example" }, + body: "secret-body", + }); +beforeEach(() => { + mocks.session.mockReset().mockResolvedValue({ user: { id: "actor" } }); + mocks.permissions.mockReset().mockResolvedValue(["idp:roles:write"]); +}); +it("denies missing sessions", async () => { + mocks.session.mockResolvedValue(null); + expect(await requireIdpPermission(request(), "idp:roles:write")).toMatchObject({ + response: { status: 401 }, + }); +}); +it("denies an empty required set or an unknown permission", async () => { + expect(await requireAnyIdpPermission(request(), [])).toMatchObject({ response: { status: 403 } }); + expect(await requireIdpPermission(request(), "idp:roles:write:extra" as never)).toMatchObject({ + response: { status: 403 }, + }); +}); +it("denies a failed authorization lookup without logging secrets", async () => { + mocks.permissions.mockRejectedValue(new Error("private-provider-token")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(await requireIdpPermission(request(), "idp:roles:write")).toMatchObject({ + response: { status: 503 }, + }); + const log = JSON.stringify(warn.mock.calls); + expect(log).not.toMatch(/never-log|secret-body|private-provider-token/); + expect(log).toContain("authorization_denied"); + } finally { + warn.mockRestore(); + } +}); diff --git a/src/auth/api-guard.ts b/src/auth/api-guard.ts new file mode 100644 index 0000000..73fcb77 --- /dev/null +++ b/src/auth/api-guard.ts @@ -0,0 +1,57 @@ +import { logAuthorizationDenial } from "./denial-log"; +import { auth } from "./index"; +import { idpPermissions } from "./idp-access"; +import type { ManagedPermissionKey } from "./rbac"; +import { env } from "../env"; + +export const noStore = { "Cache-Control": "no-store" }; + +export function apiError(status: number, error: string, fields?: Record) { + return Response.json(fields ? { error, fields } : { error }, { status, headers: noStore }); +} + +type Guard = { session: { userId: string; permissions: string[] } } | { response: Response }; + +async function requirePermission( + request: Request, + required: readonly ManagedPermissionKey[], + options: { write?: boolean }, +): Promise { + const deny = (status: number, message: string, actorId: string | null = null): Guard => { + logAuthorizationDenial(actorId, new URL(request.url).pathname, required); + return { response: apiError(status, message) }; + }; + if (options.write && request.headers.get("origin") !== new URL(env.BETTER_AUTH_URL).origin) + return deny(403, "Invalid origin."); + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.id) return deny(401, "Unauthorized."); + let permissions: string[]; + try { + permissions = await idpPermissions(session.user.id); + } catch { + return deny(503, "Authorization unavailable.", session.user.id); + } + if (!required.some((permission) => permissions.includes(permission))) + return deny(403, "You do not have permission to do that.", session.user.id); + return { session: { userId: session.user.id, permissions } }; +} + +/** + * Shared entry check for the administration endpoints: a same-origin request when it + * changes something, a signed-in session, and the managed permission the endpoint needs. + */ +export async function requireIdpPermission( + request: Request, + permission: ManagedPermissionKey, + options: { write?: boolean } = {}, +): Promise { + return requirePermission(request, [permission], options); +} + +export async function requireAnyIdpPermission( + request: Request, + permissions: readonly ManagedPermissionKey[], + options: { write?: boolean } = {}, +): Promise { + return requirePermission(request, permissions, options); +} diff --git a/src/auth/denial-log.test.ts b/src/auth/denial-log.test.ts new file mode 100644 index 0000000..17fbc01 --- /dev/null +++ b/src/auth/denial-log.test.ts @@ -0,0 +1,16 @@ +import { expect, it, vi } from "vite-plus/test"; +import { logAuthorizationDenial } from "./denial-log"; +it("logs a denial using only actor, endpoint and required permission", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + logAuthorizationDenial("user-id", "/api/rbac/role-save", ["idp:roles:write"]); + expect(JSON.parse(warn.mock.calls[0][0])).toEqual({ + event: "authorization_denied", + actorId: "user-id", + endpoint: "/api/rbac/role-save", + required: ["idp:roles:write"], + }); + } finally { + warn.mockRestore(); + } +}); diff --git a/src/auth/denial-log.ts b/src/auth/denial-log.ts new file mode 100644 index 0000000..3b7b01b --- /dev/null +++ b/src/auth/denial-log.ts @@ -0,0 +1,8 @@ +/** Deliberately exclude query strings, request bodies, cookies and provider error details. */ +export function logAuthorizationDenial( + actorId: string | null, + endpoint: string, + required: readonly string[], +) { + console.warn(JSON.stringify({ event: "authorization_denied", actorId, endpoint, required })); +} diff --git a/src/auth/identity-subject.test.ts b/src/auth/identity-subject.test.ts new file mode 100644 index 0000000..d3bfae5 --- /dev/null +++ b/src/auth/identity-subject.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +const mocks = vi.hoisted(() => ({ check: vi.fn() })); +vi.mock("../env", () => ({ + env: { PN_ENTRA_TENANT_ID: "tenant", PN_ENTRA_MEMBER_GROUP_ID: "soci" }, +})); +vi.mock("../db/index", () => ({ db: {} })); +vi.mock("./membership", () => ({ checkEntraGroupMember: mocks.check })); +vi.mock("./oidc-admin", () => ({ + canAdministerIdp: async () => false, + createGroupMembershipCache: (check: unknown) => Object.assign(check!, { cached: () => false }), +})); +import { readIdentitySubject, type IdentityReader } from "./identity-subject"; + +function reader(proofs: unknown[], exists = true): IdentityReader { + return { + select: vi + .fn() + .mockReturnValueOnce({ + from: () => ({ where: async () => (exists ? [{ id: "user" }] : []) }), + }) + .mockReturnValueOnce({ from: () => ({ innerJoin: () => ({ where: async () => proofs }) }) }), + } as unknown as IdentityReader; +} +const proof = { + issuer: "https://login.microsoftonline.com/tenant/v2.0", + providerId: "pn-entra", + externalId: "member", + states: ["socio"], + validUntil: new Date(Date.now() + 86_400_000), + telegramId: null, +}; + +describe("authorization evidence trust and freshness", () => { + it.each([false, null])( + "denies stored unexpired membership when live verification returns %s", + async (result) => { + mocks.check.mockResolvedValue(result); + expect((await readIdentitySubject("user", reader([proof]), true)).roleKeys).toEqual([]); + }, + ); + it("denies another tenant's evidence even if membership checks would pass", async () => { + mocks.check.mockResolvedValue(true); + expect( + ( + await readIdentitySubject( + "user", + reader([{ ...proof, issuer: "https://foreign.invalid" }]), + true, + ) + ).roleKeys, + ).toEqual([]); + }); + it("denies missing subjects before considering any evidence", async () => { + await expect(readIdentitySubject("deleted-user", reader([proof], false))).rejects.toThrow(); + }); +}); diff --git a/src/auth/identity-subject.ts b/src/auth/identity-subject.ts new file mode 100644 index 0000000..8900b4a --- /dev/null +++ b/src/auth/identity-subject.ts @@ -0,0 +1,81 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../db/index"; +import { account, identityEvidence, user } from "../db/schema"; +import { env } from "../env"; +import { identityStates } from "./policy"; +import { checkEntraGroupMember } from "./membership"; +import { canAdministerIdp, createGroupMembershipCache } from "./oidc-admin"; +import { MASTER_ADMIN_ROLE_KEY, staticRolesForStates } from "./rbac"; + +export type IdentityReader = Pick; +export const MEMBERSHIP_CACHE_MS = 60_000; +const member = createGroupMembershipCache(checkEntraGroupMember, MEMBERSHIP_CACHE_MS); + +/** The subject is always a persisted user; evidence must match the configured issuer. */ +export async function readIdentitySubject( + userId: string, + reader: IdentityReader, + refreshMembership = false, +) { + const [subject] = await reader.select({ id: user.id }).from(user).where(eq(user.id, userId)); + if (!subject) throw new Error("Unknown identity subject."); + const proofs = await reader + .select({ + issuer: account.issuer, + providerId: identityEvidence.providerId, + externalId: identityEvidence.externalId, + states: identityEvidence.states, + validUntil: identityEvidence.validUntil, + telegramId: identityEvidence.telegramId, + }) + .from(account) + .innerJoin( + identityEvidence, + and( + eq(account.issuer, identityEvidence.issuer), + eq(account.accountId, identityEvidence.subject), + eq(account.providerId, identityEvidence.providerId), + ), + ) + .where(eq(account.userId, userId)); + const entraIssuer = env.PN_ENTRA_TENANT_ID + ? `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID}/v2.0` + : undefined; + const trusted = proofs.filter( + (proof) => + (proof.providerId === "polimi-email" && proof.issuer === "https://mail.polimi.it") || + (proof.providerId === "telegram" && proof.issuer === "https://oauth.telegram.org"), + ); + const { states, telegramId } = identityStates(trusted); + const verifiedStates = new Set(states); + // Persisted group evidence is display/history data, never an authorization cache. + // Recheck all group-backed rights with the same short bound, including downstream rights. + const check = refreshMembership ? member : member.cached; + const checks = proofs.flatMap((proof) => { + if (proof.providerId !== "pn-entra" || proof.issuer !== entraIssuer || !proof.externalId) + return []; + const objectId = proof.externalId; + return [ + ["socio", env.PN_ENTRA_MEMBER_GROUP_ID], + ["direttivo", env.PN_ENTRA_DIRETTIVO_GROUP_ID], + ].map(async ([state, groupId]) => { + if (groupId && (await check(groupId, objectId))) verifiedStates.add(state!); + }); + }); + const [master] = await Promise.all([ + canAdministerIdp(userId, reader, refreshMembership), + ...checks, + ]); + const currentStates = [...verifiedStates].sort(); + return { + states: currentStates, + telegramId, + roleKeys: [...staticRolesForStates(currentStates), ...(master ? [MASTER_ADMIN_ROLE_KEY] : [])], + }; +} + +/** Warm only external membership facts, before taking a transaction or mutation lock. + * The transaction then rereads account ownership/evidence and checks cache expiry again. */ +export async function refreshIdentityMembership(userId: string) { + await readIdentitySubject(userId, db, true); +} diff --git a/src/auth/identity.integration.test.ts b/src/auth/identity.integration.test.ts index 3b8c952..ae38d1b 100644 --- a/src/auth/identity.integration.test.ts +++ b/src/auth/identity.integration.test.ts @@ -6,10 +6,8 @@ const baseURL = process.env.IDENTITY_TEST_URL; const databaseURL = process.env.IDENTITY_TEST_DATABASE_URL; const secret = process.env.IDENTITY_TEST_SECRET; -describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration", () => { - const pool = new Pool({ connectionString: databaseURL }); - const token = "identity-integration-session"; - const headers = { +function sessionHeaders(token: string) { + return { Cookie: `better-auth.session_token=${encodeURIComponent( `${token}.${createHmac("sha256", secret ?? "unused") .update(token) @@ -18,12 +16,28 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration" Origin: baseURL ?? "http://localhost", "Content-Type": "application/json", }; +} + +describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration", () => { + const pool = new Pool({ connectionString: databaseURL }); + let usedInternalApi = false; + const token = "identity-integration-session"; + const headers = sessionHeaders(token); + const permissionReaderHeaders = sessionHeaders("permission-reader-session"); beforeAll(async () => { await pool.query( - `INSERT INTO "user" (id, name, email) VALUES ('integration-user', 'Test', 'test@identity.invalid')`, + `INSERT INTO "user" (id, name, email) VALUES + ('integration-user', 'Test', 'test@identity.invalid'), + ('permission-reader', 'Permission Reader', 'permission-reader@identity.invalid'), + ('application-reader', 'Application Reader', 'application-reader@identity.invalid'), + ('application-writer', 'Application Writer', 'application-writer@identity.invalid')`, ); await pool.query( - `INSERT INTO session (id, token, user_id, expires_at, updated_at) VALUES ('integration-session', $1, 'integration-user', NOW() + interval '1 hour', NOW())`, + `INSERT INTO session (id, token, user_id, expires_at, updated_at) VALUES + ('integration-session', $1, 'integration-user', NOW() + interval '1 hour', NOW()), + ('permission-reader-session', 'permission-reader-session', 'permission-reader', NOW() + interval '1 hour', NOW()), + ('application-reader-session', 'application-reader-session', 'application-reader', NOW() + interval '1 hour', NOW()), + ('application-writer-session', 'application-writer-session', 'application-writer', NOW() + interval '1 hour', NOW())`, [token], ); for (const [id, provider, subject] of [ @@ -32,20 +46,62 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration" ["integration-tg", "telegram", "tg-subject"], ]) { await pool.query( - `INSERT INTO account (id, provider_id, issuer, account_id, user_id, updated_at) VALUES ($1, $2, $2, $3, 'integration-user', NOW())`, - [id, provider, subject], + `INSERT INTO account (id, provider_id, issuer, account_id, user_id, updated_at) VALUES ($1, $2, $4, $3, 'integration-user', NOW())`, + [id, provider, subject, provider === "telegram" ? "https://oauth.telegram.org" : provider], ); } await pool.query( - `INSERT INTO identity_evidence (issuer, subject, provider_id, state, valid_until, telegram_id) VALUES ('pn-entra', 'pn-subject', 'pn-entra', 'socio', NOW() + interval '1 hour', NULL), ('telegram', 'tg-subject', 'telegram', NULL, NOW() + interval '1 hour', '123456')`, + `INSERT INTO identity_evidence (issuer, subject, provider_id, states, valid_until, telegram_id) VALUES + ('pn-entra', 'pn-subject', 'pn-entra', ARRAY['socio']::text[], NOW() + interval '1 hour', NULL), + ('https://oauth.telegram.org', 'tg-subject', 'telegram', ARRAY[]::text[], NOW() + interval '1 hour', '123456')`, + ); + await pool.query( + `INSERT INTO role (id, key, name) VALUES + ('integration-permission-reader-role', 'integration-permission-reader', 'Permission Reader')`, + ); + await pool.query( + `INSERT INTO role_permission (role_id, permission_id) + SELECT 'integration-permission-reader-role', id + FROM permission + WHERE key = 'idp:permissions:read'`, + ); + await pool.query( + `INSERT INTO user_role (user_id, role_id) + VALUES ('permission-reader', 'integration-permission-reader-role')`, + ); + for (const suffix of ["reader", "writer"]) { + await pool.query(`INSERT INTO role (id, key, name) VALUES ($1, $1, $1)`, [ + `integration-application-${suffix}-role`, + ]); + await pool.query( + `INSERT INTO role_permission (role_id, permission_id) SELECT $1, id FROM permission WHERE key = $2`, + [ + `integration-application-${suffix}-role`, + `idp:applications:${suffix === "reader" ? "read" : "write"}`, + ], + ); + await pool.query(`INSERT INTO user_role (user_id, role_id) VALUES ($1, $2)`, [ + `application-${suffix}`, + `integration-application-${suffix}-role`, + ]); + } + await pool.query( + `INSERT INTO oauth_client (id, client_id, name, reference_id, redirect_uris) VALUES ('integration-foreign-client', 'integration-foreign-client', 'Foreign', 'foreign-pool', ARRAY['https://example.com/callback'])`, ); }); afterAll(async () => { - await pool.query(`DELETE FROM "user" WHERE id = 'integration-user'`); await pool.query( - `DELETE FROM identity_evidence WHERE issuer IN ('pn-entra', 'telegram', 'https://mail.polimi.it')`, + `DELETE FROM "user" WHERE id IN ('integration-user', 'permission-reader', 'application-reader', 'application-writer')`, ); + await pool.query( + `DELETE FROM role WHERE id IN ('integration-permission-reader-role', 'integration-application-reader-role', 'integration-application-writer-role')`, + ); + await pool.query( + `DELETE FROM identity_evidence WHERE subject IN ('pn-subject', 'tg-subject', 'student@mail.polimi.it')`, + ); + await pool.query(`DELETE FROM oauth_client WHERE id = 'integration-foreign-client'`); await pool.end(); + if (usedInternalApi) await (await import("../db/index")).db.$client.end(); }); it("denies anonymous identity access", async () => { const response = await fetch(`${baseURL}/api/identity`); @@ -105,16 +161,35 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration" grant_types_supported: ["authorization_code", "refresh_token"], }); }); - it("returns linked proofs but never grants Telegram moderation", async () => { + it("rejects the fabricated Entra issuer while returning linked Telegram metadata", async () => { const response = await fetch(`${baseURL}/api/identity`, { headers }); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); expect(await response.json()).toEqual({ - states: ["socio"], - permissions: ["membership:read"], + states: [], + roles: [], + permissions: [], telegramId: "123456", }); }); + it("does not disclose the role graph to a permissions-only reader", async () => { + const response = await fetch(`${baseURL}/api/rbac/catalog`, { + headers: permissionReaderHeaders, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + roles: [], + permissions: expect.arrayContaining([ + expect.objectContaining({ key: "idp:permissions:read" }), + ]), + }); + + const members = await fetch( + `${baseURL}/api/rbac/role-members?role_id=integration-permission-reader-role`, + { headers: permissionReaderHeaders }, + ); + expect(members.status).toBe(403); + }); it("denies client registration to ordinary users", async () => { const response = await fetch(`${baseURL}/api/auth/oauth2/create-client`, { method: "POST", @@ -123,6 +198,58 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration" }); expect(response.status).toBe(401); }); + it("denies every built-in client mutation to a read-only application administrator", async () => { + const readHeaders = sessionHeaders("application-reader-session"); + expect((await fetch(`${baseURL}/api/oidc/clients`, { headers: readHeaders })).status).toBe(200); + for (const [path, body] of [ + ["create-client", { redirect_uris: ["https://example.com/callback"] }], + [ + "update-client", + { client_id: "integration-foreign-client", update: { client_name: "Stolen" } }, + ], + ["delete-client", { client_id: "integration-foreign-client" }], + ["client/rotate-secret", { client_id: "integration-foreign-client" }], + ] as const) { + const response = await fetch(`${baseURL}/api/auth/oauth2/${path}`, { + method: "POST", + headers: readHeaders, + body: JSON.stringify(body), + }); + expect(response.status).toBe(401); + } + const response = await fetch(`${baseURL}/api/oidc/client-update`, { + method: "POST", + headers: readHeaders, + body: JSON.stringify({ clientId: "integration-foreign-client", disabled: true }), + }); + expect(response.status).toBe(403); + }); + it("denies cross-pool built-in client mutation even to an application writer", async () => { + const response = await fetch(`${baseURL}/api/auth/oauth2/delete-client`, { + method: "POST", + headers: sessionHeaders("application-writer-session"), + body: JSON.stringify({ client_id: "integration-foreign-client" }), + }); + expect(response.status).toBe(401); + expect( + (await pool.query(`SELECT id FROM oauth_client WHERE id = 'integration-foreign-client'`)) + .rows, + ).toHaveLength(1); + }); + it("denies resource-policy mutation through the server SDK to an ordinary session", async () => { + usedInternalApi = true; + const { auth } = await import("./index"); + await expect( + auth.api.adminCreateOAuthResource({ + headers: new Headers(headers), + body: { identifier: "https://example.invalid/security-resource" }, + }), + ).rejects.toMatchObject({ status: "UNAUTHORIZED" }); + }); + it("does not expose server-only resource administration over HTTP", async () => { + const response = await fetch(`${baseURL}/api/auth/admin/oauth2/resources`, { headers }); + expect(response.status).toBe(404); + }); it("prevents duplicate ownership of an upstream identity", async () => { await expect( pool.query( @@ -147,8 +274,9 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration" }); expect(response.status).toBe(200); expect(await (await fetch(`${baseURL}/api/identity`, { headers })).json()).toEqual({ - states: ["socio", "student"], - permissions: ["membership:read", "student:verified"], + states: ["student"], + roles: ["student"], + permissions: ["student:verified"], telegramId: "123456", }); }); @@ -162,6 +290,7 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration" expect((await unlink("integration-pn")).status).toBe(200); expect(await (await fetch(`${baseURL}/api/identity`, { headers })).json()).toEqual({ states: ["student"], + roles: ["student"], permissions: ["student:verified"], telegramId: "123456", }); diff --git a/src/auth/identity.ts b/src/auth/identity.ts index d13c945..1dd0554 100644 --- a/src/auth/identity.ts +++ b/src/auth/identity.ts @@ -1,67 +1,8 @@ -import { and, eq } from "drizzle-orm"; -import { identityEvidence } from "../db/evidence"; -import { db } from "../db/index"; -import { account } from "../db/schema"; import { env } from "../env"; -import { identityClaims, oidcIdentityClaims } from "./policy"; -import { checkPnMemberGroup, membershipEvidence } from "./membership"; +import { oidcIdentityClaims } from "./policy"; +import { resolveUserIdentity } from "./rbac-store"; -async function refreshExpiredMembership(userId: string) { - const now = new Date(); - const stale = await db - .select({ - issuer: identityEvidence.issuer, - subject: identityEvidence.subject, - externalId: identityEvidence.externalId, - validUntil: identityEvidence.validUntil, - state: identityEvidence.state, - }) - .from(account) - .innerJoin( - identityEvidence, - and( - eq(account.issuer, identityEvidence.issuer), - eq(account.accountId, identityEvidence.subject), - ), - ) - .where(eq(account.userId, userId)); - - for (const proof of stale) { - if (proof.issuer !== `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID}/v2.0`) - continue; - if (proof.validUntil > now || !proof.externalId) continue; - const member = await checkPnMemberGroup(proof.externalId); - if (member === null) continue; - await db - .update(identityEvidence) - .set(membershipEvidence(member)) - .where( - and(eq(identityEvidence.issuer, proof.issuer), eq(identityEvidence.subject, proof.subject)), - ); - } -} - -export async function getIdentity(userId: string) { - await refreshExpiredMembership(userId); - const proofs = await db - .select({ - providerId: identityEvidence.providerId, - externalId: identityEvidence.externalId, - state: identityEvidence.state, - validUntil: identityEvidence.validUntil, - telegramId: identityEvidence.telegramId, - }) - .from(account) - .innerJoin( - identityEvidence, - and( - eq(account.issuer, identityEvidence.issuer), - eq(account.accountId, identityEvidence.subject), - ), - ) - .where(eq(account.userId, userId)); - return identityClaims(proofs); -} +export const getIdentity = resolveUserIdentity; export async function getOidcClaims(userId: string, scopes: string[]) { if (!scopes.includes("polinetwork:identity")) return {}; diff --git a/src/auth/idp-access.ts b/src/auth/idp-access.ts new file mode 100644 index 0000000..42b5702 --- /dev/null +++ b/src/auth/idp-access.ts @@ -0,0 +1,23 @@ +import { MANAGED_PERMISSION_KEYS, type ManagedPermissionKey } from "./rbac"; +import { getIdentity } from "./identity"; + +/** + * What a person may do to this identity provider itself, as the managed `idp:*` + * permissions they hold. + * + * These come out of the same resolution as every other permission, so they can be given to + * any role. Whoever the deployment configures as an administrator holds Master Admin, which + * is a wildcard over every permission, so the configured allowlist keeps working as the + * bootstrap and break-glass path without being a second kind of check. + */ +export async function idpPermissions(userId: string): Promise { + const identity = await getIdentity(userId); + return identity.permissions.filter((key) => MANAGED_PERMISSION_KEYS.includes(key)); +} + +export async function hasIdpPermission( + userId: string, + required: ManagedPermissionKey, +): Promise { + return (await idpPermissions(userId)).includes(required); +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 91fc865..ebc2d45 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -11,7 +11,8 @@ import { db } from "../db"; import * as schema from "../db/schema"; import { env } from "../env"; import { getOidcClaims } from "./identity"; -import { canManageOidcClients } from "./oidc-admin"; +import { logAuthorizationDenial } from "./denial-log"; +import { hasIdpPermission } from "./idp-access"; import { OIDC_CLIENT_REFERENCE } from "./oidc-clients"; import { isLinkOnlyProvider } from "./policy"; import { providers } from "./providers"; @@ -104,7 +105,17 @@ export const auth = betterAuth({ allowPublicClientPrelogin: true, // Administrators share one client pool instead of owning clients individually. clientReference: () => OIDC_CLIENT_REFERENCE, - clientPrivileges: ({ user }) => (user ? canManageOidcClients(user.id) : false), + clientPrivileges: async ({ user, action }) => { + const allowed = user ? await hasIdpPermission(user.id, "idp:applications:write") : false; + if (!allowed) + logAuthorizationDenial(user?.id ?? null, `oauth-client:${action}`, [ + "idp:applications:write", + ]); + return allowed; + }, + // Resource-policy administration is not a supported product surface. The plugin's + // server-only SDK defaults to permitting any session unless this hook is set. + resourcePrivileges: () => false, accessTokenExpiresIn: 300, idTokenExpiresIn: 300, customIdTokenClaims: ({ user, scopes }) => getOidcClaims(user.id, scopes), diff --git a/src/auth/membership.test.ts b/src/auth/membership.test.ts index 4171218..32db3fb 100644 --- a/src/auth/membership.test.ts +++ b/src/auth/membership.test.ts @@ -7,6 +7,7 @@ vi.mock("../env", () => ({ PN_ENTRA_CLIENT_ID: "pn-app", PN_ENTRA_CLIENT_SECRET: "pn-secret", PN_ENTRA_MEMBER_GROUP_ID: "soci", + PN_ENTRA_DIRETTIVO_GROUP_ID: "direttivo", PN_ENTRA_MEMBER_REFRESH_HOURS: 24, }, })); @@ -18,10 +19,17 @@ vi.mock("@azure/identity", () => ({ }, })); vi.mock("@microsoft/microsoft-graph-client", () => ({ - Client: { initWithMiddleware: () => ({ api: () => ({ get: mocks.get }) }) }, + Client: { + initWithMiddleware: () => ({ api: () => ({ option: () => ({ get: mocks.get }) }) }), + }, })); -import { checkPnMemberGroup, membershipEvidence, readGroupMembership } from "./membership"; +import { + GRAPH_CHECK_TIMEOUT_MS, + checkPnGroupStates, + membershipEvidence, + readGroupMembership, +} from "./membership"; describe("PN membership verification", () => { beforeEach(() => { @@ -30,7 +38,7 @@ describe("PN membership verification", () => { it("uses PN credentials without mail sender credentials", async () => { mocks.get.mockResolvedValue({ value: [{ id: "member" }] }); - expect(await checkPnMemberGroup("member")).toBe(true); + expect(await checkPnGroupStates("member")).toEqual(["socio", "direttivo"]); expect(mocks.credential).toHaveBeenCalledWith("pn-tenant", "pn-app", "pn-secret"); }); @@ -60,7 +68,7 @@ describe("PN membership verification", () => { ); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { - expect(await checkPnMemberGroup("member")).toBeNull(); + expect(await checkPnGroupStates("member")).toBeNull(); expect(warn).toHaveBeenCalled(); expect(JSON.stringify(warn.mock.calls)).not.toContain("private error details"); } finally { @@ -69,16 +77,51 @@ describe("PN membership verification", () => { }, ); + it("reports each group separately and only what Graph confirmed", async () => { + mocks.get + .mockResolvedValueOnce({ value: [{ id: "member" }] }) + .mockResolvedValueOnce({ value: [{ id: "someone-else" }] }); + expect(await checkPnGroupStates("member")).toEqual(["socio"]); + }); + + it("starts both configured group checks together", async () => { + const resolve: ((page: { value: { id: string }[] }) => void)[] = []; + mocks.get.mockImplementation( + () => + new Promise<{ value: { id: string }[] }>((done) => { + resolve.push(done); + }), + ); + const result = checkPnGroupStates("member"); + expect(mocks.get).toHaveBeenCalledTimes(2); + for (const done of resolve) done({ value: [{ id: "member" }] }); + await expect(result).resolves.toEqual(["socio", "direttivo"]); + }); + it("does not cache failed checks for a full membership interval", () => { const now = new Date("2026-09-07T00:00:00Z"); - expect(membershipEvidence(null, now)).toEqual({ state: null, validUntil: now }); - expect(membershipEvidence(true, now)).toEqual({ - state: "socio", + expect(membershipEvidence(null, now)).toEqual({ states: [], validUntil: now }); + expect(membershipEvidence(["socio", "direttivo"], now)).toEqual({ + states: ["socio", "direttivo"], validUntil: new Date("2026-09-08T00:00:00Z"), }); - expect(membershipEvidence(false, now)).toEqual({ - state: null, + expect(membershipEvidence([], now)).toEqual({ + states: [], validUntil: new Date("2026-09-08T00:00:00Z"), }); }); + + it("bounds a stalled Graph lookup and denies group evidence", async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + mocks.get.mockImplementation(() => new Promise(() => {})); + const result = checkPnGroupStates("member"); + await vi.advanceTimersByTimeAsync(GRAPH_CHECK_TIMEOUT_MS); + await expect(result).resolves.toBeNull(); + } finally { + warn.mockRestore(); + vi.useRealTimers(); + } + }); }); diff --git a/src/auth/membership.ts b/src/auth/membership.ts index c78509e..4d2b97c 100644 --- a/src/auth/membership.ts +++ b/src/auth/membership.ts @@ -20,6 +20,7 @@ export async function readGroupMembership( } let graphClient: Client | undefined; +export const GRAPH_CHECK_TIMEOUT_MS = 5_000; /** * Checks whether an Entra object is a direct member of a group through Microsoft Graph. @@ -34,6 +35,8 @@ export async function checkEntraGroupMember( console.warn("Entra group check unavailable: configure PN_ENTRA credentials."); return null; } + const controller = new AbortController(); + let timer: ReturnType | undefined; try { if (!graphClient) { const credential = new ClientSecretCredential( @@ -49,7 +52,19 @@ export async function checkEntraGroupMember( }); } const client = graphClient; - return await readGroupMembership((path) => client.api(path).get(), groupId, objectId); + return await Promise.race([ + readGroupMembership( + (path) => client.api(path).option("signal", controller.signal).get(), + groupId, + objectId, + ), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error("Graph membership check timed out.")); + }, GRAPH_CHECK_TIMEOUT_MS); + }), + ]); } catch (error) { // Do not log Graph errors wholesale: they may contain tokens or personal data. const status = error instanceof Error && "statusCode" in error ? error.statusCode : null; @@ -61,19 +76,37 @@ export async function checkEntraGroupMember( "The PN_ENTRA_CLIENT_ID app needs Graph application GroupMember.Read.All with admin consent; check the group ID too.", ); return null; + } finally { + clearTimeout(timer); } } -export function checkPnMemberGroup(objectId: string): Promise { - return checkEntraGroupMember(env.PN_ENTRA_MEMBER_GROUP_ID, objectId); +/** + * The states the PoliNetwork Entra groups currently prove for an Entra object. Returns null + * when any configured check could not be performed, so a Graph outage never looks like a + * confirmed loss of membership. + */ +export async function checkPnGroupStates(objectId: string): Promise { + const groups = [ + { state: "socio", groupId: env.PN_ENTRA_MEMBER_GROUP_ID }, + { state: "direttivo", groupId: env.PN_ENTRA_DIRETTIVO_GROUP_ID }, + ].filter((group): group is { state: string; groupId: string } => Boolean(group.groupId)); + const memberships = await Promise.all( + groups.map((group) => checkEntraGroupMember(group.groupId, objectId)), + ); + if (memberships.some((member) => member === null)) return null; + return groups.flatMap((group, index) => (memberships[index] ? [group.state] : [])); } -export function membershipEvidence(member: boolean | null, now = new Date()) { +/** + * Evidence to store for an Entra account. An unsuccessful check expires immediately instead + * of being cached as a confirmed nonmember, so the next request retries. + */ +export function membershipEvidence(states: string[] | null, now = new Date()) { return { - state: member === true ? "socio" : null, - // An unsuccessful check must not be cached as a confirmed nonmember. + states: states ?? [], validUntil: new Date( - now.getTime() + (member === null ? 0 : env.PN_ENTRA_MEMBER_REFRESH_HOURS * 3_600_000), + now.getTime() + (states === null ? 0 : env.PN_ENTRA_MEMBER_REFRESH_HOURS * 3_600_000), ), }; } diff --git a/src/auth/oidc-admin.test.ts b/src/auth/oidc-admin.test.ts index 644150b..409972a 100644 --- a/src/auth/oidc-admin.test.ts +++ b/src/auth/oidc-admin.test.ts @@ -7,7 +7,7 @@ vi.mock("./membership", () => ({ checkEntraGroupMember: vi.fn() })); import { createGroupMembershipCache, decideOidcAdmin } from "./oidc-admin"; describe("OIDC administrator policy", () => { - it("lets any PN Entra account manage clients until a stricter group is configured", () => { + it("denies a linked PN Entra account when no administrator group is configured", () => { expect( decideOidcAdmin({ allowlisted: false, @@ -15,7 +15,7 @@ describe("OIDC administrator policy", () => { groupConfigured: false, groupMember: false, }), - ).toBe(true); + ).toBe(false); }); it("requires membership of the stricter group once configured", () => { @@ -77,3 +77,59 @@ describe("group membership cache", () => { expect(check).toHaveBeenCalledTimes(2); }); }); + +describe("bounded administrative revocation", () => { + it("denies a removed member at the cache deadline and on lookup failure", async () => { + let time = 0; + const check = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(null); + const member = createGroupMembershipCache(check, 60_000, () => time); + expect(await member("admin-group", "removed-user")).toBe(true); + time = 60_000; + expect(await member("admin-group", "removed-user")).toBe(false); + time = 120_000; + expect(await member("admin-group", "removed-user")).toBe(false); + }); + it("denies a positive response whose lookup outlives its authorization window", async () => { + let time = 0; + const member = createGroupMembershipCache( + async () => { + time = 60_001; + return true; + }, + 60_000, + () => time, + ); + expect(await member("group", "user")).toBe(false); + }); + it("shares a concurrent refresh and expires it at the original deadline", async () => { + let time = 0; + let finish!: (value: boolean) => void; + const check = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(false); + const member = createGroupMembershipCache(check, 60_000, () => time); + const old = member("group", "user"); + time = 10; + const concurrent = member("group", "user"); + expect(member.cached("group", "user")).toBe(false); + expect(check).toHaveBeenCalledTimes(1); + finish(true); + expect(await old).toBe(true); + expect(await concurrent).toBe(true); + expect(member.cached("group", "user")).toBe(true); + time = 60_000; + expect(member.cached("group", "user")).toBe(false); + expect(await member("group", "user")).toBe(false); + expect(check).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/auth/oidc-admin.ts b/src/auth/oidc-admin.ts index fbe18d2..ca93b8d 100644 --- a/src/auth/oidc-admin.ts +++ b/src/auth/oidc-admin.ts @@ -3,19 +3,24 @@ import { identityEvidence } from "../db/evidence"; import { db } from "../db/index"; import { account } from "../db/schema"; import { env } from "../env"; +import type { IdentityReader } from "./identity-subject"; import { checkEntraGroupMember } from "./membership"; /** - * Who may manage OIDC clients. - * - `pn-entra`: anyone who signed in with a PoliNetwork Entra account (the initial rule). + * Who holds the built-in Master Admin role, and through it every permission. + * - `allowlist`: only explicitly configured local user IDs. * - `entra-group`: only direct members of `PN_ENTRA_OIDC_ADMIN_GROUP_ID`, a stricter group - * than Soci. Set that variable to switch without code changes. + * than Soci. Missing group configuration never grants membership. * `IDP_ADMIN_USER_IDS` remains a break-glass allowlist in both modes. + * + * This is the one decision that is deliberately made outside the database, so a mistake in + * the roles cannot lock the service out of its own administration. Every other + * administrative right is an ordinary permission resolved through RBAC. */ -export type OidcAdminPolicy = "pn-entra" | "entra-group"; +export type OidcAdminPolicy = "allowlist" | "entra-group"; export function oidcAdminPolicy(): OidcAdminPolicy { - return env.PN_ENTRA_OIDC_ADMIN_GROUP_ID ? "entra-group" : "pn-entra"; + return env.PN_ENTRA_OIDC_ADMIN_GROUP_ID ? "entra-group" : "allowlist"; } export type OidcAdminDecisionInput = { @@ -28,7 +33,7 @@ export type OidcAdminDecisionInput = { export function decideOidcAdmin(input: OidcAdminDecisionInput): boolean { if (input.allowlisted) return true; if (!input.pnEntraAccount) return false; - if (!input.groupConfigured) return true; + if (!input.groupConfigured) return false; return input.groupMember; } @@ -39,29 +44,45 @@ type GroupCheck = (groupId: string, objectId: string) => Promise * through the group. Failed checks are never cached and grant nothing. */ export function createGroupMembershipCache(check: GroupCheck, ttlMs: number, now = Date.now) { - const cache = new Map(); - return async (groupId: string, objectId: string): Promise => { + type Entry = { member: boolean; expiresAt: number; pending?: Promise }; + const cache = new Map(); + const cachedMember = (groupId: string, objectId: string) => { + const entry = cache.get(`${groupId} ${objectId}`); + return Boolean(entry && entry.expiresAt > now() && entry.member); + }; + const isMember = async (groupId: string, objectId: string): Promise => { const key = `${groupId} ${objectId}`; const cached = cache.get(key); if (cached && cached.expiresAt > now()) return cached.member; - const member = await check(groupId, objectId); - if (member === null) return false; - if (cache.size >= 1000) { - for (const [entryKey, entry] of cache) if (entry.expiresAt <= now()) cache.delete(entryKey); - } - cache.set(key, { member, expiresAt: now() + ttlMs }); - return member; + if (cached?.pending) return cached.pending; + const startedAt = now(); + if (cache.size >= 1000) cache.delete(cache.keys().next().value!); + const entry: Entry = { member: false, expiresAt: 0 }; + cache.set(key, entry); + entry.pending = (async () => { + const member = await check(groupId, objectId).catch(() => null); + // Measure freshness from request start, never from a delayed response. + if (member === null || now() >= startedAt + ttlMs || cache.get(key) !== entry) return false; + entry.member = member; + entry.expiresAt = startedAt + ttlMs; + return member; + })().finally(() => { + entry.pending = undefined; + }); + return entry.pending; }; + // Transactional authorization must never initiate or wait for remote I/O. + return Object.assign(isMember, { cached: cachedMember }); } -const ADMIN_GROUP_CACHE_MS = 15 * 60_000; +const ADMIN_GROUP_CACHE_MS = 60_000; const adminGroupMember = createGroupMembershipCache(checkEntraGroupMember, ADMIN_GROUP_CACHE_MS); // Entra object IDs of the user's verified PoliNetwork tenant accounts. -async function pnEntraObjectIds(userId: string): Promise { +async function pnEntraObjectIds(userId: string, reader: IdentityReader): Promise { if (!env.PN_ENTRA_TENANT_ID) return []; const issuer = `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID}/v2.0`; - const rows = await db + const rows = await reader .select({ externalId: identityEvidence.externalId }) .from(account) .innerJoin( @@ -69,6 +90,7 @@ async function pnEntraObjectIds(userId: string): Promise { and( eq(account.issuer, identityEvidence.issuer), eq(account.accountId, identityEvidence.subject), + eq(account.providerId, identityEvidence.providerId), ), ) .where( @@ -82,18 +104,20 @@ async function pnEntraObjectIds(userId: string): Promise { return rows.flatMap((row) => (row.externalId ? [row.externalId] : [])); } -export async function canManageOidcClients(userId: string): Promise { +export async function canAdministerIdp( + userId: string, + reader: IdentityReader = db, + refreshMembership = false, +): Promise { if (env.IDP_ADMIN_USER_IDS.includes(userId)) return true; - const objectIds = await pnEntraObjectIds(userId); + const objectIds = await pnEntraObjectIds(userId, reader); const groupId = env.PN_ENTRA_OIDC_ADMIN_GROUP_ID; let groupMember = false; if (groupId) { - for (const objectId of objectIds) { - if (await adminGroupMember(groupId, objectId)) { - groupMember = true; - break; - } - } + const check = refreshMembership ? adminGroupMember : adminGroupMember.cached; + groupMember = ( + await Promise.all(objectIds.map(async (objectId) => check(groupId, objectId))) + ).some(Boolean); } return decideOidcAdmin({ allowlisted: false, diff --git a/src/auth/oidc-registry.ts b/src/auth/oidc-registry.ts index bca86df..9e93f6b 100644 --- a/src/auth/oidc-registry.ts +++ b/src/auth/oidc-registry.ts @@ -1,5 +1,6 @@ import { and, countDistinct, desc, eq } from "drizzle-orm"; import { db } from "../db/index"; +import { withAuthorizedRbacRead, withAuthorizedRbacWrite } from "./rbac-store"; import { oauthClient, oauthConsent } from "../db/schema"; import { grantTypesForScopes, @@ -33,16 +34,19 @@ function toSummary(row: ClientRow, authorizedUsers: number): OidcClientSummary { } /** Clients in the shared PoliNetwork pool, newest first, with how many people authorized each. */ -export async function listOidcClients(clientId?: string): Promise { +async function readOidcClients( + reader: Pick, + clientId?: string, +): Promise { const filter = clientId ? and(eq(oauthClient.referenceId, OIDC_CLIENT_REFERENCE), eq(oauthClient.clientId, clientId)) : eq(oauthClient.referenceId, OIDC_CLIENT_REFERENCE); - const rows = await db + const rows = await reader .select() .from(oauthClient) .where(filter) .orderBy(desc(oauthClient.createdAt)); - const consents = await db + const consents = await reader .select({ clientId: oauthConsent.clientId, users: countDistinct(oauthConsent.userId) }) .from(oauthConsent) .groupBy(oauthConsent.clientId); @@ -50,6 +54,15 @@ export async function listOidcClients(clientId?: string): Promise toSummary(row, usersByClient.get(row.clientId) ?? 0)); } +export async function listOidcClients( + actorId: string, + clientId?: string, +): Promise { + return withAuthorizedRbacRead(actorId, ["idp:applications:read"], (transaction) => + readOidcClients(transaction, clientId), + ); +} + export type OidcClientPatch = { draft?: OidcClientDraft; disabled?: boolean; @@ -58,36 +71,39 @@ export type OidcClientPatch = { /** Applies validated settings to a pooled client. Returns null when the client is not in the pool. */ export async function updateOidcClient( + actorId: string, clientId: string, patch: OidcClientPatch, ): Promise { - const values: Partial = { updatedAt: new Date() }; - if (patch.draft) { - const draft = patch.draft; - values.name = draft.name; - values.uri = draft.uri || null; - values.icon = draft.logo || null; - values.redirectUris = draft.redirectUris; - values.postLogoutRedirectUris = draft.postLogoutRedirectUris.length - ? draft.postLogoutRedirectUris - : null; - values.contacts = draft.contacts.length ? draft.contacts : null; - values.tos = draft.tosUri || null; - values.policy = draft.policyUri || null; - values.scopes = draft.scopes; - values.grantTypes = grantTypesForScopes(draft.scopes); - values.applicationType = draft.applicationType; - } - if (patch.disabled !== undefined) values.disabled = patch.disabled; - if (patch.skipConsent !== undefined) values.skipConsent = patch.skipConsent; - const [updated] = await db - .update(oauthClient) - .set(values) - .where( - and(eq(oauthClient.clientId, clientId), eq(oauthClient.referenceId, OIDC_CLIENT_REFERENCE)), - ) - .returning(); - if (!updated) return null; - const [summary] = await listOidcClients(clientId); - return summary ?? toSummary(updated, 0); + return withAuthorizedRbacWrite(actorId, "idp:applications:write", async (transaction) => { + const values: Partial = { updatedAt: new Date() }; + if (patch.draft) { + const draft = patch.draft; + values.name = draft.name; + values.uri = draft.uri || null; + values.icon = draft.logo || null; + values.redirectUris = draft.redirectUris; + values.postLogoutRedirectUris = draft.postLogoutRedirectUris.length + ? draft.postLogoutRedirectUris + : null; + values.contacts = draft.contacts.length ? draft.contacts : null; + values.tos = draft.tosUri || null; + values.policy = draft.policyUri || null; + values.scopes = draft.scopes; + values.grantTypes = grantTypesForScopes(draft.scopes); + values.applicationType = draft.applicationType; + } + if (patch.disabled !== undefined) values.disabled = patch.disabled; + if (patch.skipConsent !== undefined) values.skipConsent = patch.skipConsent; + const [updated] = await transaction + .update(oauthClient) + .set(values) + .where( + and(eq(oauthClient.clientId, clientId), eq(oauthClient.referenceId, OIDC_CLIENT_REFERENCE)), + ) + .returning(); + if (!updated) return null; + const [summary] = await readOidcClients(transaction, clientId); + return summary ?? toSummary(updated, 0); + }); } diff --git a/src/auth/policy.test.ts b/src/auth/policy.test.ts index b69d921..e276ad2 100644 --- a/src/auth/policy.test.ts +++ b/src/auth/policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { hasAppRole, hasPolimiStudentDomain, - identityClaims, + identityStates, isLinkOnlyProvider, isLoginProvider, oidcIdentityClaims, @@ -10,36 +10,37 @@ import { const now = new Date("2026-01-01T00:00:00Z"); const future = new Date("2026-01-01T01:00:00Z"); -describe("identity permissions", () => { +describe("identity states", () => { it("combines independently verified membership and student states", () => { expect( - identityClaims( + identityStates( [ - { providerId: "pn-entra", state: "socio", validUntil: future, telegramId: null }, - { providerId: "polimi-email", state: "student", validUntil: future, telegramId: null }, + { + providerId: "pn-entra", + states: ["socio", "direttivo"], + validUntil: future, + telegramId: null, + }, + { providerId: "polimi-email", states: ["student"], validUntil: future, telegramId: null }, ], now, ), - ).toEqual({ - states: ["socio", "student"], - telegramId: null, - permissions: ["membership:read", "student:verified"], - }); + ).toEqual({ states: ["direttivo", "socio", "student"], telegramId: null }); }); it("does not turn a Telegram link into moderation access", () => { expect( - identityClaims( - [{ providerId: "telegram", state: "socio", validUntil: future, telegramId: "123" }], + identityStates( + [{ providerId: "telegram", states: ["socio"], validUntil: future, telegramId: "123" }], now, ), - ).toEqual({ states: [], permissions: [], telegramId: "123" }); + ).toEqual({ states: [], telegramId: "123" }); }); it("rejects expired evidence and states from the wrong provider", () => { expect( - identityClaims( + identityStates( [ - { providerId: "pn-entra", state: "socio", validUntil: now, telegramId: null }, - { providerId: "polimi-email", state: "socio", validUntil: future, telegramId: null }, + { providerId: "pn-entra", states: ["socio"], validUntil: now, telegramId: null }, + { providerId: "polimi-email", states: ["socio"], validUntil: future, telegramId: null }, ], now, ).states, @@ -50,24 +51,34 @@ describe("identity permissions", () => { for (const roles of [undefined, "student", ["student-admin"], ["student", 1]]) expect(hasAppRole(roles, "student")).toBe(false); }); - it("removes all permissions without linked evidence", () => { - expect(identityClaims([], now)).toEqual({ states: [], permissions: [], telegramId: null }); + it("removes all states without linked evidence", () => { + expect(identityStates([], now)).toEqual({ states: [], telegramId: null }); }); it("adds string-only OIDC claims without changing the identity claim", () => { const identity = { states: ["socio", "student"], + roles: ["socio", "student"], permissions: ["membership:read", "student:verified"], telegramId: "123456789", }; expect(oidcIdentityClaims("https://auth.polinetwork.org/api/identity", identity)).toEqual({ "https://auth.polinetwork.org/api/identity": identity, polinetwork_states: "socio student", + polinetwork_roles: "socio student", polinetwork_permissions: "membership:read student:verified", polinetwork_telegram_id: "123456789", }); - expect(oidcIdentityClaims("identity", identityClaims([], now))).toEqual({ - identity: { states: [], permissions: [], telegramId: null }, + expect( + oidcIdentityClaims("identity", { + states: [], + roles: [], + permissions: [], + telegramId: null, + }), + ).toEqual({ + identity: { states: [], roles: [], permissions: [], telegramId: null }, polinetwork_states: "", + polinetwork_roles: "", polinetwork_permissions: "", polinetwork_telegram_id: "", }); diff --git a/src/auth/policy.ts b/src/auth/policy.ts index b335fce..a5f5a52 100644 --- a/src/auth/policy.ts +++ b/src/auth/policy.ts @@ -1,36 +1,43 @@ export type IdentityEvidence = { providerId: string; - state: string | null; + states: string[]; validUntil: Date; telegramId: string | null; }; -export function identityClaims(evidence: IdentityEvidence[], now = new Date()) { +/** Which provider is trusted to prove which state. Evidence from anywhere else is ignored. */ +const STATE_PROVIDERS: Record = { + socio: "pn-entra", + direttivo: "pn-entra", + student: "polimi-email", +}; + +/** The states a person's linked accounts currently prove, plus their Telegram identity. */ +export function identityStates(evidence: IdentityEvidence[], now = new Date()) { const states = new Set(); let telegramId: string | null = null; for (const proof of evidence) { if (proof.providerId === "telegram") telegramId = proof.telegramId; - if ( - proof.validUntil > now && - ((proof.providerId === "pn-entra" && proof.state === "socio") || - (proof.providerId === "polimi-email" && proof.state === "student")) - ) - states.add(proof.state); + if (proof.validUntil <= now) continue; + for (const state of proof.states) + if (STATE_PROVIDERS[state] === proof.providerId) states.add(state); } - return { - states: [...states].sort(), - telegramId, - permissions: [ - ...(states.has("socio") ? ["membership:read"] : []), - ...(states.has("student") ? ["student:verified"] : []), - ], - }; + return { states: [...states].sort(), telegramId }; } -export function oidcIdentityClaims(claimName: string, claims: ReturnType) { +/** What `/api/identity`, the ID token, and UserInfo report about a person. */ +export type IdentityClaims = { + states: string[]; + roles: string[]; + permissions: string[]; + telegramId: string | null; +}; + +export function oidcIdentityClaims(claimName: string, claims: IdentityClaims) { return { [claimName]: claims, polinetwork_states: claims.states.join(" "), + polinetwork_roles: claims.roles.join(" "), polinetwork_permissions: claims.permissions.join(" "), polinetwork_telegram_id: claims.telegramId ?? "", }; diff --git a/src/auth/providers.ts b/src/auth/providers.ts index be877fa..0f1bd2f 100644 --- a/src/auth/providers.ts +++ b/src/auth/providers.ts @@ -3,7 +3,7 @@ import { createRemoteJWKSet, jwtVerify } from "jose"; import { identityEvidence } from "../db/evidence"; import { db } from "../db/index"; import { env } from "../env"; -import { checkPnMemberGroup, membershipEvidence } from "./membership"; +import { checkPnGroupStates, membershipEvidence } from "./membership"; type ProviderSettings = { clientId: string; @@ -56,8 +56,8 @@ function makeProvider(id: string, settings: ProviderSettings): GenericOAuthConfi if (!telegram && typeof payload.oid !== "string") throw new Error("Missing Entra object ID for membership verification"); const membership = telegram - ? { state: null, validUntil: new Date(payload.exp * 1000) } - : membershipEvidence(await checkPnMemberGroup(payload.oid as string)); + ? { states: [], validUntil: new Date(payload.exp * 1000) } + : membershipEvidence(await checkPnGroupStates(payload.oid as string)); const proof = { issuer, subject: payload.sub, diff --git a/src/auth/rbac-delegation.ts b/src/auth/rbac-delegation.ts new file mode 100644 index 0000000..dc470dd --- /dev/null +++ b/src/auth/rbac-delegation.ts @@ -0,0 +1,65 @@ +import { + type RbacCatalog, + type ResolvedAccess, + MASTER_ADMIN_ROLE_KEY, + effectiveRolePermissions, + expandPermissionKeys, + resolveAccess, +} from "./rbac"; + +/** Compare both committed and proposed graphs. Called under the mutation lock; a false + * result rolls back the entire transaction, including edge replacements and assignments. */ +export function mayDelegateMutation( + before: RbacCatalog, + after: RbacCatalog, + access: ResolvedAccess, + operation: string, + targetId: string, +): boolean { + if (access.roles.includes(MASTER_ADMIN_ROLE_KEY)) return true; + const held = new Set(access.permissions); + const subset = (permissions: readonly string[]) => permissions.every((key) => held.has(key)); + const same = (a: readonly string[], b: readonly string[]) => + a.length === b.length && a.every((key, index) => key === b[index]); + if (operation.startsWith("permission.")) { + const old = before.permissions.find((entry) => entry.id === targetId); + const next = after.permissions.find((entry) => entry.id === targetId); + if (old?.managed || next?.managed) return false; + if (old && !subset(expandPermissionKeys(before, [old.key]))) return false; + // Defining a new capability does not confer it. Activating it requires a grant by + // someone who already holds it (initially Master Admin). + if ( + next && + !subset(expandPermissionKeys(after, [next.key]).filter((key) => old || key !== next.key)) + ) + return false; + } else { + const old = before.roles.find((entry) => entry.id === targetId); + const next = after.roles.find((entry) => entry.id === targetId); + if (old?.managed || next?.managed) return false; + if (old && !subset(effectiveRolePermissions(before, old.key))) return false; + if (next && !subset(effectiveRolePermissions(after, next.key))) return false; + } + if (!subset(resolveAccess(after, access.roles).permissions)) return false; + // Custom ancestors/implications cannot act as a backdoor for editing managed or + // more privileged roles, including roles the writer does not themselves hold. + for (const old of before.roles) { + // Catalog growth always extends the deployment-conferred wildcard; it does not + // confer new authority on the writer or alter Master Admin membership. + if (old.key === MASTER_ADMIN_ROLE_KEY) continue; + const next = after.roles.find((entry) => entry.id === old.id); + const previous = effectiveRolePermissions(before, old.key); + const proposed = next ? effectiveRolePermissions(after, next.key) : []; + if (!same(previous, proposed) && (old.managed || !subset(previous) || !subset(proposed))) + return false; + } + for (const old of before.permissions.filter((entry) => entry.managed)) { + const next = after.permissions.find((entry) => entry.id === old.id); + if ( + !next || + !same(expandPermissionKeys(before, [old.key]), expandPermissionKeys(after, [next.key])) + ) + return false; + } + return true; +} diff --git a/src/auth/rbac-security.integration.test.mjs b/src/auth/rbac-security.integration.test.mjs new file mode 100644 index 0000000..ce55690 --- /dev/null +++ b/src/auth/rbac-security.integration.test.mjs @@ -0,0 +1,734 @@ +import { createHmac, randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { spawnSync } from "node:child_process"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ graph: vi.fn().mockResolvedValue(false), sendEmail: vi.fn() })); +vi.mock("../env", () => { + const url = new URL( + process.env.RBAC_TEST_DATABASE_URL ?? + "postgresql://postgres:test@localhost:55439/auth_security", + ); + return { + env: { + DB_HOST: url.hostname, + DB_PORT: Number(url.port), + DB_USER: url.username, + DB_PASS: url.password, + DB_NAME: url.pathname.slice(1), + BETTER_AUTH_URL: "http://localhost:35439", + BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters", + STUDENT_VERIFICATION_TTL_DAYS: 365, + IDP_ADMIN_USER_IDS: ["security-root"], + PN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111", + PN_ENTRA_MEMBER_GROUP_ID: "soci", + PN_ENTRA_OIDC_ADMIN_GROUP_ID: "admins", + }, + }; +}); +vi.mock("./membership", () => ({ checkEntraGroupMember: mocks.graph })); +// Only session authentication is substituted; routes, authorization, evidence, SQL and +// transactions are real. The separate identity HTTP suite exercises signed session cookies. +vi.mock("./index", () => ({ + auth: { + api: { + getSession: async ({ headers }) => { + const id = headers.get("x-test-user"); + return id ? { user: { id } } : null; + }, + }, + }, +})); + +vi.mock("./email", () => ({ + studentVerificationEmailConfigured: true, + sendStudentVerificationEmail: mocks.sendEmail, +})); + +import { db } from "../db/index"; +import { confirmStudentVerification, requestStudentVerification } from "./student-verification"; +import { disconnectAccount } from "./accounts"; +import { getIdentity } from "./identity"; +import { listOidcClients, updateOidcClient } from "./oidc-registry"; +import { + assignRole, + deletePermission, + deleteRole, + loadCatalog, + listRoleMembers, + searchUsers, + savePermission, + saveRole, + unassignRole, +} from "./rbac-store"; +import { Route as roleSave } from "../routes/api/rbac/role-save"; +import { Route as permissionSave } from "../routes/api/rbac/permission-save"; +import { Route as members } from "../routes/api/rbac/role-members"; +import { Route as clientUpdate } from "../routes/api/oidc/client-update"; + +const root = "security-root"; +const ordinary = "security-ordinary"; +const writer = "security-writer"; +const draftRole = (key, permissions = [], parents = []) => ({ + key, + name: key, + description: "", + permissions, + parents, +}); +const draftPermission = (key, implies = []) => ({ key, name: key, description: "", implies }); +const unique = (name) => `security-${name}-${randomUUID().slice(0, 8)}`; + +function post(route, actor, body, origin = "http://localhost:35439") { + return route.options.server.handlers.POST({ + request: new Request(`http://localhost:35439${route.id ?? "/api/test"}`, { + method: "POST", + headers: { "x-test-user": actor, Origin: origin, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + }); +} + +describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("RBAC security with PostgreSQL", () => { + const pool = new Pool({ connectionString: process.env.RBAC_TEST_DATABASE_URL }); + beforeAll(async () => { + await pool.query( + `INSERT INTO "user" (id, name, email) SELECT id, id, id || '@identity.invalid' FROM unnest($1::text[]) AS id`, + [[root, ordinary, writer]], + ); + }); + afterAll(async () => { + await pool.query(`DELETE FROM "user" WHERE id LIKE 'security-%'`); + await pool.query(`DELETE FROM role WHERE key LIKE 'security-%'`); + await pool.query(`DELETE FROM permission WHERE key LIKE 'security-%'`); + await pool.query(`DELETE FROM identity_evidence WHERE subject LIKE 'security-%'`); + await pool.query(`DELETE FROM oauth_client WHERE client_id LIKE 'security-%'`); + await pool.end(); + await db.$client.end(); + }); + + async function delegate(permissions) { + const id = unique("delegate"); + await pool.query( + `INSERT INTO "user" (id, name, email) VALUES ($1, $1, $1 || '@identity.invalid')`, + [id], + ); + const role = await saveRole(root, draftRole(unique("delegated"), permissions)); + await assignRole(root, role.id, id); + return { id, role }; + } + + it("denies creating a privileged role, self-assigning it, and editing one's own role", async () => { + const actor = await delegate(["idp:roles:write"]); + const high = await saveRole(root, draftRole(unique("high"), ["idp:applications:write"])); + const key = unique("escalation"); + expect( + ( + await post(roleSave, actor.id, { + action: "create", + actorId: root, + draft: draftRole(key, ["idp:applications:write"]), + }) + ).status, + ).toBe(403); + expect( + (await post(members, actor.id, { action: "assign", roleId: high.id, userId: actor.id })) + .status, + ).toBe(403); + expect( + ( + await post(roleSave, actor.id, { + action: "update", + roleId: actor.role.id, + draft: draftRole(actor.role.key, ["idp:roles:write", "idp:applications:write"]), + }) + ).status, + ).toBe(403); + expect((await getIdentity(actor.id)).permissions).not.toContain("idp:applications:write"); + expect((await pool.query("SELECT id FROM role WHERE key = $1", [key])).rows).toEqual([]); + }); + + it("denies transitive role grants, revocation and deletion above one's authority", async () => { + const actor = await delegate(["idp:roles:write"]); + const high = await saveRole(root, draftRole(unique("high"), ["idp:applications:write"])); + expect( + ( + await post(roleSave, actor.id, { + action: "create", + draft: draftRole(unique("inherited"), [], [high.key]), + }) + ).status, + ).toBe(403); + expect((await post(roleSave, actor.id, { action: "delete", roleId: high.id })).status).toBe( + 403, + ); + expect( + (await post(members, actor.id, { action: "unassign", roleId: high.id, userId: root })).status, + ).toBe(403); + }); + + it("denies escalation through managed and custom permission implications", async () => { + const own = await savePermission(root, draftPermission(unique("own"))); + const actor = await delegate(["idp:permissions:write", own.key]); + const managed = (await loadCatalog(root)).permissions.find( + (entry) => entry.key === "idp:permissions:write", + ); + for (const target of [managed, own]) { + expect( + ( + await post(permissionSave, actor.id, { + action: "update", + permissionId: target.id, + draft: draftPermission(target.key, ["idp:applications:write"]), + }) + ).status, + ).toBe(403); + } + expect( + ( + await post(permissionSave, actor.id, { + action: "update", + permissionId: own.id, + draft: draftPermission(unique("renamed-authority")), + }) + ).status, + ).toBe(403); + expect((await getIdentity(actor.id)).permissions).not.toContain("idp:applications:write"); + }); + + it("denies bootstrapping new authority with both writer permissions", async () => { + const actor = await delegate(["idp:roles:write", "idp:permissions:write"]); + const permission = await savePermission(actor.id, draftPermission(unique("new-capability"))); + expect( + ( + await post(roleSave, actor.id, { + action: "create", + draft: draftRole(unique("new-capability"), [permission.key]), + }) + ).status, + ).toBe(403); + expect((await getIdentity(actor.id)).permissions).not.toContain(permission.key); + expect( + ( + await post(permissionSave, actor.id, { + action: "create", + draft: draftPermission(unique("laundered"), ["idp:applications:write"]), + }) + ).status, + ).toBe(403); + }); + + it("preserves bounded delegation while refusing direct and indirect managed-role edits", async () => { + const own = await savePermission(root, draftPermission(unique("own"))); + const actor = await delegate(["idp:roles:write", own.key]); + const low = await saveRole(actor.id, draftRole(unique("low"), [own.key])); + await assignRole(actor.id, low.id, ordinary); + expect((await getIdentity(ordinary)).permissions).toContain(own.key); + await unassignRole(actor.id, low.id, ordinary); + const managed = (await loadCatalog(root)).roles.find((entry) => entry.key === "socio"); + expect( + ( + await post(roleSave, actor.id, { + action: "update", + roleId: managed.id, + draft: draftRole(managed.key, [own.key]), + }) + ).status, + ).toBe(403); + const parent = await saveRole(root, draftRole(unique("managed-parent"))); + await saveRole(root, draftRole(managed.key, managed.permissions, [parent.key]), managed.id); + try { + expect( + ( + await post(roleSave, actor.id, { + action: "update", + roleId: parent.id, + draft: draftRole(parent.key, [own.key]), + }) + ).status, + ).toBe(403); + } finally { + await saveRole( + root, + draftRole(managed.key, managed.permissions, managed.parents), + managed.id, + ); + } + }); + + async function challenge(actor) { + const email = `${unique("student")}@mail.polimi.it`; + const code = "123456"; + const hash = createHmac("sha256", "test-only-secret-with-at-least-32-characters") + .update(`${actor}:${email}:${code}`) + .digest("hex"); + await pool.query( + `INSERT INTO student_verification_challenge (user_id, email, code_hash, expires_at, last_sent_at) VALUES ($1, $2, $3, now() + interval '10 minutes', now())`, + [actor, email, hash], + ); + return { email, code }; + } + + it("denies a correct student code after five concurrent wrong attempts", async () => { + const actor = await delegate([]); + const input = await challenge(actor.id); + const guesses = await Promise.allSettled( + Array.from({ length: 5 }, () => + confirmStudentVerification(actor.id, { ...input, code: "000000" }), + ), + ); + expect(guesses.every((entry) => entry.status === "rejected")).toBe(true); + await expect(confirmStudentVerification(actor.id, input)).rejects.toMatchObject({ + status: 400, + }); + expect((await getIdentity(actor.id)).roles).not.toContain("student"); + }); + + it("denies concurrent code replay and prevents cross-user code consumption", async () => { + const actor = await delegate([]); + const input = await challenge(actor.id); + await expect(confirmStudentVerification(ordinary, input)).rejects.toMatchObject({ + status: 400, + }); + const results = await Promise.allSettled([ + confirmStudentVerification(actor.id, input), + confirmStudentVerification(actor.id, input), + ]); + expect(results.filter((entry) => entry.status === "fulfilled")).toHaveLength(1); + expect(results.filter((entry) => entry.status === "rejected")).toHaveLength(1); + expect((await getIdentity(ordinary)).roles).not.toContain("student"); + }); + + it("denies concurrent resend attempts inside the cooldown", async () => { + const actor = await delegate([]); + const email = `${unique("resend")}@mail.polimi.it`; + const results = await Promise.allSettled( + Array.from({ length: 3 }, () => requestStudentVerification(actor.id, email)), + ); + expect(results.filter((entry) => entry.status === "fulfilled")).toHaveLength(1); + expect( + results + .filter((entry) => entry.status === "rejected") + .every((entry) => entry.reason.status === 429), + ).toBe(true); + }); + + it("denies cross-user unlink and concurrent removal of the last login account", async () => { + const actor = await delegate([]); + const first = unique("google"); + const second = unique("entra"); + await pool.query( + `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $1, 'google', 'google', $3, now()), ($2, $2, 'pn-entra', 'pn-entra', $3, now())`, + [first, second, actor.id], + ); + await expect(disconnectAccount(ordinary, first)).rejects.toMatchObject({ status: 404 }); + const results = await Promise.allSettled([ + disconnectAccount(actor.id, first), + disconnectAccount(actor.id, second), + ]); + expect(results.filter((entry) => entry.status === "fulfilled")).toHaveLength(1); + expect(results.filter((entry) => entry.status === "rejected")).toHaveLength(1); + expect( + (await pool.query(`SELECT id FROM account WHERE user_id = $1`, [actor.id])).rows, + ).toHaveLength(1); + }); + + it("denies unguarded repository reads and application mutations", async () => { + for (const operation of [ + () => loadCatalog(ordinary), + () => listRoleMembers(ordinary, "unknown"), + () => searchUsers(ordinary, ""), + () => listOidcClients(ordinary), + () => updateOidcClient(ordinary, "unknown", { disabled: true }), + ]) + await expect(operation()).rejects.toMatchObject({ status: 403 }); + }); + + it("marks who already holds a role in a scoped people search, for role readers only", async () => { + const role = await saveRole(root, draftRole(unique("scoped"))); + await assignRole(root, role.id, ordinary); + const [holder] = await searchUsers(root, ordinary, role.id); + expect(holder.holdsRole).toBe(true); + const [other] = await searchUsers(root, root, role.id); + expect(other.holdsRole).toBe(false); + // Membership is role data, so scoping a search by role needs role read as well. + const actor = await delegate(["idp:people:read"]); + await expect(searchUsers(actor.id, ordinary, role.id)).rejects.toMatchObject({ status: 403 }); + expect(await searchUsers(actor.id, ordinary)).toHaveLength(1); + await expect(searchUsers(root, ordinary, "unknown")).rejects.toMatchObject({ status: 404 }); + await unassignRole(root, role.id, ordinary); + }); + + it("does not leak role members through a write-only membership response", async () => { + const managed = (await loadCatalog(root)).permissions.find( + (entry) => entry.key === "idp:roles:write", + ); + await savePermission(root, draftPermission(managed.key), managed.id); + try { + const actor = await delegate([managed.key]); + const role = await saveRole(root, draftRole(unique("private-members"))); + await assignRole(root, role.id, ordinary); + const response = await post(members, actor.id, { + action: "assign", + roleId: role.id, + userId: actor.id, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ changed: true }); + await expect(listRoleMembers(actor.id, role.id)).rejects.toMatchObject({ status: 403 }); + } finally { + await savePermission(root, draftPermission(managed.key, managed.implies), managed.id); + } + }); + + it("acknowledges self-revocation after the actor loses read access", async () => { + const actor = await delegate(["idp:roles:write"]); + const response = await post(members, actor.id, { + action: "unassign", + roleId: actor.role.id, + userId: actor.id, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ changed: true }); + expect((await getIdentity(actor.id)).permissions).not.toContain("idp:roles:read"); + await expect(listRoleMembers(actor.id, actor.role.id)).rejects.toMatchObject({ status: 403 }); + }); + + it("pages every member beyond the former 500-person cutoff without duplicates", async () => { + const role = await saveRole(root, draftRole(unique("paged"))); + const prefix = unique("paged-person"); + await pool.query( + `INSERT INTO "user" (id, name, email) + SELECT $1 || '-' || n, $1, $1 || '-' || n || '@identity.invalid' + FROM generate_series(1, 505) AS n`, + [prefix], + ); + await pool.query( + `INSERT INTO user_role (user_id, role_id) + SELECT id, $1 FROM "user" WHERE id LIKE $2`, + [role.id, `${prefix}-%`], + ); + const ids = []; + let cursor; + do { + const page = await listRoleMembers(root, role.id, cursor); + expect(page.members.length).toBeLessThanOrEqual(100); + ids.push(...page.members.map((member) => member.userId)); + cursor = page.nextCursor; + } while (cursor); + expect(ids).toHaveLength(505); + expect(new Set(ids).size).toBe(505); + const response = await members.options.server.handlers.GET({ + request: new Request(`http://localhost:35439/api/rbac/role-members?role_id=${role.id}`, { + headers: { "x-test-user": root }, + }), + }); + const page = await response.json(); + expect(response.status).toBe(200); + expect(page.members).toHaveLength(100); + expect(page.nextCursor).toBe(page.members[99].userId); + }); + + it("does not lock unrelated writes during Graph I/O or trust an account unlinked meanwhile", async () => { + const actor = await delegate([]); + const subject = unique("slow-graph"); + const issuer = "https://login.microsoftonline.com/11111111-1111-4111-8111-111111111111/v2.0"; + await pool.query( + `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) + VALUES ($1, $1, 'pn-entra', $2, $3, now()), ($1 || '-google', $1, 'google', 'google', $3, now())`, + [subject, issuer, actor.id], + ); + await pool.query( + `INSERT INTO identity_evidence (issuer, subject, provider_id, external_id, states, valid_until) + VALUES ($1, $2, 'pn-entra', $2, ARRAY['socio'], now() + interval '24 hours')`, + [issuer, subject], + ); + const graph = Promise.withResolvers(); + const started = Promise.withResolvers(); + mocks.graph.mockImplementation(async (_group, objectId) => { + if (objectId !== subject) return false; + started.resolve(); + return graph.promise; + }); + const pending = saveRole(actor.id, draftRole(unique("stale-actor"))).catch((error) => error); + try { + await started.promise; + const independent = (async () => { + await saveRole(root, draftRole(unique("unblocked"))); + await disconnectAccount(actor.id, subject); + return "completed"; + })(); + let timer; + try { + expect( + await Promise.race([ + independent, + new Promise((resolve) => { + timer = setTimeout(() => resolve("blocked"), 2000); + }), + ]), + ).toBe("completed"); + } finally { + clearTimeout(timer); + } + } finally { + graph.resolve(true); + mocks.graph.mockResolvedValue(false); + } + expect(await pending).toMatchObject({ status: 403 }); + }); + + it("denies ordinary users at HTTP and direct repository mutation boundaries", async () => { + const role = await saveRole(root, draftRole(unique("target"))); + const permission = await savePermission(root, draftPermission(unique("permission"))); + for (const action of [ + () => saveRole(ordinary, draftRole(unique("bad"))), + () => deleteRole(ordinary, role.id), + () => savePermission(ordinary, draftPermission(unique("bad"))), + () => deletePermission(ordinary, permission.id), + () => assignRole(ordinary, role.id, ordinary), + () => unassignRole(ordinary, role.id, root), + ]) + await expect(action()).rejects.toMatchObject({ status: 403 }); + expect( + (await post(roleSave, ordinary, { action: "create", draft: draftRole(unique("http")) })) + .status, + ).toBe(403); + expect( + (await post(permissionSave, ordinary, { action: "delete", permissionId: permission.id })) + .status, + ).toBe(403); + expect( + (await post(members, ordinary, { action: "assign", roleId: role.id, userId: ordinary })) + .status, + ).toBe(403); + expect( + ( + await post( + roleSave, + root, + { action: "delete", roleId: role.id }, + "https://attacker.invalid", + ) + ).status, + ).toBe(403); + }); + + it("denies missing/deleted subjects even if the identifier is allowlisted", async () => { + await expect(getIdentity("not-a-user")).rejects.toThrow("Unknown identity subject"); + await expect(saveRole("not-a-user", draftRole(unique("bad")))).rejects.toThrow(); + }); + + it("does not revive removed implications when the catalog is reloaded", async () => { + const catalog = await loadCatalog(root); + const managed = catalog.permissions.find((entry) => entry.key === "idp:applications:write"); + await savePermission(root, draftPermission(managed.key), managed.id); + try { + const role = await saveRole(root, draftRole(unique("appwriter"), [managed.key])); + await assignRole(root, role.id, ordinary); + expect((await getIdentity(ordinary)).permissions).not.toContain("idp:applications:read"); + await loadCatalog(root); + expect((await getIdentity(ordinary)).permissions).not.toContain("idp:applications:read"); + const connection = new URL(process.env.RBAC_TEST_DATABASE_URL); + const fresh = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "-e", + 'import { getIdentity } from "./src/auth/identity.ts"; console.log(JSON.stringify((await getIdentity("security-ordinary")).permissions)); process.exit(0);', + ], + { + env: { + PATH: process.env.PATH, + DB_HOST: connection.hostname, + DB_PORT: connection.port, + DB_USER: connection.username, + DB_PASS: connection.password, + DB_NAME: connection.pathname.slice(1), + BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters", + IDP_ADMIN_USER_IDS: root, + }, + encoding: "utf8", + timeout: 10000, + }, + ); + expect(fresh.status, fresh.stderr).toBe(0); + expect(JSON.parse(fresh.stdout)).not.toContain("idp:applications:read"); + await unassignRole(root, role.id, ordinary); + } finally { + // Restore the managed implication even when an assertion above fails, so the rest of + // the suite still runs against the real catalog. + await savePermission( + root, + draftPermission(managed.key, ["idp:applications:read"]), + managed.id, + ); + } + }); + + it("denies access immediately after a committed graph or assignment revocation", async () => { + const permission = await savePermission(root, draftPermission(unique("revocation"))); + const role = await saveRole(root, draftRole(unique("revocation"), [permission.key])); + await assignRole(root, role.id, ordinary); + expect((await getIdentity(ordinary)).permissions).toContain(permission.key); + await saveRole(root, draftRole(role.key), role.id); + expect((await getIdentity(ordinary)).permissions).not.toContain(permission.key); + await saveRole(root, draftRole(role.key, [permission.key]), role.id); + await unassignRole(root, role.id, ordinary); + expect((await getIdentity(ordinary)).permissions).not.toContain(permission.key); + }); + + it("denies stored 24-hour membership after Graph removal or lookup failure", async () => { + const issuer = "https://login.microsoftonline.com/11111111-1111-4111-8111-111111111111/v2.0"; + const subject = unique("stale"); + await pool.query( + `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $1, 'pn-entra', $2, $3, now())`, + [subject, issuer, ordinary], + ); + await pool.query( + `INSERT INTO identity_evidence (issuer, subject, provider_id, external_id, states, valid_until) VALUES ($1, $2, 'pn-entra', $2, ARRAY['socio'], now() + interval '24 hours')`, + [issuer, subject], + ); + mocks.graph.mockResolvedValue(false); + expect((await getIdentity(ordinary)).roles).not.toContain("socio"); + expect((await getIdentity(ordinary)).roles).not.toContain("master-admin"); + mocks.graph.mockResolvedValue(null); + await pool.query( + `UPDATE identity_evidence SET external_id = external_id || '-outage' WHERE subject = $1`, + [subject], + ); + expect((await getIdentity(ordinary)).permissions).not.toContain("membership:read"); + await pool.query(`DELETE FROM account WHERE id = $1`, [subject]); + mocks.graph.mockResolvedValue(false); + }); + + it("denies evidence from another tenant even with a matching provider label", async () => { + const subject = unique("wrong-tenant"); + await pool.query( + `INSERT INTO account (id, account_id, provider_id, issuer, user_id, updated_at) VALUES ($1, $1, 'pn-entra', 'https://foreign.invalid', $2, now())`, + [subject, ordinary], + ); + await pool.query( + `INSERT INTO identity_evidence (issuer, subject, provider_id, external_id, states, valid_until) VALUES ('https://foreign.invalid', $1, 'pn-entra', $1, ARRAY['socio'], now() + interval '24 hours')`, + [subject], + ); + mocks.graph.mockResolvedValue(true); + expect((await getIdentity(ordinary)).roles).not.toContain("socio"); + expect((await getIdentity(ordinary)).roles).not.toContain("master-admin"); + await pool.query(`DELETE FROM account WHERE id = $1`, [subject]); + mocks.graph.mockResolvedValue(false); + }); + + it("rejects managed assignments and root inheritance at the database boundary", async () => { + await expect( + pool.query( + `INSERT INTO user_role (user_id, role_id) VALUES ($1, 'static-role-master-admin')`, + [ordinary], + ), + ).rejects.toThrow("Managed roles cannot be assigned"); + const role = await saveRole(root, draftRole(unique("legacy"))); + await expect( + pool.query( + `INSERT INTO role_parent (role_id, parent_role_id) VALUES ($1, 'static-role-master-admin')`, + [role.id], + ), + ).rejects.toThrow("Master Admin cannot be inherited"); + }); + + it("keeps durable actor and before/after history and refuses erasure", async () => { + const role = await saveRole(root, draftRole(unique("audited"))); + await assignRole(root, role.id, ordinary); + await unassignRole(root, role.id, ordinary); + await deleteRole(root, role.id); + const { rows } = await pool.query( + `SELECT * FROM rbac_audit_event WHERE target_id = $1 ORDER BY "createdAt"`, + [role.id], + ); + expect(rows.map((row) => row.operation)).toEqual([ + "role.save", + "role.assign", + "role.unassign", + "role.delete", + ]); + expect(rows.every((row) => row.actor_id === root)).toBe(true); + expect(rows[2].before.assignments[0].userId).toBe(ordinary); + expect(rows[2].after.assignments).toEqual([]); + await expect( + pool.query(`DELETE FROM rbac_audit_event WHERE target_id = $1`, [role.id]), + ).rejects.toThrow("append-only"); + await expect( + pool.query(`UPDATE rbac_audit_event SET actor_id = 'erased' WHERE target_id = $1`, [role.id]), + ).rejects.toThrow("append-only"); + await expect(pool.query(`TRUNCATE rbac_audit_event`)).rejects.toThrow("append-only"); + }); + + it("rolls back a mutation if its audit event cannot be stored", async () => { + await pool.query( + `CREATE FUNCTION security_fail_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.actor_id = 'security-root' THEN RAISE EXCEPTION 'audit unavailable'; END IF; RETURN NEW; END $$; CREATE TRIGGER security_fail_audit BEFORE INSERT ON rbac_audit_event FOR EACH ROW EXECUTE FUNCTION security_fail_audit()`, + ); + const key = unique("rollback"); + try { + await expect(saveRole(root, draftRole(key))).rejects.toThrow(); + expect((await pool.query(`SELECT * FROM role WHERE key = $1`, [key])).rows).toEqual([]); + } finally { + await pool.query( + `DROP TRIGGER security_fail_audit ON rbac_audit_event; DROP FUNCTION security_fail_audit()`, + ); + } + }); + + it("serializes concurrent opposite graph edges and refuses the cycle", async () => { + const a = await saveRole(root, draftRole(unique("a"))); + const b = await saveRole(root, draftRole(unique("b"))); + const results = await Promise.allSettled([ + saveRole(root, draftRole(a.key, [], [b.key]), a.id), + saveRole(root, draftRole(b.key, [], [a.key]), b.id), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + }); + + it("denies an in-flight writer after a queued revoke commits", async () => { + const authority = await saveRole(root, draftRole(unique("writer"), ["idp:roles:write"])); + const target = await saveRole(root, draftRole(unique("victim"))); + await assignRole(root, authority.id, writer); + const connection = await pool.connect(); + await connection.query("BEGIN"); + await connection.query( + "SELECT pg_advisory_xact_lock(hashtext('polinetwork-auth'), hashtext('rbac-hierarchy'))", + ); + try { + await connection.query("DELETE FROM user_role WHERE user_id = $1", [writer]); + const grant = assignRole(writer, target.id, ordinary); + // The writer cannot enter its authorization/mutation transaction until commit. + await connection.query("COMMIT"); + await expect(grant).rejects.toMatchObject({ status: 403 }); + expect( + ( + await pool.query("SELECT * FROM user_role WHERE user_id = $1 AND role_id = $2", [ + ordinary, + target.id, + ]) + ).rows, + ).toEqual([]); + } finally { + await connection.query("ROLLBACK"); + connection.release(); + } + }); + + it("refuses cross-pool client updates even by an application administrator", async () => { + const client = unique("foreign-client"); + await pool.query( + `INSERT INTO oauth_client (id, client_id, redirect_uris, reference_id) VALUES ($1, $1, ARRAY['https://example.com/callback'], 'another-pool')`, + [client], + ); + expect((await post(clientUpdate, root, { clientId: client, disabled: true })).status).toBe(404); + expect( + (await pool.query("SELECT disabled FROM oauth_client WHERE client_id = $1", [client])).rows[0] + .disabled, + ).toBe(false); + }); +}); diff --git a/src/auth/rbac-store.ts b/src/auth/rbac-store.ts new file mode 100644 index 0000000..4a6933a --- /dev/null +++ b/src/auth/rbac-store.ts @@ -0,0 +1,534 @@ +import { mayDelegateMutation } from "./rbac-delegation"; +import { logAuthorizationDenial } from "./denial-log"; +import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject"; +import type { IdentityClaims } from "./policy"; +import { randomUUID } from "node:crypto"; +import { and, count, eq, gt, ilike, or, sql } from "drizzle-orm"; +import { db } from "../db/index"; +import { authorizationMutationLock } from "../db/security-lock"; +import { + rbacAuditEvent, + permission, + permissionImplication, + role, + roleParent, + rolePermission, + user, + userRole, +} from "../db/schema"; +import { + type ManagedPermissionKey, + type ResolvedAccess, + type PermissionDraft, + type PermissionSummary, + type RbacCatalog, + type RoleDraft, + type RoleMemberPage, + type RoleSummary, + type UserSearchResult, + MASTER_ADMIN_ROLE_KEY, + hasDraftErrors, + catalogForIdpPermissions, + normalizePermissionDraft, + normalizeRoleDraft, + resolveAccess, + validatePermissionDraft, + validateRoleDraft, + withIntrinsicImplications, +} from "./rbac"; + +export class RbacError extends Error { + constructor( + readonly status: number, + message: string, + readonly fields?: Record, + ) { + super(message); + } +} + +/** Anything that can run the catalog queries: the pool, or an open transaction. */ +type CatalogReader = Pick; + +type Transaction = Parameters[0]>[0]; + +// Read one query at a time: `db` may be an open transaction, and a transaction is a single +// PostgreSQL session that cannot run overlapping queries. +async function readCatalog(db: CatalogReader): Promise { + const roles = await db.select().from(role).orderBy(role.key); + const permissions = await db.select().from(permission).orderBy(permission.key); + const rolePermissions = await db + .select({ roleId: rolePermission.roleId, permissionKey: permission.key }) + .from(rolePermission) + .innerJoin(permission, eq(permission.id, rolePermission.permissionId)); + const roleParents = await db + .select({ roleId: roleParent.roleId, parentKey: role.key }) + .from(roleParent) + .innerJoin(role, eq(role.id, roleParent.parentRoleId)); + const implications = await db + .select({ permissionId: permissionImplication.permissionId, impliedKey: permission.key }) + .from(permissionImplication) + .innerJoin(permission, eq(permission.id, permissionImplication.impliedPermissionId)); + const members = await db + .select({ roleId: userRole.roleId, members: count() }) + .from(userRole) + .groupBy(userRole.roleId); + + const collect = (rows: T[], keyOf: (row: T) => string, valueOf: (row: T) => string) => { + const grouped = new Map(); + for (const row of rows) { + const list = grouped.get(keyOf(row)); + if (list) list.push(valueOf(row)); + else grouped.set(keyOf(row), [valueOf(row)]); + } + for (const list of grouped.values()) list.sort(); + return grouped; + }; + + const permissionsByRole = collect( + rolePermissions, + (row) => row.roleId, + (row) => row.permissionKey, + ); + const parentsByRole = collect( + roleParents, + (row) => row.roleId, + (row) => row.parentKey, + ); + const impliedByPermission = collect( + implications, + (row) => row.permissionId, + (row) => row.impliedKey, + ); + const memberCounts = new Map(members.map((row) => [row.roleId, row.members])); + const rolesPerPermission = new Map(); + for (const row of rolePermissions) + rolesPerPermission.set(row.permissionKey, (rolesPerPermission.get(row.permissionKey) ?? 0) + 1); + + return { + roles: roles.map((row): RoleSummary => ({ + id: row.id, + key: row.key, + name: row.name, + description: row.description, + managed: row.managed, + sourceState: row.sourceState, + permissions: permissionsByRole.get(row.id) ?? [], + parents: parentsByRole.get(row.id) ?? [], + memberCount: memberCounts.get(row.id) ?? 0, + createdAt: row.createdAt?.toISOString() ?? null, + updatedAt: row.updatedAt?.toISOString() ?? null, + })), + permissions: permissions.map((row): PermissionSummary => ({ + id: row.id, + key: row.key, + name: row.name, + description: row.description, + managed: row.managed, + implies: withIntrinsicImplications(row.key, impliedByPermission.get(row.id) ?? []), + roleCount: rolesPerPermission.get(row.key) ?? 0, + createdAt: row.createdAt?.toISOString() ?? null, + updatedAt: row.updatedAt?.toISOString() ?? null, + })), + }; +} + +/** Read guards and protected data share one database snapshot. */ +export async function withAuthorizedRbacRead( + actorId: string, + required: readonly ManagedPermissionKey[], + read: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise, +): Promise { + await refreshIdentityMembership(actorId); + return db.transaction( + async (transaction) => { + const subject = await readIdentitySubject(actorId, transaction); + const catalog = await readCatalog(transaction); + const access = resolveAccess(catalog, [ + ...(await assignedRoleKeys(actorId, catalog, transaction)), + ...subject.roleKeys, + ]); + if (!required.some((key) => access.permissions.includes(key))) { + logAuthorizationDenial(actorId, "rbac-store", required); + throw new RbacError(403, "You do not have permission to do that."); + } + return read(transaction, catalog, access); + }, + { isolationLevel: "repeatable read", accessMode: "read only" }, + ); +} + +export async function loadCatalog(actorId: string): Promise { + return withAuthorizedRbacRead( + actorId, + ["idp:roles:read", "idp:permissions:read"], + async (_transaction, catalog, access) => catalogForIdpPermissions(catalog, access.permissions), + ); +} + +/** + * Runs a change to the role or permission graph against the graph as it actually is. + * + * Both hierarchies are validated by walking the whole catalog, so validating against the + * memoized copy would let two administrators writing at the same time each add an edge + * that is fine on its own while the pair closes a cycle. The transaction-scoped advisory + * lock serializes these writes across every replica, and the catalog is then re-read + * inside the transaction so the checks see the other writer's committed work. + */ +export async function withAuthorizedRbacWrite( + actorId: string, + required: Extract, + change: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise, +): Promise { + await refreshIdentityMembership(actorId); + return db.transaction( + async (transaction) => { + await transaction.execute(authorizationMutationLock); + const subject = await readIdentitySubject(actorId, transaction); + const catalog = await readCatalog(transaction); + const access = resolveAccess(catalog, [ + ...(await assignedRoleKeys(actorId, catalog, transaction)), + ...subject.roleKeys, + ]); + if (!access.permissions.includes(required)) { + logAuthorizationDenial(actorId, "rbac-store", [required]); + throw new RbacError(403, "You do not have permission to do that."); + } + return change(transaction, catalog, access); + }, + { isolationLevel: "read committed" }, + ); +} + +async function auditSnapshot( + transaction: Transaction, + catalog: RbacCatalog, + operation: string, + targetId: string, +) { + if (operation.startsWith("permission.")) { + const target = catalog.permissions.find((entry) => entry.id === targetId); + return { + target: target ?? null, + roles: catalog.roles + .filter((entry) => target && entry.permissions.includes(target.key)) + .map((entry) => ({ id: entry.id, permissions: entry.permissions })), + incoming: catalog.permissions + .filter((entry) => target && entry.implies.includes(target.key)) + .map((entry) => ({ id: entry.id, implies: entry.implies })), + }; + } + const target = catalog.roles.find((entry) => entry.id === targetId); + return { + target: target ?? null, + children: catalog.roles + .filter((entry) => target && entry.parents.includes(target.key)) + .map((entry) => ({ id: entry.id, parents: entry.parents })), + assignments: await transaction.select().from(userRole).where(eq(userRole.roleId, targetId)), + }; +} + +async function withRbacWriteLock( + actorId: string, + operation: string, + targetId: string, + change: (transaction: Transaction, catalog: RbacCatalog, access: ResolvedAccess) => Promise, +): Promise { + return withAuthorizedRbacWrite( + actorId, + operation.startsWith("permission.") ? "idp:permissions:write" : "idp:roles:write", + async (transaction, catalog, access) => { + const before = await auditSnapshot(transaction, catalog, operation, targetId); + const result = await change(transaction, catalog, access); + const next = await readCatalog(transaction); + if (!mayDelegateMutation(catalog, next, access, operation, targetId)) { + logAuthorizationDenial(actorId, "rbac-store", ["bounded-delegation"]); + throw new RbacError( + 403, + "This change exceeds your delegated authority. Ask a Master Admin.", + ); + } + const after = await auditSnapshot(transaction, next, operation, targetId); + await transaction + .insert(rbacAuditEvent) + .values({ id: randomUUID(), actorId, operation, targetId, before, after }); + return result; + }, + ); +} + +function requireRole(catalog: RbacCatalog, roleId: string) { + const found = catalog.roles.find((entry) => entry.id === roleId); + if (!found) throw new RbacError(404, "Role not found."); + return found; +} + +function requirePermission(catalog: RbacCatalog, permissionId: string) { + const found = catalog.permissions.find((entry) => entry.id === permissionId); + if (!found) throw new RbacError(404, "Permission not found."); + return found; +} + +function idsForPermissionKeys(catalog: RbacCatalog, keys: string[]) { + return keys.map((key) => { + const found = catalog.permissions.find((entry) => entry.key === key); + if (!found) throw new RbacError(400, `Unknown permission ${key}.`); + return found.id; + }); +} + +function idsForRoleKeys(catalog: RbacCatalog, keys: string[]) { + return keys.map((key) => { + const found = catalog.roles.find((entry) => entry.key === key); + if (!found) throw new RbacError(400, `Unknown role ${key}.`); + return found.id; + }); +} + +function checked(errors: Record) { + if (hasDraftErrors(errors)) + throw new RbacError(400, "Check the highlighted fields.", errors as Record); +} + +export async function savePermission( + actorId: string, + input: PermissionDraft, + permissionId?: string, +): Promise { + const draft = normalizePermissionDraft(input); + const targetId = permissionId ?? randomUUID(); + return withRbacWriteLock(actorId, "permission.save", targetId, async (transaction, catalog) => { + const current = permissionId ? requirePermission(catalog, permissionId) : undefined; + // A managed permission's key is what the identity provider's own checks look for. + if (current?.managed && draft.key !== current.key) + throw new RbacError(400, "The key of a built-in permission cannot be changed.", { + key: "This permission is defined by the identity provider.", + }); + checked(validatePermissionDraft(draft, { catalog, currentKey: current?.key })); + const id = targetId; + const impliedIds = idsForPermissionKeys(catalog, draft.implies); + const values = { + key: draft.key, + name: draft.name, + description: draft.description || null, + updatedAt: new Date(), + }; + if (current) await transaction.update(permission).set(values).where(eq(permission.id, id)); + else await transaction.insert(permission).values({ id, ...values }); + await transaction + .delete(permissionImplication) + .where(eq(permissionImplication.permissionId, id)); + if (impliedIds.length) + await transaction + .insert(permissionImplication) + .values( + impliedIds.map((impliedPermissionId) => ({ permissionId: id, impliedPermissionId })), + ); + return (await readCatalog(transaction)).permissions.find((entry) => entry.id === id)!; + }); +} + +export async function deletePermission(actorId: string, permissionId: string) { + await withRbacWriteLock( + actorId, + "permission.delete", + permissionId, + async (transaction, catalog) => { + const current = requirePermission(catalog, permissionId); + if (current.managed) + throw new RbacError( + 400, + "Permissions defined by the identity provider cannot be deleted. Remove it from the roles that carry it instead.", + ); + await transaction.delete(permission).where(eq(permission.id, permissionId)); + }, + ); +} + +export async function saveRole( + actorId: string, + input: RoleDraft, + roleId?: string, +): Promise { + const draft = normalizeRoleDraft(input); + const targetId = roleId ?? randomUUID(); + return withRbacWriteLock(actorId, "role.save", targetId, async (transaction, catalog) => { + const current = roleId ? requireRole(catalog, roleId) : undefined; + // A managed role's key is what ties it to the evidence that grants it. + if (current?.managed && draft.key !== current.key) + throw new RbacError(400, "The key of a built-in role cannot be changed.", { + key: "This role is defined by the identity provider.", + }); + // Master Admin already holds everything, so a stored grant list would only mislead. + if ( + current?.key === MASTER_ADMIN_ROLE_KEY && + (draft.permissions.length > 0 || draft.parents.length > 0) + ) + throw new RbacError( + 400, + `${current.name} already holds every permission, so it needs no grants of its own.`, + ); + checked(validateRoleDraft(draft, { catalog, currentKey: current?.key })); + const id = targetId; + const permissionIds = idsForPermissionKeys(catalog, draft.permissions); + const parentIds = idsForRoleKeys(catalog, draft.parents); + const values = { + key: draft.key, + name: draft.name, + description: draft.description || null, + updatedAt: new Date(), + }; + if (current) await transaction.update(role).set(values).where(eq(role.id, id)); + else await transaction.insert(role).values({ id, managed: false, ...values }); + await transaction.delete(rolePermission).where(eq(rolePermission.roleId, id)); + if (permissionIds.length) + await transaction + .insert(rolePermission) + .values(permissionIds.map((permissionId) => ({ roleId: id, permissionId }))); + await transaction.delete(roleParent).where(eq(roleParent.roleId, id)); + if (parentIds.length) + await transaction + .insert(roleParent) + .values(parentIds.map((parentRoleId) => ({ roleId: id, parentRoleId }))); + return (await readCatalog(transaction)).roles.find((entry) => entry.id === id)!; + }); +} + +export async function deleteRole(actorId: string, roleId: string) { + await withRbacWriteLock(actorId, "role.delete", roleId, async (transaction, catalog) => { + const current = requireRole(catalog, roleId); + if (current.managed) + throw new RbacError(400, "Roles defined by the identity provider cannot be deleted."); + await transaction.delete(role).where(eq(role.id, roleId)); + }); +} + +export async function listRoleMembers( + actorId: string, + roleId: string, + after?: string, +): Promise { + return withAuthorizedRbacRead(actorId, ["idp:roles:read"], async (transaction, catalog) => { + requireRole(catalog, roleId); + const rows = await transaction + .select({ + userId: user.id, + name: user.name, + email: user.email, + image: user.image, + assignedAt: userRole.assignedAt, + assignedBy: userRole.assignedBy, + }) + .from(userRole) + .innerJoin(user, eq(user.id, userRole.userId)) + .where(and(eq(userRole.roleId, roleId), after ? gt(user.id, after) : undefined)) + .orderBy(user.id) + .limit(101); + return { + members: rows.slice(0, 100).map((row) => ({ + ...row, + assignedAt: row.assignedAt?.toISOString() ?? null, + })), + nextCursor: rows.length > 100 ? rows[99]!.userId : null, + }; + }); +} + +export async function assignRole(actorId: string, roleId: string, userId: string) { + await withRbacWriteLock(actorId, "role.assign", roleId, async (transaction, catalog) => { + const target = requireRole(catalog, roleId); + if (target.managed) throw new RbacError(400, "Managed roles cannot be assigned by hand."); + const [found] = await transaction.select({ id: user.id }).from(user).where(eq(user.id, userId)); + if (!found) throw new RbacError(404, "That person was not found."); + await transaction + .insert(userRole) + .values({ roleId, userId, assignedBy: actorId }) + .onConflictDoNothing(); + }); +} + +export async function unassignRole(actorId: string, roleId: string, userId: string) { + await withRbacWriteLock(actorId, "role.unassign", roleId, async (transaction, catalog) => { + const target = requireRole(catalog, roleId); + if (target.managed) throw new RbacError(400, "Managed roles cannot be revoked by hand."); + await transaction + .delete(userRole) + .where(and(eq(userRole.roleId, roleId), eq(userRole.userId, userId))); + }); +} + +/** + * People an administrator can pick when assigning a role. + * + * Scoping the search to a role marks everyone who already holds it, so the caller can tell + * them apart without paging through the whole membership list. Who holds a role is role + * data, so that answer is only given to someone who may read roles. + */ +export async function searchUsers( + actorId: string, + query: string, + roleId?: string, +): Promise { + return withAuthorizedRbacRead( + actorId, + ["idp:people:read"], + async (transaction, catalog, access) => { + if (roleId) { + if (!access.permissions.includes("idp:roles:read")) { + logAuthorizationDenial(actorId, "rbac-store", ["idp:roles:read"]); + throw new RbacError(403, "You do not have permission to do that."); + } + requireRole(catalog, roleId); + } + const term = `%${query.trim().replace(/[%_\\]/g, (match) => `\\${match}`)}%`; + return transaction + .select({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + holdsRole: roleId + ? sql`exists (select 1 from ${userRole} where ${userRole.userId} = ${user.id} and ${userRole.roleId} = ${roleId})` + : sql`false`, + }) + .from(user) + .where(query.trim() ? or(ilike(user.name, term), ilike(user.email, term)) : undefined) + .orderBy(user.name) + .limit(25); + }, + ); +} + +/** The role keys a person has been given by hand, ignoring anything managed. */ +async function assignedRoleKeys( + userId: string, + catalog: RbacCatalog, + reader: CatalogReader, +): Promise { + const rows = await reader + .select({ roleId: userRole.roleId }) + .from(userRole) + .where(eq(userRole.userId, userId)); + const assignable = new Map( + catalog.roles.filter((entry) => !entry.managed).map((entry) => [entry.id, entry.key]), + ); + return rows.flatMap((row) => { + const key = assignable.get(row.roleId); + return key ? [key] : []; + }); +} + +/** Read identity, assignments and graph from one committed database snapshot. */ +export async function resolveUserIdentity(userId: string): Promise { + await refreshIdentityMembership(userId); + return db.transaction( + async (transaction) => { + const subject = await readIdentitySubject(userId, transaction); + const catalog = await readCatalog(transaction); + const held = [...(await assignedRoleKeys(userId, catalog, transaction)), ...subject.roleKeys]; + const access = resolveAccess(catalog, held); + return { states: subject.states, telegramId: subject.telegramId, ...access }; + }, + { isolationLevel: "repeatable read", accessMode: "read only" }, + ); +} diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts new file mode 100644 index 0000000..e1ebb8b --- /dev/null +++ b/src/auth/rbac.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + type PermissionSummary, + type RbacCatalog, + type RoleSummary, + MANAGED_PERMISSIONS, + MASTER_ADMIN_ROLE_KEY, + STATIC_ROLES, + catalogForIdpPermissions, + effectiveRolePermissions, + expandPermissionKeys, + expandRoleKeys, + isManagedPermissionKey, + isStaticRoleKey, + resolveAccess, + roleParentWouldCycle, + staticRolesForStates, + validatePermissionDraft, + validateRoleDraft, + withIntrinsicImplications, +} from "./rbac"; + +function permission(key: string, implies: string[] = [], managed = false): PermissionSummary { + return { + id: `p-${key}`, + key, + name: key, + description: null, + managed, + implies, + roleCount: 0, + createdAt: null, + updatedAt: null, + }; +} + +function role( + key: string, + permissions: string[] = [], + parents: string[] = [], + managed = false, +): RoleSummary { + return { + id: `r-${key}`, + key, + name: key, + description: null, + managed, + sourceState: managed ? key : null, + permissions, + parents, + memberCount: 0, + createdAt: null, + updatedAt: null, + }; +} + +// socio ← direttivo ← chair, with a write permission that covers reading. +const catalog: RbacCatalog = { + permissions: [ + permission("membership:read"), + permission("membership:write", ["membership:read"]), + permission("bank:sign"), + permission("student:verified"), + ], + roles: [ + role("socio", ["membership:read"], [], true), + role("student", ["student:verified"], [], true), + role("direttivo", ["membership:write"], ["socio"], true), + role("chair", ["bank:sign"], ["direttivo"]), + ], +}; + +describe("role and permission hierarchies", () => { + it("follows role inheritance to the top of the chain", () => { + expect(expandRoleKeys(catalog, ["chair"])).toEqual(["chair", "direttivo", "socio"]); + }); + + it("follows permission grants down the chain", () => { + expect(expandPermissionKeys(catalog, ["membership:write"])).toEqual([ + "membership:read", + "membership:write", + ]); + }); + + it("resolves both hierarchies together", () => { + expect(resolveAccess(catalog, ["chair"])).toEqual({ + roles: ["chair", "direttivo", "socio"], + permissions: ["bank:sign", "membership:read", "membership:write"], + }); + }); + + it("grants nothing to someone holding no roles", () => { + expect(resolveAccess(catalog, [])).toEqual({ roles: [], permissions: [] }); + }); + + it("ignores roles and permissions that no longer exist", () => { + expect(resolveAccess(catalog, ["deleted-role"])).toEqual({ roles: [], permissions: [] }); + expect(expandPermissionKeys(catalog, ["gone"])).toEqual([]); + }); + + it("terminates on stored data that contains a cycle", () => { + const looping: RbacCatalog = { + permissions: [permission("a", ["b"]), permission("b", ["a"])], + roles: [role("x", ["a"], ["y"]), role("y", [], ["x"])], + }; + expect(resolveAccess(looping, ["x"])).toEqual({ roles: ["x", "y"], permissions: ["a", "b"] }); + }); + + it("reports everything a single role confers", () => { + expect(effectiveRolePermissions(catalog, "direttivo")).toEqual([ + "membership:read", + "membership:write", + ]); + }); +}); + +describe("roles the identity provider defines itself", () => { + it("derives them from verified states rather than assignments", () => { + expect(staticRolesForStates(["student", "socio"])).toEqual(["socio", "student"]); + expect(staticRolesForStates([])).toEqual([]); + expect(staticRolesForStates(["unrelated"])).toEqual([]); + }); + + it("covers Master Admin, Socio, Direttivo, and Student", () => { + expect(STATIC_ROLES.map((entry) => entry.key)).toEqual([ + "master-admin", + "socio", + "direttivo", + "student", + ]); + for (const key of ["master-admin", "socio", "direttivo", "student"]) + expect(isStaticRoleKey(key)).toBe(true); + expect(isStaticRoleKey("chair")).toBe(false); + }); + + it("refuses a new role that would shadow one of them", () => { + const errors = validateRoleDraft( + { key: "socio", name: "Socio", description: "", parents: [], permissions: [] }, + { catalog: { roles: [], permissions: [] } }, + ); + expect(errors.key).toBeTruthy(); + }); +}); + +describe("validation", () => { + const context = { catalog }; + + it("rejects keys that are empty, malformed, or already used", () => { + const draft = { name: "Name", description: "", parents: [], permissions: [] }; + expect(validateRoleDraft({ ...draft, key: "" }, context).key).toBeTruthy(); + expect(validateRoleDraft({ ...draft, key: "Has Spaces" }, context).key).toBeTruthy(); + expect(validateRoleDraft({ ...draft, key: "chair" }, context).key).toBeTruthy(); + expect(validateRoleDraft({ ...draft, key: "new-role" }, context).key).toBeUndefined(); + }); + + it("keeps a role's own key available while editing it", () => { + const draft = { key: "chair", name: "Chair", description: "", parents: [], permissions: [] }; + expect(validateRoleDraft(draft, { catalog, currentKey: "chair" }).key).toBeUndefined(); + }); + + it("refuses a parent that would make two roles inherit from each other", () => { + expect(roleParentWouldCycle(catalog, "socio", "chair")).toBe(true); + expect(roleParentWouldCycle(catalog, "chair", "socio")).toBe(false); + const errors = validateRoleDraft( + { key: "socio", name: "Socio", description: "", parents: ["chair"], permissions: [] }, + { catalog, currentKey: "socio" }, + ); + expect(errors.parents).toBeTruthy(); + }); + + it("refuses a permission that would grant itself, directly or in a loop", () => { + expect( + validatePermissionDraft( + { key: "bank:sign", name: "Sign", description: "", implies: ["bank:sign"] }, + { catalog, currentKey: "bank:sign" }, + ).implies, + ).toBeTruthy(); + expect( + validatePermissionDraft( + { key: "membership:read", name: "Read", description: "", implies: ["membership:write"] }, + { catalog, currentKey: "membership:read" }, + ).implies, + ).toBeTruthy(); + }); + + it("rejects references to things that were deleted meanwhile", () => { + expect( + validateRoleDraft( + { key: "new-role", name: "New", description: "", parents: [], permissions: ["gone"] }, + context, + ).permissions, + ).toBeTruthy(); + }); +}); + +describe("Master Admin", () => { + const withMaster: RbacCatalog = { + ...catalog, + roles: [...catalog.roles, role(MASTER_ADMIN_ROLE_KEY, [], [], true)], + }; + + it("holds every permission without listing any of them", () => { + const access = resolveAccess(withMaster, [MASTER_ADMIN_ROLE_KEY]); + expect(access.permissions).toEqual([ + "bank:sign", + "membership:read", + "membership:write", + "student:verified", + ]); + }); + + it("covers a permission created after it was last edited", () => { + const later: RbacCatalog = { + ...withMaster, + permissions: [...withMaster.permissions, permission("invented:later")], + }; + expect(resolveAccess(later, [MASTER_ADMIN_ROLE_KEY]).permissions).toContain("invented:later"); + }); + + it("cannot be named as a parent, which would make its wildcard assignable", () => { + const draft = { name: "Deputy", description: "", permissions: [], parents: ["master-admin"] }; + expect( + validateRoleDraft({ ...draft, key: "deputy" }, { catalog: withMaster }).parents, + ).toBeTruthy(); + expect( + validateRoleDraft({ ...draft, key: "chair" }, { catalog: withMaster, currentKey: "chair" }) + .parents, + ).toBeTruthy(); + // Not even a role the identity provider defines itself: everyone proven a socio would + // otherwise become omnipotent. + expect( + validateRoleDraft({ ...draft, key: "socio" }, { catalog: withMaster, currentKey: "socio" }) + .parents, + ).toBeTruthy(); + }); + + it("denies a wildcard inherited through an edge left by an older version", () => { + const deputy: RbacCatalog = { + ...withMaster, + roles: [...withMaster.roles, role("deputy", [], [MASTER_ADMIN_ROLE_KEY])], + }; + expect(resolveAccess(deputy, ["deputy"]).permissions).toEqual([]); + expect(resolveAccess(deputy, ["deputy"]).roles).not.toContain(MASTER_ADMIN_ROLE_KEY); + }); + + it("leaves ordinary parents alone", () => { + expect( + validateRoleDraft( + { key: "deputy", name: "Deputy", description: "", permissions: [], parents: ["socio"] }, + { catalog: withMaster }, + ).parents, + ).toBeUndefined(); + }); + + it("is not conferred by any identity state", () => { + expect(staticRolesForStates(["socio", "student", "direttivo"])).not.toContain( + MASTER_ADMIN_ROLE_KEY, + ); + }); + + it("gives nothing extra to someone who does not hold it", () => { + expect(resolveAccess(withMaster, ["socio"]).permissions).toEqual(["membership:read"]); + }); +}); + +describe("permissions the identity provider defines itself", () => { + const managedCatalog: RbacCatalog = { + roles: [role("staff", ["idp:roles:write"])], + permissions: MANAGED_PERMISSIONS.map((entry) => + permission(entry.key, [...entry.implies], true), + ), + }; + + it("covers roles, permissions, applications, and people", () => { + expect(MANAGED_PERMISSIONS.map((entry) => entry.key)).toEqual([ + "idp:people:read", + "idp:permissions:read", + "idp:permissions:write", + "idp:roles:read", + "idp:roles:write", + "idp:applications:read", + "idp:applications:write", + ]); + for (const entry of MANAGED_PERMISSIONS) expect(isManagedPermissionKey(entry.key)).toBe(true); + expect(isManagedPermissionKey("membership:read")).toBe(false); + }); + + it("expands managing roles into reading roles, permissions, and people", () => { + expect(resolveAccess(managedCatalog, ["staff"]).permissions).toEqual([ + "idp:people:read", + "idp:permissions:read", + "idp:roles:read", + "idp:roles:write", + ]); + }); + + it("does not let managing roles reach applications", () => { + expect(resolveAccess(managedCatalog, ["staff"]).permissions).not.toContain( + "idp:applications:write", + ); + }); + + it("refuses a new permission that would shadow one of them", () => { + expect( + validatePermissionDraft( + { key: "idp:roles:write", name: "Mine", description: "", implies: [] }, + { catalog: { roles: [], permissions: [] } }, + ).key, + ).toBeTruthy(); + }); + + it("refuses to rekey one of them", () => { + expect( + validatePermissionDraft( + { key: "idp:roles:writeable", name: "Manage roles", description: "", implies: [] }, + { catalog: managedCatalog, currentKey: "idp:roles:write" }, + ).key, + ).toBeTruthy(); + }); + + it("always lets managing roles or permissions see them, whatever is stored", () => { + const stripped: RbacCatalog = { + roles: [role("roles", ["idp:roles:write"]), role("permissions", ["idp:permissions:write"])], + permissions: MANAGED_PERMISSIONS.map((entry) => + permission(entry.key, withIntrinsicImplications(entry.key, []), true), + ), + }; + expect(resolveAccess(stripped, ["roles"]).permissions).toContain("idp:roles:read"); + expect(resolveAccess(stripped, ["permissions"]).permissions).toContain("idp:permissions:read"); + expect(withIntrinsicImplications("idp:applications:write", [])).toEqual([]); + expect(withIntrinsicImplications("constructor", [])).toEqual([]); + }); + + it("refuses to drop the read a write permission always grants", () => { + for (const [key, read] of [ + ["idp:roles:write", "idp:roles:read"], + ["idp:permissions:write", "idp:permissions:read"], + ]) { + const implies = managedCatalog.permissions.find((entry) => entry.key === key)!.implies; + const draft = { key, name: key, description: "", implies }; + const context = { catalog: managedCatalog, currentKey: key }; + expect(validatePermissionDraft(draft, context).implies).toBeUndefined(); + expect( + validatePermissionDraft( + { ...draft, implies: implies.filter((implied) => implied !== read) }, + context, + ).implies, + ).toBeTruthy(); + } + }); +}); + +describe("administration catalog visibility", () => { + it("does not disclose roles to someone who may only read permissions", () => { + const visible = catalogForIdpPermissions(catalog, ["idp:permissions:read"]); + expect(visible.permissions).toBe(catalog.permissions); + expect(visible.roles).toEqual([]); + }); + + it("keeps the permission graph available when explaining roles", () => { + const visible = catalogForIdpPermissions(catalog, ["idp:roles:read"]); + expect(visible).toEqual(catalog); + }); +}); diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts new file mode 100644 index 0000000..1da8c76 --- /dev/null +++ b/src/auth/rbac.ts @@ -0,0 +1,508 @@ +// Shared by the server and the browser: keep this file free of server-only imports. + +/** + * Roles the identity provider always defines itself. They exist without being created, + * cannot be deleted or assigned by hand, and are conferred by the rest of the identity + * provider rather than by an administrator: `state` names the identity state that proves + * the role, and is null for a role conferred some other way. + * + * Administrators can still rename them, describe them, give them permissions, and place + * them in the role hierarchy — only their membership is out of their hands. + */ +export const STATIC_ROLES = [ + { + key: "master-admin", + state: null, + /** Holds every permission that exists, including ones created later. */ + grantsAllPermissions: true, + name: "Master Admin", + description: "Complete control of this identity provider.", + evidence: + "Configured outside the database through IDP_ADMIN_USER_IDS or the PoliNetwork Entra administrators group. Missing group configuration never grants access.", + }, + { + key: "socio", + state: "socio", + grantsAllPermissions: false, + name: "Socio", + description: "Member of PoliNetwork APS.", + evidence: + "Direct membership of the Soci group in PoliNetwork Entra ID. Membership checks expire after one minute.", + }, + { + key: "direttivo", + state: "direttivo", + grantsAllPermissions: false, + name: "Direttivo", + description: "Member of the PoliNetwork APS board.", + evidence: + "Direct membership of the Direttivo group in PoliNetwork Entra ID. Membership checks expire after one minute.", + }, + { + key: "student", + state: "student", + grantsAllPermissions: false, + name: "Student", + description: "Verified Politecnico di Milano student.", + evidence: "A verification code delivered to the person's @mail.polimi.it address.", + }, +] as const; + +export type StaticRole = (typeof STATIC_ROLES)[number]; +export type StaticRoleKey = StaticRole["key"]; + +export const MASTER_ADMIN_ROLE_KEY = "master-admin"; + +export const STATIC_ROLE_KEYS: string[] = STATIC_ROLES.map((entry) => entry.key); + +export function isStaticRoleKey(key: string) { + return STATIC_ROLE_KEYS.includes(key); +} + +export function staticRole(key: string): StaticRole | undefined { + return STATIC_ROLES.find((entry) => entry.key === key); +} + +/** Static roles first, in the order they are defined above, then everything else by key. */ +export function staticRoleOrder(key: string) { + const index = STATIC_ROLES.findIndex((entry) => entry.key === key); + return index === -1 ? STATIC_ROLES.length : index; +} + +/** The managed roles proven by a set of identity states, in catalog order. */ +export function staticRolesForStates(states: readonly string[]): string[] { + return STATIC_ROLES.filter((entry) => entry.state !== null && states.includes(entry.state)).map( + (entry) => entry.key, + ); +} + +/** + * Permissions the identity provider defines itself, covering its own administration. They + * cannot be created, deleted, or rekeyed, because the code checks for these exact keys. + * Which roles carry them is entirely up to the administrator. + */ +export const MANAGED_PERMISSIONS = [ + { + key: "idp:people:read", + name: "Find people", + description: "Search the people registered with this identity provider.", + implies: [], + }, + { + key: "idp:permissions:read", + name: "View permissions", + description: "See the permissions this identity provider defines.", + implies: [], + }, + { + key: "idp:permissions:write", + name: "Manage permissions", + description: "Create, change, and delete permissions, and choose what each one also grants.", + implies: ["idp:permissions:read"], + }, + { + key: "idp:roles:read", + name: "View roles", + description: "See roles, what they grant, and who holds them.", + implies: ["idp:permissions:read"], + }, + { + key: "idp:roles:write", + name: "Manage roles", + description: "Create, change, and delete roles, and give them to people.", + implies: ["idp:roles:read", "idp:people:read"], + }, + { + key: "idp:applications:read", + name: "View applications", + description: "See the applications that sign people in with PoliNetwork Identity.", + implies: [], + }, + { + key: "idp:applications:write", + name: "Manage applications", + description: + "Register applications, edit their redirect URIs and scopes, rotate secrets, and delete them.", + implies: ["idp:applications:read"], + }, +] as const; + +export type ManagedPermission = (typeof MANAGED_PERMISSIONS)[number]; +export type ManagedPermissionKey = ManagedPermission["key"]; + +export const MANAGED_PERMISSION_KEYS: string[] = MANAGED_PERMISSIONS.map((entry) => entry.key); + +export function isManagedPermissionKey(key: string) { + return MANAGED_PERMISSION_KEYS.includes(key); +} + +export function managedPermission(key: string): ManagedPermission | undefined { + return MANAGED_PERMISSIONS.find((entry) => entry.key === key); +} + +/** + * Implications that hold whatever the stored graph says, because changing roles or + * permissions without seeing what already exists makes no sense. The other seeded + * implications of managed permissions stay editable. + */ +const INTRINSIC_IMPLICATIONS = new Map([ + ["idp:permissions:write", ["idp:permissions:read"]], + ["idp:roles:write", ["idp:roles:read"]], +]); + +export function intrinsicImplications(key: string): readonly string[] { + return INTRINSIC_IMPLICATIONS.get(key) ?? []; +} + +/** A permission's stored implications plus the ones it always carries, sorted. */ +export function withIntrinsicImplications(key: string, implies: readonly string[]): string[] { + return [...new Set([...implies, ...intrinsicImplications(key)])].sort(); +} + +export type PermissionSummary = { + id: string; + key: string; + name: string; + description: string | null; + /** Managed permissions are defined by the identity provider and cannot be added or removed. */ + managed: boolean; + /** Keys of the permissions this one also grants. */ + implies: string[]; + roleCount: number; + createdAt: string | null; + updatedAt: string | null; +}; + +export type RoleSummary = { + id: string; + key: string; + name: string; + description: string | null; + /** Managed roles are inferred from identity evidence and never assigned by hand. */ + managed: boolean; + sourceState: string | null; + /** Keys of the permissions granted directly, before inheritance. */ + permissions: string[]; + /** Keys of the roles this role inherits permissions from. */ + parents: string[]; + /** People holding a hand-made assignment. Always 0 for managed roles. */ + memberCount: number; + createdAt: string | null; + updatedAt: string | null; +}; + +export type RbacCatalog = { roles: RoleSummary[]; permissions: PermissionSummary[] }; + +export type RoleMemberPage = { members: RoleMember[]; nextCursor: string | null }; + +export const emptyCatalog: RbacCatalog = { roles: [], permissions: [] }; + +/** + * Removes role metadata when the caller may inspect permissions but not roles. Role readers + * still need the permission graph to understand what each role grants, even when an + * administrator removes the default `idp:roles:read -> idp:permissions:read` implication. + */ +export function catalogForIdpPermissions( + catalog: RbacCatalog, + permissions: readonly string[], +): RbacCatalog { + return permissions.includes("idp:roles:read") ? catalog : { ...catalog, roles: [] }; +} + +type CatalogIndex = { + roles: Map; + permissions: Map; +}; + +function indexCatalog(catalog: RbacCatalog): CatalogIndex { + return { + roles: new Map(catalog.roles.map((entry) => [entry.key, entry])), + permissions: new Map(catalog.permissions.map((entry) => [entry.key, entry])), + }; +} + +/** + * Walks a hierarchy from the given keys, following `edges` breadth-first. The visited set + * makes traversal terminate even if stored data ever contains a cycle, so a bad edge + * cannot hang token issuance. + */ +function closure(start: Iterable, edges: (key: string) => readonly string[]): Set { + const seen = new Set(); + const queue = [...start]; + while (queue.length) { + const key = queue.shift() as string; + if (seen.has(key)) continue; + seen.add(key); + for (const next of edges(key)) if (!seen.has(next)) queue.push(next); + } + return seen; +} + +/** The given roles plus every role they inherit from, transitively. */ +export function expandRoleKeys(catalog: RbacCatalog, roleKeys: Iterable): string[] { + const index = indexCatalog(catalog); + const reachable = closure(roleKeys, (key) => + (index.roles.get(key)?.parents ?? []).filter((parent) => parent !== MASTER_ADMIN_ROLE_KEY), + ); + return [...reachable].filter((key) => index.roles.has(key)).sort(); +} + +/** The given permissions plus every permission they grant, transitively. */ +export function expandPermissionKeys( + catalog: RbacCatalog, + permissionKeys: Iterable, +): string[] { + const index = indexCatalog(catalog); + const reachable = closure(permissionKeys, (key) => index.permissions.get(key)?.implies ?? []); + return [...reachable].filter((key) => index.permissions.has(key)).sort(); +} + +export type ResolvedAccess = { roles: string[]; permissions: string[] }; + +/** Whether any of these roles carries every permission that exists. */ +export function grantsAllPermissions(roleKeys: readonly string[]) { + return STATIC_ROLES.some((entry) => entry.grantsAllPermissions && roleKeys.includes(entry.key)); +} + +/** + * Turns the roles a person holds into the access they actually have: roles expand up the + * role hierarchy, then the permissions those roles carry expand down the permission + * hierarchy. Keys that no longer exist in the catalog are dropped. + * + * Master Admin is a wildcard rather than a stored list, so it keeps covering permissions + * created after it was last edited. Nothing may inherit from it (see `validateRoleDraft`), + * and resolution ignores legacy inheritance edges to it as well. Only a directly conferred + * Master Admin role activates the wildcard. + */ +export function resolveAccess(catalog: RbacCatalog, roleKeys: Iterable): ResolvedAccess { + const index = indexCatalog(catalog); + const roles = expandRoleKeys(catalog, roleKeys); + if (grantsAllPermissions(roles)) + return { roles, permissions: catalog.permissions.map((entry) => entry.key).sort() }; + const granted = roles.flatMap((key) => index.roles.get(key)?.permissions ?? []); + return { roles, permissions: expandPermissionKeys(catalog, granted) }; +} + +/** Everything a single role confers, for explaining inheritance in the admin UI. */ +export function effectiveRolePermissions(catalog: RbacCatalog, roleKey: string): string[] { + return resolveAccess(catalog, [roleKey]).permissions; +} + +/** + * Whether pointing `from` at `to` would close a loop in a hierarchy. Used before writing a + * role parent or a permission implication so stored edges stay acyclic. + */ +export function wouldCycle( + edges: (key: string) => readonly string[], + from: string, + to: string, +): boolean { + return from === to || closure([to], edges).has(from); +} + +export function roleParentWouldCycle(catalog: RbacCatalog, roleKey: string, parentKey: string) { + const index = indexCatalog(catalog); + return wouldCycle((key) => index.roles.get(key)?.parents ?? [], roleKey, parentKey); +} + +export function permissionImplicationWouldCycle( + catalog: RbacCatalog, + permissionKey: string, + impliedKey: string, +) { + const index = indexCatalog(catalog); + return wouldCycle((key) => index.permissions.get(key)?.implies ?? [], permissionKey, impliedKey); +} + +export const MAX_KEY_LENGTH = 64; +export const MAX_NAME_LENGTH = 80; +export const MAX_DESCRIPTION_LENGTH = 300; + +/** Permission keys read like `membership:read`; role keys like `group-moderator`. */ +const PERMISSION_KEY_PATTERN = /^[a-z0-9]([a-z0-9._:-]*[a-z0-9])?$/; +const ROLE_KEY_PATTERN = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/; + +export type PermissionDraft = { + key: string; + name: string; + description: string; + implies: string[]; +}; + +export type RoleDraft = { + key: string; + name: string; + description: string; + parents: string[]; + permissions: string[]; +}; + +export type RbacDraftErrors = { + key?: string; + name?: string; + description?: string; + implies?: string; + parents?: string; + permissions?: string; +}; + +export function emptyPermissionDraft(): PermissionDraft { + return { key: "", name: "", description: "", implies: [] }; +} + +export function emptyRoleDraft(): RoleDraft { + return { key: "", name: "", description: "", parents: [], permissions: [] }; +} + +export function permissionDraftFrom(permission: PermissionSummary): PermissionDraft { + return { + key: permission.key, + name: permission.name, + description: permission.description ?? "", + implies: [...permission.implies], + }; +} + +export function roleDraftFrom(role: RoleSummary): RoleDraft { + return { + key: role.key, + name: role.name, + description: role.description ?? "", + parents: [...role.parents], + permissions: [...role.permissions], + }; +} + +export function normalizePermissionDraft(draft: PermissionDraft): PermissionDraft { + return { + key: draft.key.trim().toLowerCase(), + name: draft.name.trim(), + description: draft.description.trim(), + implies: [...new Set(draft.implies)].sort(), + }; +} + +export function normalizeRoleDraft(draft: RoleDraft): RoleDraft { + return { + key: draft.key.trim().toLowerCase(), + name: draft.name.trim(), + description: draft.description.trim(), + parents: [...new Set(draft.parents)].sort(), + permissions: [...new Set(draft.permissions)].sort(), + }; +} + +function validateShared( + draft: { key: string; name: string; description: string }, + pattern: RegExp, + taken: (key: string) => boolean, + hint: string, +): RbacDraftErrors { + const errors: RbacDraftErrors = {}; + const key = draft.key.trim().toLowerCase(); + if (!key) errors.key = "Give it a key."; + else if (key.length > MAX_KEY_LENGTH) + errors.key = `Keep the key under ${MAX_KEY_LENGTH} characters.`; + else if (!pattern.test(key)) errors.key = hint; + else if (taken(key)) errors.key = "This key is already used."; + + const name = draft.name.trim(); + if (!name) errors.name = "Give it a name."; + else if (name.length > MAX_NAME_LENGTH) + errors.name = `Keep the name under ${MAX_NAME_LENGTH} characters.`; + + if (draft.description.trim().length > MAX_DESCRIPTION_LENGTH) + errors.description = `Keep the description under ${MAX_DESCRIPTION_LENGTH} characters.`; + return errors; +} + +export type DraftContext = { + catalog: RbacCatalog; + /** The key being edited, so an unchanged key is not reported as taken. */ + currentKey?: string; +}; + +export function validatePermissionDraft( + draft: PermissionDraft, + { catalog, currentKey }: DraftContext, +): RbacDraftErrors { + const index = indexCatalog(catalog); + const errors = validateShared( + draft, + PERMISSION_KEY_PATTERN, + (key) => key !== currentKey && index.permissions.has(key), + "Use lowercase letters, digits, and . _ - : for example membership:read.", + ); + const key = draft.key.trim().toLowerCase(); + const current = currentKey ? index.permissions.get(currentKey) : undefined; + if (current?.managed && key !== currentKey) + errors.key = "This permission is defined by the identity provider, so its key is fixed."; + else if (!currentKey && isManagedPermissionKey(key)) + errors.key = "This key belongs to a permission the identity provider defines itself."; + if (draft.implies.some((implied) => !index.permissions.has(implied))) + errors.implies = "One of the granted permissions no longer exists."; + else if (draft.implies.includes(key)) errors.implies = "A permission cannot grant itself."; + else if ( + currentKey && + draft.implies.some((implied) => permissionImplicationWouldCycle(catalog, currentKey, implied)) + ) + errors.implies = "That would make two permissions grant each other."; + else if (intrinsicImplications(key).some((implied) => !draft.implies.includes(implied))) + errors.implies = "Changing this always requires seeing what exists, so it keeps that grant."; + return errors; +} + +export function validateRoleDraft( + draft: RoleDraft, + { catalog, currentKey }: DraftContext, +): RbacDraftErrors { + const index = indexCatalog(catalog); + const errors = validateShared( + draft, + ROLE_KEY_PATTERN, + (key) => key !== currentKey && index.roles.has(key), + "Use lowercase letters, digits, and . _ - for example group-moderator.", + ); + const key = draft.key.trim().toLowerCase(); + if (!currentKey && isStaticRoleKey(key)) + errors.key = "This key belongs to a role the identity provider defines itself."; + if (draft.permissions.some((permission) => !index.permissions.has(permission))) + errors.permissions = "One of the selected permissions no longer exists."; + if (draft.parents.some((parent) => !index.roles.has(parent))) + errors.parents = "One of the selected roles no longer exists."; + // Master Admin's wildcard is conferred by the deployment's configuration and is never + // handed out. Inheriting from it would launder that wildcard into a role an + // administrator can give to anyone, so no role may name it as a parent. + else if (draft.parents.includes(MASTER_ADMIN_ROLE_KEY)) + errors.parents = + "No role can inherit from Master Admin: it holds everything and is granted only by this deployment's configuration."; + else if (draft.parents.includes(key)) errors.parents = "A role cannot inherit from itself."; + else if ( + currentKey && + draft.parents.some((parent) => roleParentWouldCycle(catalog, currentKey, parent)) + ) + errors.parents = "That would make two roles inherit from each other."; + return errors; +} + +export function hasDraftErrors(errors: RbacDraftErrors) { + return Object.keys(errors).length > 0; +} + +export type RoleMember = { + userId: string; + name: string; + email: string; + image: string | null; + assignedAt: string | null; + assignedBy: string | null; +}; + +export type UserSearchResult = { + id: string; + name: string; + email: string; + image: string | null; + /** Whether the person already holds the role the search was scoped to. */ + holdsRole: boolean; +}; diff --git a/src/auth/security-config.test.ts b/src/auth/security-config.test.ts new file mode 100644 index 0000000..c62be0b --- /dev/null +++ b/src/auth/security-config.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; +import { validateSecurityConfiguration as validate } from "../../scripts/security-config.mjs"; + +const validateSecurityConfiguration = (environment: Record) => + validate({ BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters", ...environment }); + +describe("security configuration startup validation", () => { + it.each([undefined, "", " ", ",", "root,", "root,,other", "*", "root user"])( + "refuses absent or malformed bootstrap allowlist %j", + (value) => { + expect(() => validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: value })).toThrow(); + }, + ); + it("denies bootstrap for a configured tenant without an admin group", () => { + expect(() => + validateSecurityConfiguration({ + PN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111", + PN_ENTRA_CLIENT_ID: "app", + PN_ENTRA_CLIENT_SECRET: "secret", + }), + ).toThrow(); + }); + it("refuses malformed groups and incomplete Graph credentials even with a break-glass user", () => { + expect(() => + validateSecurityConfiguration({ + IDP_ADMIN_USER_IDS: "root", + PN_ENTRA_OIDC_ADMIN_GROUP_ID: "bad", + }), + ).toThrow(); + expect(() => + validateSecurityConfiguration({ + IDP_ADMIN_USER_IDS: "root", + PN_ENTRA_OIDC_ADMIN_GROUP_ID: "11111111-1111-4111-8111-111111111111", + }), + ).toThrow(); + }); + it("accepts explicit break-glass configuration", () => { + expect(() => + validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: "root, another-user" }), + ).not.toThrow(); + }); + it.each([ + { BETTER_AUTH_SECRET: "" }, + { PN_ENTRA_MEMBER_GROUP_ID: "bad" }, + { PN_ENTRA_MEMBER_REFRESH_HOURS: "NaN" }, + { STUDENT_VERIFICATION_TTL_DAYS: "-1" }, + { BETTER_AUTH_URL: "javascript:alert(1)" }, + { BETTER_AUTH_URL: "http://auth.polinetwork.org" }, + { BETTER_AUTH_URL: "http://localhost.attacker.example" }, + { BETTER_AUTH_URL: "https://user:secret@auth.polinetwork.org" }, + { GOOGLE_CLIENT_ID: "partial" }, + ])("rejects malformed security settings before startup: %j", (invalid) => { + expect(() => + validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: "root", ...invalid }), + ).toThrow(); + }); + it.each(["http://localhost:3000", "http://127.0.0.1:3000", "http://[::1]:3000"])( + "accepts cleartext HTTP only for local development: %s", + (BETTER_AUTH_URL) => { + expect(() => + validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: "root", BETTER_AUTH_URL }), + ).not.toThrow(); + }, + ); +}); diff --git a/src/auth/student-verification.integration.test.mjs b/src/auth/student-verification.integration.test.mjs new file mode 100644 index 0000000..0e55973 --- /dev/null +++ b/src/auth/student-verification.integration.test.mjs @@ -0,0 +1,170 @@ +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ sendEmail: vi.fn() })); +vi.mock("../env", () => { + const url = new URL( + process.env.RBAC_TEST_DATABASE_URL ?? + "postgresql://postgres:test@localhost:55439/auth_security", + ); + return { + env: { + DB_HOST: url.hostname, + DB_PORT: Number(url.port), + DB_USER: url.username, + DB_PASS: url.password, + DB_NAME: url.pathname.slice(1), + BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters", + STUDENT_VERIFICATION_TTL_DAYS: 365, + }, + }; +}); +vi.mock("./email", () => ({ + studentVerificationEmailConfigured: true, + sendStudentVerificationEmail: mocks.sendEmail, +})); + +import { db } from "../db/index"; +import { confirmStudentVerification, requestStudentVerification } from "./student-verification"; + +describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)( + "Student verification resend limits with PostgreSQL", + () => { + const pool = new Pool({ connectionString: process.env.RBAC_TEST_DATABASE_URL }); + const users = []; + const emails = []; + + beforeEach(() => mocks.sendEmail.mockReset().mockResolvedValue(undefined)); + afterAll(async () => { + await pool.query('DELETE FROM "user" WHERE id = ANY($1::text[])', [users]); + await pool.query( + "DELETE FROM identity_evidence WHERE issuer = 'https://mail.polimi.it' AND subject = ANY($1::text[])", + [emails], + ); + await pool.end(); + await db.$client.end(); + }); + + async function subject() { + const id = `student-cooldown-${randomUUID()}`; + await pool.query('INSERT INTO "user" (id, name, email) VALUES ($1, $1, $2)', [ + id, + `${id}@identity.invalid`, + ]); + users.push(id); + const email = `${id}@mail.polimi.it`; + emails.push(email); + return { id, email }; + } + + async function requested() { + const actor = await subject(); + await requestStudentVerification(actor.id, actor.email); + return { ...actor, code: mocks.sendEmail.mock.lastCall[1] }; + } + + async function allowResend(id) { + await pool.query( + "UPDATE student_verification_challenge SET last_sent_at = now() - interval '61 seconds' WHERE user_id = $1", + [id], + ); + } + + it("keeps the cooldown and original code after an email mismatch", async () => { + const actor = await requested(); + const other = await subject(); + await expect( + confirmStudentVerification(actor.id, { email: other.email, code: actor.code }), + ).rejects.toMatchObject({ status: 400 }); + await expect(requestStudentVerification(actor.id, actor.email)).rejects.toMatchObject({ + status: 429, + }); + await expect(requestStudentVerification(other.id, actor.email)).rejects.toMatchObject({ + status: 429, + }); + expect(mocks.sendEmail).toHaveBeenCalledTimes(1); + await expect(confirmStudentVerification(actor.id, actor)).resolves.toMatchObject({ + email: actor.email, + }); + }); + + it("exhausts five guesses without permitting immediate resend, then accepts a fresh code", async () => { + const actor = await requested(); + const wrong = actor.code === "000000" ? "000001" : "000000"; + for (let attempt = 0; attempt < 5; attempt++) + await expect( + confirmStudentVerification(actor.id, { email: actor.email, code: wrong }), + ).rejects.toMatchObject({ status: 400 }); + await expect(confirmStudentVerification(actor.id, actor)).rejects.toMatchObject({ + status: 400, + }); + await expect(requestStudentVerification(actor.id, actor.email)).rejects.toMatchObject({ + status: 429, + }); + await allowResend(actor.id); + await requestStudentVerification(actor.id, actor.email); + await expect( + confirmStudentVerification(actor.id, { + email: actor.email, + code: mocks.sendEmail.mock.lastCall[1], + }), + ).resolves.toMatchObject({ email: actor.email }); + }); + + it("consumes a valid code once without clearing user or recipient cooldowns", async () => { + const actor = await requested(); + const other = await subject(); + const results = await Promise.allSettled([ + confirmStudentVerification(actor.id, actor), + confirmStudentVerification(actor.id, actor), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + await expect(requestStudentVerification(actor.id, other.email)).rejects.toMatchObject({ + status: 429, + }); + await expect(requestStudentVerification(other.id, actor.email)).rejects.toMatchObject({ + status: 429, + }); + }); + + it("invalidates a failed delivery while preserving its resend cooldown", async () => { + const actor = await subject(); + mocks.sendEmail.mockRejectedValueOnce(new Error("mail unavailable")); + await expect(requestStudentVerification(actor.id, actor.email)).rejects.toMatchObject({ + status: 502, + }); + await expect( + confirmStudentVerification(actor.id, { + email: actor.email, + code: mocks.sendEmail.mock.lastCall[1], + }), + ).rejects.toMatchObject({ status: 400 }); + await expect(requestStudentVerification(actor.id, actor.email)).rejects.toMatchObject({ + status: 429, + }); + expect(mocks.sendEmail).toHaveBeenCalledTimes(1); + }); + + it("does not invalidate a replacement code when an earlier delivery fails late", async () => { + const actor = await subject(); + const delivery = Promise.withResolvers(); + const started = Promise.withResolvers(); + mocks.sendEmail.mockImplementationOnce(() => { + started.resolve(); + return delivery.promise; + }); + const first = requestStudentVerification(actor.id, actor.email).catch((error) => error); + await started.promise; + await allowResend(actor.id); + await requestStudentVerification(actor.id, actor.email); + const code = mocks.sendEmail.mock.lastCall[1]; + delivery.reject(new Error("late delivery failure")); + expect(await first).toMatchObject({ status: 502 }); + await expect( + confirmStudentVerification(actor.id, { email: actor.email, code }), + ).resolves.toMatchObject({ email: actor.email }); + }); + }, +); diff --git a/src/auth/student-verification.ts b/src/auth/student-verification.ts index afac96c..e654f25 100644 --- a/src/auth/student-verification.ts +++ b/src/auth/student-verification.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { account } from "../db/auth-schema"; import { identityEvidence, studentVerificationChallenge } from "../db/evidence"; import { db } from "../db/index"; +import { authorizationMutationLock } from "../db/security-lock"; import { env } from "../env"; import { sendStudentVerificationEmail, studentVerificationEmailConfigured } from "./email"; import { hasPolimiStudentDomain } from "./policy"; @@ -47,51 +48,56 @@ export async function requestStudentVerification(userId: string, input: unknown) throw new StudentVerificationError(503, "Student email verification is not configured."); } const email = parsePolimiStudentEmail(input); - const now = new Date(); - const [recent] = await db - .select({ lastSentAt: studentVerificationChallenge.lastSentAt }) - .from(studentVerificationChallenge) - .where( - or( - eq(studentVerificationChallenge.userId, userId), - eq(studentVerificationChallenge.email, email), - ), - ) - .orderBy(desc(studentVerificationChallenge.lastSentAt)) - .limit(1); - if (recent && now.getTime() - recent.lastSentAt.getTime() < RESEND_DELAY_MS) { - throw new StudentVerificationError(429, "Wait one minute before requesting another code."); - } - const code = randomInt(0, 1_000_000).toString().padStart(6, "0"); const codeHash = hashCode(userId, email, code); - await db.transaction(async (transaction) => { - await transaction - .delete(studentVerificationChallenge) - .where( - or( - eq(studentVerificationChallenge.userId, userId), - eq(studentVerificationChallenge.email, email), - ), - ); - await transaction.insert(studentVerificationChallenge).values({ - userId, - email, - codeHash, - expiresAt: new Date(now.getTime() + CODE_LIFETIME_MS), - lastSentAt: now, - }); - }); + const sentAt = await db.transaction( + async (transaction) => { + await transaction.execute(authorizationMutationLock); + const now = new Date(); + const [recent] = await transaction + .select({ lastSentAt: studentVerificationChallenge.lastSentAt }) + .from(studentVerificationChallenge) + .where( + or( + eq(studentVerificationChallenge.userId, userId), + eq(studentVerificationChallenge.email, email), + ), + ) + .orderBy(desc(studentVerificationChallenge.lastSentAt)) + .limit(1); + if (recent && now.getTime() - recent.lastSentAt.getTime() < RESEND_DELAY_MS) + throw new StudentVerificationError(429, "Wait one minute before requesting another code."); + await transaction + .delete(studentVerificationChallenge) + .where( + or( + eq(studentVerificationChallenge.userId, userId), + eq(studentVerificationChallenge.email, email), + ), + ); + await transaction.insert(studentVerificationChallenge).values({ + userId, + email, + codeHash, + expiresAt: new Date(now.getTime() + CODE_LIFETIME_MS), + lastSentAt: now, + }); + return now; + }, + { isolationLevel: "read committed" }, + ); try { await sendStudentVerificationEmail(email, code); } catch { await db - .delete(studentVerificationChallenge) + .update(studentVerificationChallenge) + .set({ codeHash: "", expiresAt: new Date() }) .where( and( eq(studentVerificationChallenge.userId, userId), eq(studentVerificationChallenge.codeHash, codeHash), + eq(studentVerificationChallenge.lastSentAt, sentAt), ), ); throw new StudentVerificationError(502, "The verification email could not be sent."); @@ -110,90 +116,99 @@ export async function confirmStudentVerification( .safeParse(input.code); if (!code.success) throw new StudentVerificationError(400, "Enter the six-digit code."); - const [challenge] = await db - .select() - .from(studentVerificationChallenge) - .where(eq(studentVerificationChallenge.userId, userId)) - .limit(1); - if (!challenge || challenge.email !== email || challenge.expiresAt <= new Date()) { - if (challenge) { - await db - .delete(studentVerificationChallenge) - .where(eq(studentVerificationChallenge.userId, userId)); - } - throw new StudentVerificationError(400, "The code is invalid or expired."); - } + const result = await db.transaction( + async (transaction) => { + await transaction.execute(authorizationMutationLock); + const [challenge] = await transaction + .select() + .from(studentVerificationChallenge) + .where(eq(studentVerificationChallenge.userId, userId)) + .limit(1); + // Keep the send timestamp after invalidation or consumption. Removing this row + // would let a failed confirmation reset both the user and email resend limits. + if ( + !challenge || + challenge.email !== email || + challenge.expiresAt <= new Date() || + challenge.attempts >= MAX_ATTEMPTS + ) { + return new StudentVerificationError(400, "The code is invalid or expired."); + } - if (!codeMatches(challenge.codeHash, hashCode(userId, email, code.data))) { - if (challenge.attempts + 1 >= MAX_ATTEMPTS) { - await db - .delete(studentVerificationChallenge) - .where(eq(studentVerificationChallenge.userId, userId)); - } else { - await db - .update(studentVerificationChallenge) - .set({ attempts: challenge.attempts + 1 }) - .where(eq(studentVerificationChallenge.userId, userId)); - } - throw new StudentVerificationError(400, "The code is invalid or expired."); - } + if (!codeMatches(challenge.codeHash, hashCode(userId, email, code.data))) { + await transaction + .update(studentVerificationChallenge) + .set({ + attempts: challenge.attempts + 1, + ...(challenge.attempts + 1 >= MAX_ATTEMPTS + ? { codeHash: "", expiresAt: new Date() } + : {}), + }) + .where(eq(studentVerificationChallenge.userId, userId)); + return new StudentVerificationError(400, "The code is invalid or expired."); + } - const now = new Date(); - const validUntil = new Date( - now.getTime() + env.STUDENT_VERIFICATION_TTL_DAYS * 24 * 60 * 60 * 1_000, - ); - await db.transaction(async (transaction) => { - const [currentStudentAccount] = await transaction - .select({ accountId: account.accountId }) - .from(account) - .where(and(eq(account.userId, userId), eq(account.providerId, "polimi-email"))) - .limit(1); - if (currentStudentAccount && currentStudentAccount.accountId !== email) { - throw new StudentVerificationError( - 409, - "Unlink your current Polimi email before linking another one.", - ); - } - const [existingAccount] = await transaction - .select({ id: account.id, userId: account.userId }) - .from(account) - .where(and(eq(account.issuer, POLIMI_EMAIL_ISSUER), eq(account.accountId, email))) - .limit(1); - if (existingAccount && existingAccount.userId !== userId) { - throw new StudentVerificationError( - 409, - "This Polimi email is already connected to another account.", + const now = new Date(); + const validUntil = new Date( + now.getTime() + env.STUDENT_VERIFICATION_TTL_DAYS * 24 * 60 * 60 * 1_000, ); - } - if (!existingAccount) { - await transaction.insert(account).values({ - id: randomUUID(), - accountId: email, - providerId: "polimi-email", + const [currentStudentAccount] = await transaction + .select({ accountId: account.accountId }) + .from(account) + .where(and(eq(account.userId, userId), eq(account.providerId, "polimi-email"))) + .limit(1); + if (currentStudentAccount && currentStudentAccount.accountId !== email) { + throw new StudentVerificationError( + 409, + "Unlink your current Polimi email before linking another one.", + ); + } + const [existingAccount] = await transaction + .select({ id: account.id, userId: account.userId }) + .from(account) + .where(and(eq(account.issuer, POLIMI_EMAIL_ISSUER), eq(account.accountId, email))) + .limit(1); + if (existingAccount && existingAccount.userId !== userId) { + throw new StudentVerificationError( + 409, + "This Polimi email is already connected to another account.", + ); + } + if (!existingAccount) { + await transaction.insert(account).values({ + id: randomUUID(), + accountId: email, + providerId: "polimi-email", + issuer: POLIMI_EMAIL_ISSUER, + userId, + createdAt: now, + updatedAt: now, + }); + } + const proof = { issuer: POLIMI_EMAIL_ISSUER, - userId, - createdAt: now, - updatedAt: now, - }); - } - const proof = { - issuer: POLIMI_EMAIL_ISSUER, - subject: email, - providerId: "polimi-email", - state: "student", - validUntil, - telegramId: null, - }; - await transaction - .insert(identityEvidence) - .values(proof) - .onConflictDoUpdate({ - target: [identityEvidence.issuer, identityEvidence.subject], - set: proof, - }); - await transaction - .delete(studentVerificationChallenge) - .where(eq(studentVerificationChallenge.userId, userId)); - }); - return { email, validUntil }; + subject: email, + providerId: "polimi-email", + states: ["student"], + validUntil, + telegramId: null, + }; + await transaction + .insert(identityEvidence) + .values(proof) + .onConflictDoUpdate({ + target: [identityEvidence.issuer, identityEvidence.subject], + set: proof, + }); + await transaction + .update(studentVerificationChallenge) + .set({ codeHash: "", expiresAt: now }) + .where(eq(studentVerificationChallenge.userId, userId)); + return { email, validUntil }; + }, + { isolationLevel: "read committed" }, + ); + // Failed attempts must commit their counter/invalidation before returning a denial. + if (result instanceof StudentVerificationError) throw result; + return result; } diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx index 916d902..7dba047 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -1,21 +1,34 @@ import { Link } from "@tanstack/react-router"; import { cn } from "cn"; import { authClient } from "@/auth/client"; -import { type OidcAccessState, useOidcAccess } from "@/components/oidc/use-oidc-access"; +import { type IdpAccess, useIdpAccess } from "@/components/idp-access"; +import { firstAccessTab } from "@/components/rbac/access-tabs"; import { ThemeSwitch } from "@/components/theme-switch"; import { UserAvatar } from "@/components/user-avatar"; -type Section = "account" | "applications"; +type Section = "account" | "applications" | "access"; -export function AppHeader({ active, access }: { active: Section; access?: OidcAccessState }) { +export function AppHeader({ active, access }: { active: Section; access?: IdpAccess }) { const { data: session } = authClient.useSession(); - const ownAccess = useOidcAccess(!!session && !access); - const status = (access ?? ownAccess).status; - const links: { to: "/" | "/applications"; label: string; section: Section }[] = [ - { to: "/", label: "Account", section: "account" }, - ]; - if (status === "allowed") - links.push({ to: "/applications", label: "Applications", section: "applications" }); + const ownAccess = useIdpAccess(!!session && !access); + const { can } = access ?? ownAccess; + const links: { + to: "/" | "/applications" | "/applications/new" | "/access/roles" | "/access/permissions"; + label: string; + section: Section; + }[] = [{ to: "/", label: "Account", section: "account" }]; + const canReadApplications = can("idp:applications:read"); + // Someone who may register applications without seeing the existing ones goes straight + // to the form, rather than to a list they would be refused. + if (canReadApplications || can("idp:applications:write")) + links.push({ + to: canReadApplications ? "/applications" : "/applications/new", + label: "Applications", + section: "applications", + }); + // Straight to the tab they can actually read, rather than a page that would refuse them. + const accessTab = firstAccessTab(can); + if (accessTab) links.push({ to: accessTab.to, label: "Access", section: "access" }); const nav = (className: string) => session && links.length > 1 ? (