diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index 7c6824b95..3e9e4b1d5 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -10,19 +10,8 @@ components: type: apiKey in: header name: X-Admin-Token - # Generated code uses security schemas in the alphabetical order. - # In order to check first the token, and then the team (so we can already use the user), - # there is a 1 and 2 present in the names of the security schemas. - Supabase1TokenAuth: - type: apiKey - in: header - name: X-Supabase-Token - Supabase2TeamAuth: - type: apiKey - in: header - name: X-Supabase-Team # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name - # so Bearer is validated before Team (same reason as Supabase1/2 above). + # so Bearer is validated before Team. AuthProviderBearerAuth: type: http scheme: bearer @@ -132,42 +121,6 @@ components: minimum: 1 maximum: 100 default: 50 - templates_cursor: - name: cursor - in: query - required: false - description: Cursor returned by the previous list response in `{sort}|{value}|{templateID}` format. Rejected if its sort does not match the request. - schema: - type: string - templates_public: - name: public - in: query - required: false - description: Filter templates by visibility (true = public, false = internal). - schema: - type: boolean - templates_search: - name: search - in: query - required: false - description: Case-insensitive substring match on template names, aliases, and template id. - schema: - type: string - templates_sort: - name: sort - in: query - required: false - description: Sort column and direction. - schema: - type: string - enum: - [ - created_at_asc, - created_at_desc, - updated_at_asc, - updated_at_desc, - ] - default: updated_at_desc templateID: name: templateID in: path @@ -213,6 +166,42 @@ components: minimum: 1 maximum: 100 default: 50 + templates_cursor: + name: cursor + in: query + required: false + description: Cursor returned by the previous list response in `{sort}|{value}|{templateID}` format. Rejected if its sort does not match the request. + schema: + type: string + templates_public: + name: public + in: query + required: false + description: Filter templates by visibility (true = public, false = internal). + schema: + type: boolean + templates_search: + name: search + in: query + required: false + description: Case-insensitive substring match on template names, aliases, and template id. + schema: + type: string + templates_sort: + name: sort + in: query + required: false + description: Sort column and direction. + schema: + type: string + enum: + [ + created_at_asc, + created_at_desc, + updated_at_asc, + updated_at_desc, + ] + default: created_at_desc tag_assignments_cursor: name: cursor in: query @@ -282,6 +271,12 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "500": description: Server error content: @@ -1170,8 +1165,6 @@ paths: summary: List team builds tags: [builds] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1200,8 +1193,6 @@ paths: summary: Get build statuses tags: [builds] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1228,8 +1219,6 @@ paths: summary: Get build details tags: [builds] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1255,8 +1244,6 @@ paths: summary: Get sandbox record tags: [sandboxes] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1283,7 +1270,6 @@ paths: description: Returns all teams the authenticated user belongs to, with limits and default flag. tags: [teams] security: - - Supabase1TokenAuth: [] - AuthProviderBearerAuth: [] responses: "200": @@ -1300,7 +1286,6 @@ paths: summary: Create team tags: [teams] security: - - Supabase1TokenAuth: [] - AuthProviderBearerAuth: [] requestBody: required: true @@ -1322,26 +1307,6 @@ paths: "500": $ref: "#/components/responses/500" - /admin/users/{userId}/bootstrap: - post: - summary: Bootstrap user - tags: [teams] - security: - - AdminApiKeyAuth: [] - parameters: - - $ref: "#/components/parameters/userId" - responses: - "200": - description: Successfully bootstrapped user. - content: - application/json: - schema: - $ref: "#/components/schemas/TeamResolveResponse" - "401": - $ref: "#/components/responses/401" - "500": - $ref: "#/components/responses/500" - /admin/users/bootstrap: post: summary: Bootstrap auth provider user @@ -1471,13 +1436,35 @@ paths: "500": $ref: "#/components/responses/500" + /admin/users/{userId}: + delete: + summary: Delete user + description: Deletes a user by removing the identity provider record, user_identities mapping, and public.users row. + tags: [admin] + security: + - AdminApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/userId" + responses: + "204": + description: Successfully deleted user. + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "409": + $ref: "#/components/responses/409" + "500": + $ref: "#/components/responses/500" + /teams/resolve: get: summary: Resolve team identity description: Resolves a team slug to the team's identity, validating the user is a member. tags: [teams] security: - - Supabase1TokenAuth: [] - AuthProviderBearerAuth: [] parameters: - $ref: "#/components/parameters/teamSlug" @@ -1502,8 +1489,6 @@ paths: summary: Update team tags: [teams] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1535,8 +1520,6 @@ paths: summary: List team members tags: [teams] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1558,8 +1541,6 @@ paths: summary: Add team member tags: [teams] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1589,8 +1570,6 @@ paths: summary: Remove team member tags: [teams] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1614,8 +1593,6 @@ paths: description: Returns a paginated list of the team's templates (and default templates inline, unless the team is on a dedicated cluster), with filtering, search, and column sorting via keyset cursor pagination. tags: [templates] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1646,7 +1623,6 @@ paths: description: Returns the list of default templates with their latest build info and aliases. tags: [templates] security: - - Supabase1TokenAuth: [] - AuthProviderBearerAuth: [] responses: "200": @@ -1666,8 +1642,6 @@ paths: description: Returns a single template owned by the current team. Dashboard-shaped read, indexed by template ID. tags: [templates] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1694,8 +1668,6 @@ paths: description: Returns ready template tag assignment groups with bounded per-tag history, paginated over tags with keyset cursor. tags: [templates] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1729,8 +1701,6 @@ paths: description: Returns the total number of distinct ready tags for the template. tags: [templates] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1757,8 +1727,6 @@ paths: description: Checks whether a template tag has at least one ready assignment. tags: [templates] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -1788,8 +1756,6 @@ paths: description: Returns ready tag assignment events for a single tag, ordered newest first, with keyset cursor pagination. tags: [templates] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: diff --git a/spec/openapi.infra.yaml b/spec/openapi.infra.yaml index bc0c17228..b0096d0c1 100644 --- a/spec/openapi.infra.yaml +++ b/spec/openapi.infra.yaml @@ -16,19 +16,8 @@ components: type: http scheme: bearer bearerFormat: access_token - # Generated code uses security schemas in the alphabetical order. - # In order to check first the token, and then the team (so we can already use the user), - # there is a 1 and 2 present in the names of the security schemas. - Supabase1TokenAuth: - type: apiKey - in: header - name: X-Supabase-Token - Supabase2TeamAuth: - type: apiKey - in: header - name: X-Supabase-Team # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name - # so Bearer is validated before Team (same reason as Supabase1/2 above). + # so Bearer is validated before Team. AuthProviderBearerAuth: type: http scheme: bearer @@ -1952,7 +1941,6 @@ paths: tags: [auth] security: - AccessTokenAuth: [] - - Supabase1TokenAuth: [] - AuthProviderBearerAuth: [] responses: "200": @@ -1974,8 +1962,6 @@ paths: tags: [auth] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2020,8 +2006,6 @@ paths: tags: [auth] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2071,8 +2055,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2104,8 +2086,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2136,8 +2116,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2183,8 +2161,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2222,8 +2198,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2265,8 +2239,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2325,8 +2297,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2352,8 +2322,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2376,8 +2344,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2424,8 +2390,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2451,8 +2415,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2487,8 +2449,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2528,8 +2488,6 @@ paths: description: Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. Calling this method multiple times overwrites the TTL, each time using the current timestamp as the starting point to measure the timeout duration. security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2557,8 +2515,6 @@ paths: description: Update the network configuration for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting field clears it. security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2589,8 +2545,6 @@ paths: description: Refresh the sandbox extending its time to live security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2617,8 +2571,6 @@ paths: tags: [sandboxes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2653,8 +2605,6 @@ paths: tags: [snapshots] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2688,8 +2638,6 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2724,8 +2672,6 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2758,8 +2704,6 @@ paths: security: - AccessTokenAuth: [] - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2796,8 +2740,6 @@ paths: security: - ApiKeyAuth: [] - AccessTokenAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2828,8 +2770,6 @@ paths: tags: [templates] security: - AccessTokenAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] requestBody: @@ -2859,8 +2799,6 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2886,8 +2824,6 @@ paths: tags: [templates] security: - AccessTokenAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -2916,8 +2852,6 @@ paths: security: - ApiKeyAuth: [] - AccessTokenAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2938,8 +2872,6 @@ paths: security: - ApiKeyAuth: [] - AccessTokenAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -2969,8 +2901,6 @@ paths: tags: [templates] security: - AccessTokenAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] parameters: @@ -2990,8 +2920,6 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3020,8 +2948,6 @@ paths: security: - ApiKeyAuth: [] - AccessTokenAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3055,8 +2981,6 @@ paths: security: - AccessTokenAuth: [] - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3106,8 +3030,6 @@ paths: security: - AccessTokenAuth: [] - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3164,8 +3086,6 @@ paths: tags: [tags] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3196,8 +3116,6 @@ paths: tags: [tags] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3226,8 +3144,6 @@ paths: tags: [tags] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3258,8 +3174,6 @@ paths: tags: [templates] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3495,7 +3409,6 @@ paths: description: Create a new access token tags: [access-tokens] security: - - Supabase1TokenAuth: [] - AuthProviderBearerAuth: [] requestBody: required: true @@ -3520,7 +3433,6 @@ paths: description: Delete an access token tags: [access-tokens] security: - - Supabase1TokenAuth: [] - AuthProviderBearerAuth: [] parameters: - $ref: "#/components/parameters/accessTokenID" @@ -3539,8 +3451,6 @@ paths: description: List all team API keys tags: [api-keys] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3562,8 +3472,6 @@ paths: description: Create a new team API key tags: [api-keys] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] requestBody: @@ -3589,8 +3497,6 @@ paths: description: Update a team API key tags: [api-keys] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3616,8 +3522,6 @@ paths: description: Delete a team API key tags: [api-keys] security: - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3640,8 +3544,6 @@ paths: tags: [volumes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3665,8 +3567,6 @@ paths: tags: [volumes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3697,8 +3597,6 @@ paths: tags: [volumes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] @@ -3724,8 +3622,6 @@ paths: tags: [volumes] security: - ApiKeyAuth: [] - - Supabase1TokenAuth: [] - Supabase2TeamAuth: [] - AuthProviderBearerAuth: [] AuthProviderTeamAuth: [] - AdminApiKeyAuth: [] diff --git a/src/core/modules/users/admin-repository.server.ts b/src/core/modules/users/admin-repository.server.ts index f9e12ccba..c22c7088c 100644 --- a/src/core/modules/users/admin-repository.server.ts +++ b/src/core/modules/users/admin-repository.server.ts @@ -3,9 +3,13 @@ import 'server-only' import { ADMIN_AUTH_HEADERS } from '@/configs/api' import type { ResolvedTeam } from '@/core/modules/teams/models' import { api } from '@/core/shared/clients/api' +import type { components as DashboardApiComponents } from '@/core/shared/contracts/dashboard-api.types' import { repoErrorFromHttp } from '@/core/shared/errors' import { err, ok, type RepoResult } from '@/core/shared/result' +export type AdminAuthProviderUserBootstrapRequest = + DashboardApiComponents['schemas']['AdminAuthProviderUserBootstrapRequest'] + type AdminUsersRepositoryDeps = { apiClient: typeof api adminHeaders: typeof ADMIN_AUTH_HEADERS @@ -13,7 +17,9 @@ type AdminUsersRepositoryDeps = { } export interface AdminUsersRepository { - bootstrapUser(userId: string): Promise> + bootstrapAuthProviderUser( + body: AdminAuthProviderUserBootstrapRequest + ): Promise> } export function createAdminUsersRepository( @@ -24,7 +30,7 @@ export function createAdminUsersRepository( } ): AdminUsersRepository { return { - async bootstrapUser(userId) { + async bootstrapAuthProviderUser(body) { if (!deps.adminToken) { return err( repoErrorFromHttp( @@ -36,13 +42,9 @@ export function createAdminUsersRepository( } const { data, error, response } = await deps.apiClient.POST( - '/admin/users/{userId}/bootstrap', + '/admin/users/bootstrap', { - params: { - path: { - userId, - }, - }, + body, headers: deps.adminHeaders(deps.adminToken), } ) diff --git a/src/core/server/auth/ory/dashboard-bootstrap.ts b/src/core/server/auth/ory/dashboard-bootstrap.ts index 0f6f5cddc..2b96e5e03 100644 --- a/src/core/server/auth/ory/dashboard-bootstrap.ts +++ b/src/core/server/auth/ory/dashboard-bootstrap.ts @@ -1,10 +1,8 @@ import 'server-only' -import { ADMIN_AUTH_HEADERS } from '@/configs/api' -import { api } from '@/core/shared/clients/api' +import { createAdminUsersRepository } from '@/core/modules/users/admin-repository.server' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import type { components as DashboardApiComponents } from '@/core/shared/contracts/dashboard-api.types' -import { repoErrorFromHttp } from '@/core/shared/errors' import { decodeJwtClaims, readStringClaim, tokenFormat } from './jwt-claims' import { readOrySignupMetadataCookie } from './signup-metadata' @@ -33,19 +31,44 @@ type OryTokenClaims = { export async function ensureOryUserBootstrapped( input: BootstrapOryUserInput ): Promise { - const claims = readBootstrapClaims(input) - if (!claims) return false + const body = await createOryUserBootstrapRequest(input) + if (!body) return false - return bootstrapOryUserWithClaims(claims, input.provider) + return bootstrapOryUserWithRequest(body, input.provider) } export async function bootstrapOryUser( input: BootstrapOryUserInput ): Promise { + const body = await createOryUserBootstrapRequest(input) + if (!body) return false + + return bootstrapOryUserWithRequest(body, input.provider) +} + +export async function createOryUserBootstrapRequest( + input: BootstrapOryUserInput +): Promise< + | DashboardApiComponents['schemas']['AdminAuthProviderUserBootstrapRequest'] + | null +> { const claims = readBootstrapClaims(input) - if (!claims) return false + if (!claims) return null + + const signupMetadata = await readOrySignupMetadataCookie() - return bootstrapOryUserWithClaims(claims, input.provider) + return { + oidc_issuer: claims.oidcIssuer, + oidc_user_id: claims.oidcUserId, + oidc_user_email: claims.oidcUserEmail, + oidc_user_name: claims.oidcUserName, + ...(signupMetadata?.signup_ip + ? { signup_ip: signupMetadata.signup_ip } + : {}), + ...(signupMetadata?.signup_user_agent + ? { signup_user_agent: signupMetadata.signup_user_agent } + : {}), + } satisfies DashboardApiComponents['schemas']['AdminAuthProviderUserBootstrapRequest'] } function readBootstrapClaims( @@ -94,61 +117,27 @@ function readBootstrapClaims( } } -async function bootstrapOryUserWithClaims( - claims: OryBootstrapClaims, +async function bootstrapOryUserWithRequest( + body: DashboardApiComponents['schemas']['AdminAuthProviderUserBootstrapRequest'], provider?: string ): Promise { try { - const adminToken = process.env.DASHBOARD_API_ADMIN_TOKEN - if (!adminToken) { - l.error( - { - key: 'auth_events:bootstrap_user:missing_admin_token', - context: { provider }, - }, - 'DASHBOARD_API_ADMIN_TOKEN is not configured' - ) - return false - } + const bootstrapResult = + await createAdminUsersRepository().bootstrapAuthProviderUser(body) - const signupMetadata = await readOrySignupMetadataCookie() - const body = { - oidc_issuer: claims.oidcIssuer, - oidc_user_id: claims.oidcUserId, - oidc_user_email: claims.oidcUserEmail, - oidc_user_name: claims.oidcUserName, - ...(signupMetadata?.signup_ip - ? { signup_ip: signupMetadata.signup_ip } - : {}), - ...(signupMetadata?.signup_user_agent - ? { signup_user_agent: signupMetadata.signup_user_agent } - : {}), - } satisfies DashboardApiComponents['schemas']['AdminAuthProviderUserBootstrapRequest'] - - const { error, response } = await api.POST('/admin/users/bootstrap', { - body, - headers: ADMIN_AUTH_HEADERS(adminToken), - }) - - if (!response.ok || error) { - const repoError = repoErrorFromHttp( - response.status, - error?.message ?? 'Failed to bootstrap user', - error - ) + if (!bootstrapResult.ok) { l.error( { key: 'auth_events:bootstrap_user:error', context: { provider, - error_status: response.status, has_oidc_issuer: body.oidc_issuer !== '', has_oidc_user_id: body.oidc_user_id !== '', has_oidc_user_email: body.oidc_user_email !== '', has_oidc_user_name: body.oidc_user_name !== null, }, }, - `bootstrap_user failed: ${repoError.message}` + `bootstrap_user failed: ${bootstrapResult.error.message}` ) return false } diff --git a/src/core/server/functions/team/resolve-user-team.ts b/src/core/server/functions/team/resolve-user-team.ts index 61281d2fa..b17e9cf63 100644 --- a/src/core/server/functions/team/resolve-user-team.ts +++ b/src/core/server/functions/team/resolve-user-team.ts @@ -6,6 +6,7 @@ import { ENABLE_USER_BOOTSTRAP } from '@/configs/env-flags' import type { ResolvedTeam } from '@/core/modules/teams/models' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { createAdminUsersRepository } from '@/core/modules/users/admin-repository.server' +import { createOryUserBootstrapRequest } from '@/core/server/auth/ory/dashboard-bootstrap' import { l } from '@/core/shared/clients/logger/logger' export async function resolveUserTeam( @@ -71,8 +72,24 @@ export async function resolveUserTeam( return null } + const bootstrapRequest = await createOryUserBootstrapRequest({ + accessToken, + }) + if (!bootstrapRequest) { + l.error( + { + key: 'resolve_user_team:bootstrap_claims_error', + user_id: userId, + }, + 'Failed to build auth provider user bootstrap request' + ) + + return null + } + const adminUsersRepository = createAdminUsersRepository() - const bootstrapResult = await adminUsersRepository.bootstrapUser(userId) + const bootstrapResult = + await adminUsersRepository.bootstrapAuthProviderUser(bootstrapRequest) if (!bootstrapResult.ok) { l.error( diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 65d570f83..0f7821b27 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -286,47 +286,6 @@ export interface paths { patch?: never trace?: never } - '/admin/users/{userId}/bootstrap': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Bootstrap user */ - post: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the user. */ - userId: components['parameters']['userId'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully bootstrapped user. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } '/admin/users/bootstrap': { parameters: { query?: never @@ -545,6 +504,51 @@ export interface paths { patch?: never trace?: never } + '/admin/users/{userId}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post?: never + /** + * Delete user + * @description Deletes a user by removing the identity provider record, user_identities mapping, and public.users row. + */ + delete: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the user. */ + userId: components['parameters']['userId'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully deleted user. */ + 204: { + headers: { + [name: string]: unknown + } + content?: never + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 404: components['responses']['404'] + 409: components['responses']['409'] + 500: components['responses']['500'] + } + } + options?: never + head?: never + patch?: never + trace?: never + } '/teams/resolve': { parameters: { query?: never @@ -1563,6 +1567,15 @@ export interface components { 'application/json': components['schemas']['Error'] } } + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Error'] + } + } /** @description Server error */ 500: { headers: { @@ -1605,6 +1618,16 @@ export interface components { teamSlug: string /** @description Maximum number of items to return per page. */ templates_limit: number + /** @description Identifier of the template. */ + templateID: string + /** @description Template tag name to check. */ + tag: string + /** @description Maximum number of ready assignment rows to return per tag. */ + tag_assignment_limit: number + /** @description Template tag name. */ + tag_path: string + /** @description Maximum number of assignment rows to return per page. */ + tag_assignments_limit: number /** @description Cursor returned by the previous list response in `{sort}|{value}|{templateID}` format. Rejected if its sort does not match the request. */ templates_cursor: string /** @description Filter templates by visibility (true = public, false = internal). */ @@ -1617,16 +1640,6 @@ export interface components { | 'created_at_desc' | 'updated_at_asc' | 'updated_at_desc' - /** @description Identifier of the template. */ - templateID: string - /** @description Template tag name to check. */ - tag: string - /** @description Maximum number of ready assignment rows to return per tag. */ - tag_assignment_limit: number - /** @description Template tag name. */ - tag_path: string - /** @description Maximum number of assignment rows to return per page. */ - tag_assignments_limit: number /** @description Cursor returned by the previous list response in `assigned_at|assignment_id` format. */ tag_assignments_cursor: string /** @description Maximum number of distinct tags to return per page. */ diff --git a/tests/integration/resolve-user-team.test.ts b/tests/integration/resolve-user-team.test.ts index e0e52fd79..cbae4655c 100644 --- a/tests/integration/resolve-user-team.test.ts +++ b/tests/integration/resolve-user-team.test.ts @@ -5,7 +5,7 @@ const { mockFlags, mockCookieStore, mockListUserTeams, - mockBootstrapUser, + mockBootstrapAuthProviderUser, mockResolveTeamBySlug, mockCreateUserTeamsRepository, mockCreateAdminUsersRepository, @@ -17,7 +17,7 @@ const { get: vi.fn(), }, mockListUserTeams: vi.fn(), - mockBootstrapUser: vi.fn(), + mockBootstrapAuthProviderUser: vi.fn(), mockResolveTeamBySlug: vi.fn(), mockCreateUserTeamsRepository: vi.fn(), mockCreateAdminUsersRepository: vi.fn(), @@ -44,7 +44,22 @@ vi.mock('@/configs/env-flags', () => ({ import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' const TEST_USER_ID = 'user-123' -const TEST_ACCESS_TOKEN = 'access-token' +const TEST_ACCESS_TOKEN = createTestJwt({ + iss: 'https://auth.example.test', + sub: TEST_USER_ID, + email: 'user-123@example.test', + name: 'Ada Lovelace', +}) + +function createTestJwt(payload: Record) { + return [ + Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString( + 'base64url' + ), + Buffer.from(JSON.stringify(payload)).toString('base64url'), + 'signature', + ].join('.') +} function setupCookies(cookieValues: Record) { mockCookieStore.get.mockImplementation((key: string) => { @@ -73,7 +88,7 @@ describe('resolveUserTeam', () => { resolveTeamBySlug: mockResolveTeamBySlug, }) mockCreateAdminUsersRepository.mockReturnValue({ - bootstrapUser: mockBootstrapUser, + bootstrapAuthProviderUser: mockBootstrapAuthProviderUser, }) }) @@ -226,7 +241,7 @@ describe('resolveUserTeam', () => { ok: true, data: [], }) - mockBootstrapUser.mockResolvedValue({ + mockBootstrapAuthProviderUser.mockResolvedValue({ ok: true, data: { id: 'bootstrapped-team', @@ -241,8 +256,13 @@ describe('resolveUserTeam', () => { slug: 'bootstrapped-team', }) expect(mockCreateAdminUsersRepository).toHaveBeenCalledTimes(1) - expect(mockBootstrapUser).toHaveBeenCalledTimes(1) - expect(mockBootstrapUser).toHaveBeenCalledWith(TEST_USER_ID) + expect(mockBootstrapAuthProviderUser).toHaveBeenCalledTimes(1) + expect(mockBootstrapAuthProviderUser).toHaveBeenCalledWith({ + oidc_issuer: 'https://auth.example.test', + oidc_user_id: TEST_USER_ID, + oidc_user_email: 'user-123@example.test', + oidc_user_name: 'Ada Lovelace', + }) }) it('returns null when bootstrap fails after empty team lookup', async () => { @@ -251,7 +271,7 @@ describe('resolveUserTeam', () => { ok: true, data: [], }) - mockBootstrapUser.mockResolvedValue({ + mockBootstrapAuthProviderUser.mockResolvedValue({ ok: false, error: new Error('Failed to bootstrap user'), }) @@ -260,8 +280,13 @@ describe('resolveUserTeam', () => { expect(result).toBeNull() expect(mockCreateAdminUsersRepository).toHaveBeenCalledTimes(1) - expect(mockBootstrapUser).toHaveBeenCalledTimes(1) - expect(mockBootstrapUser).toHaveBeenCalledWith(TEST_USER_ID) + expect(mockBootstrapAuthProviderUser).toHaveBeenCalledTimes(1) + expect(mockBootstrapAuthProviderUser).toHaveBeenCalledWith({ + oidc_issuer: 'https://auth.example.test', + oidc_user_id: TEST_USER_ID, + oidc_user_email: 'user-123@example.test', + oidc_user_name: 'Ada Lovelace', + }) }) it('returns null without bootstrapping when bootstrap is disabled', async () => { @@ -276,7 +301,7 @@ describe('resolveUserTeam', () => { expect(result).toBeNull() expect(mockCreateAdminUsersRepository).not.toHaveBeenCalled() - expect(mockBootstrapUser).not.toHaveBeenCalled() + expect(mockBootstrapAuthProviderUser).not.toHaveBeenCalled() }) it('returns null when listing teams fails', async () => { @@ -289,6 +314,6 @@ describe('resolveUserTeam', () => { const result = await resolveUserTeam(TEST_USER_ID, TEST_ACCESS_TOKEN) expect(result).toBeNull() - expect(mockBootstrapUser).not.toHaveBeenCalled() + expect(mockBootstrapAuthProviderUser).not.toHaveBeenCalled() }) })