diff --git a/apps/studio/data/oauth-apps/keys.ts b/apps/studio/data/oauth-apps/keys.ts index 39734ec121c..7fa7cd3de54 100644 --- a/apps/studio/data/oauth-apps/keys.ts +++ b/apps/studio/data/oauth-apps/keys.ts @@ -6,7 +6,20 @@ export const oauthAppsKeys = { ['oauth-apps', 'authorize', id, 'organizations', slug, 'projects'] as const, orgAppDetails: (slug: string | undefined, appId: string | undefined) => ['oauth-apps', 'organizations', slug, 'apps', appId, 'grant'] as const, - authorizedApps: (slug: string | undefined) => ['oauth-apps', 'authorized', slug] as const, - appMemberGrants: (slug: string | undefined, appId: string | undefined) => - ['oauth-apps', 'authorized', slug, appId, 'member-grants'] as const, + preflightValidation: (slug: string | undefined, appId: string | undefined) => + ['oauth-apps', 'organizations', slug, 'apps', appId, 'preflight-validation'] as const, + // cursor omitted (rather than passed as `undefined`) so the cursor-less key is a true prefix + // of every paginated key — invalidating without a cursor clears every cached page. + approvals: (slug: string | undefined, cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'approvals', slug] as const) + : (['oauth-apps', 'approvals', slug, cursor] as const), + appMemberGrants: (slug: string | undefined, appId: string | undefined, cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'authorized', slug, appId, 'member-grants'] as const) + : (['oauth-apps', 'authorized', slug, appId, 'member-grants', cursor] as const), + grants: (cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'grants'] as const) + : (['oauth-apps', 'grants', cursor] as const), } diff --git a/apps/studio/data/oauth-apps/mocks.test.ts b/apps/studio/data/oauth-apps/mocks.test.ts index cd5c6974829..144ce57ebe3 100644 --- a/apps/studio/data/oauth-apps/mocks.test.ts +++ b/apps/studio/data/oauth-apps/mocks.test.ts @@ -2,18 +2,19 @@ import { describe, expect, test } from 'vitest' import { getMockOAuthAppGrants, + getMockOAuthApprovals, getMockOAuthAppsAuthorizeApproveResult, getMockOAuthAppsAuthorizeIdentity, getMockOAuthAppsAuthorizeOrganizationProjects, getMockOAuthAppsAuthorizeRequest, - getMockOAuthAppsOverview, - getMockOAuthBlockedApps, + getMockOAuthAppsPreflightValidation, getMockOAuthOrgAppDetails, + getMockOAuthOwnGrants, OAUTH_APPS_MOCK_SCENARIOS, READ_ONLY_ROLE, USE_MOCKS, } from './mocks' -import { getFailedProjects, isRoleValidationFailure } from './types' +import { getFailedProjects, isPreflightValidationFailure, isRoleValidationFailure } from './types' const NORTHWIND_SLUG = 'northwind-traders' const TAILSPIN_SLUG = 'tailspin-toys' @@ -66,7 +67,7 @@ describe('oauth-apps mocks', () => { expect(kinds).toEqual(new Set(['organization_bound', 'member_bound'])) }) - test('a member-bound app exists for every project scoping mode', () => { + test('a member-bound app exists both with and without project scoping', () => { const modes = new Set( scenarios .map((scenario) => getMockOAuthAppsAuthorizeRequest(scenario)) @@ -74,15 +75,15 @@ describe('oauth-apps mocks', () => { .map((request) => request.project_scoping_mode) ) - expect(modes).toEqual(new Set(['off', 'optional', 'required'])) + expect(modes).toEqual(new Set([true, false])) }) - test('a dynamic client has a fixture', () => { - const dynamic = scenarios.filter( - (scenario) => getMockOAuthAppsAuthorizeRequest(scenario).registration_type === 'dynamic' - ) + test('a DCR-registered app is forced org-bound and unscoped', () => { + const dynamic = getMockOAuthAppsAuthorizeRequest(OAUTH_APPS_MOCK_SCENARIOS.dynamicMcpClient) - expect(dynamic.length).toBeGreaterThan(0) + expect(dynamic.registration_type).toBe('dynamic') + expect(dynamic.grant_kind).toBe('organization_bound') + expect(dynamic.project_scoping_mode).toBe(false) }) test('app_name mirrors the live name field', () => { @@ -100,28 +101,8 @@ describe('oauth-apps mocks', () => { }) describe('org app details fixtures', () => { - test('an unknown pair degrades to an unblocked, grant-less default', () => { - const details = getMockOAuthOrgAppDetails('not-an-org', 'not-an-app') - - expect(details.blocked_reason).toBeNull() - expect(details.existing_grant).toBeNull() - expect(details.organization_settings.require_project_scoping).toBe(false) - }) - - test('every blocked reason has a fixture', () => { - expect(getMockOAuthOrgAppDetails('fabrikam-industries', 'vercel-org-wide').blocked_reason).toBe( - 'org_requires_project_scoping' - ) - expect(getMockOAuthOrgAppDetails('litware-inc', 'vercel').blocked_reason).toBe( - 'app_blocked_for_organization' - ) - }) - - test('an org that requires project scoping does not block a project-scoped app', () => { - const details = getMockOAuthOrgAppDetails('fabrikam-industries', 'vercel') - - expect(details.organization_settings.require_project_scoping).toBe(true) - expect(details.blocked_reason).toBeNull() + test('an unknown pair degrades to a grant-less default', () => { + expect(getMockOAuthOrgAppDetails('not-an-org', 'not-an-app').existing_grant).toBeNull() }) test('the re-consent scenario carries an existing grant with a stale and a live ref', () => { @@ -140,9 +121,8 @@ describe('org app details fixtures', () => { test('the existing grant scopes differ from the scopes the app requests today', () => { const request = getMockOAuthAppsAuthorizeRequest(OAUTH_APPS_MOCK_SCENARIOS.vercelReconsent) const grant = getMockOAuthOrgAppDetails(TAILSPIN_SLUG, request.app_id).existing_grant - const requestedScopes = request.scopes.flatMap((group) => group.scopes).sort() - expect([...(grant?.approved_scopes ?? [])].sort()).not.toEqual(requestedScopes) + expect([...(grant?.approved_scopes ?? [])].sort()).not.toEqual([...request.scopes].sort()) }) test('an existing grant without project refs has a fixture', () => { @@ -156,9 +136,61 @@ describe('org app details fixtures', () => { }) }) +describe('preflight validation fixtures', () => { + test('an unknown org or app degrades to success', () => { + expect( + isPreflightValidationFailure(getMockOAuthAppsPreflightValidation('not-an-org', 'vercel')) + ).toBe(false) + expect( + isPreflightValidationFailure( + getMockOAuthAppsPreflightValidation(NORTHWIND_SLUG, 'not-an-app') + ) + ).toBe(false) + }) + + test('an org-bound app fails the organization branch for a non-admin', () => { + const result = getMockOAuthAppsPreflightValidation(NORTHWIND_SLUG, 'kemal-bot') + + if (!isPreflightValidationFailure(result)) throw new Error('expected a preflight failure') + expect(result.validation.scope_target).toBe('organization') + if (result.validation.scope_target !== 'organization') return + expect(result.validation.role.id).toBe(READ_ONLY_ROLE.id) + expect('failed_scopes' in result.validation).toBe(false) + }) + + test('an org-bound app passes the organization branch for an admin', () => { + const result = getMockOAuthAppsPreflightValidation(TAILSPIN_SLUG, 'kemal-bot') + + expect(isPreflightValidationFailure(result)).toBe(false) + }) + + test('a member-bound app fails the all-projects branch for a read-only member', () => { + const result = getMockOAuthAppsPreflightValidation(NORTHWIND_SLUG, 'vercel') + + if (!isPreflightValidationFailure(result)) throw new Error('expected a preflight failure') + expect(result.validation.scope_target).toBe('all_projects') + if (result.validation.scope_target !== 'all_projects') return + expect(result.validation.role.id).toBe(READ_ONLY_ROLE.id) + expect(result.validation.failed_scopes.length).toBeGreaterThan(0) + }) + + test('a member-bound app passes the all-projects branch for an admin', () => { + const result = getMockOAuthAppsPreflightValidation(TAILSPIN_SLUG, 'vercel') + + expect(isPreflightValidationFailure(result)).toBe(false) + }) + + test('preflight never returns a project-level failure', () => { + const result = getMockOAuthAppsPreflightValidation(NORTHWIND_SLUG, 'vercel') + + if (!isPreflightValidationFailure(result)) throw new Error('expected a preflight failure') + expect(result.validation.scope_target).not.toBe('projects') + }) +}) + describe('post-submit role validation', () => { - const approve = (authId: string, projectRefs: string[] | undefined) => - getMockOAuthAppsAuthorizeApproveResult(authId, { slug: NORTHWIND_SLUG, projectRefs }) + const approve = (authId: string, slug: string, projectRefs: string[] | undefined) => + getMockOAuthAppsAuthorizeApproveResult(authId, { slug, projectRefs }) const readOnlyRefs = () => getMockOAuthAppsAuthorizeOrganizationProjects(NORTHWIND_SLUG) @@ -176,7 +208,11 @@ describe('post-submit role validation', () => { }) test('rejects read-only projects when write scopes are requested', () => { - const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, readOnlyRefs()) + const result = approve( + OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, + NORTHWIND_SLUG, + readOnlyRefs() + ) if (!isRoleValidationFailure(result)) throw new Error('expected a role validation failure') @@ -192,10 +228,14 @@ describe('post-submit role validation', () => { }) test('every failed scope is one the app actually requested', () => { - const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, readOnlyRefs()) + const result = approve( + OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, + NORTHWIND_SLUG, + readOnlyRefs() + ) const requestedScopes = getMockOAuthAppsAuthorizeRequest( OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation - ).scopes.flatMap((group) => group.scopes) + ).scopes if (!isRoleValidationFailure(result)) throw new Error('expected a role validation failure') @@ -205,7 +245,7 @@ describe('post-submit role validation', () => { }) test('rejects only the read-only refs out of a mixed selection', () => { - const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, [ + const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, NORTHWIND_SLUG, [ ...readOnlyRefs(), ...writableRefs(), ]) @@ -222,33 +262,57 @@ describe('post-submit role validation', () => { test('approves a selection of writable projects', () => { expect( isRoleValidationFailure( - approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, writableRefs()) + approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, NORTHWIND_SLUG, writableRefs()) ) ).toBe(false) }) test('scenarios outside the role-validated set approve read-only projects', () => { expect( - isRoleValidationFailure(approve(OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper, readOnlyRefs())) + isRoleValidationFailure( + approve(OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper, NORTHWIND_SLUG, readOnlyRefs()) + ) ).toBe(false) }) - test('an org-wide approval by a read-only member fails on the organization branch', () => { - const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects, undefined) + test('a member-bound, all-projects approval by a read-only member fails on the all_projects branch', () => { + const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects, NORTHWIND_SLUG, undefined) if (!isRoleValidationFailure(result)) throw new Error('expected a role validation failure') - expect(result.validation.scope_target).toBe('organization') - if (result.validation.scope_target !== 'organization') return + expect(result.validation.scope_target).toBe('all_projects') + if (result.validation.scope_target !== 'all_projects') return expect(result.validation.role.id).toBe(READ_ONLY_ROLE.id) expect(result.validation.failed_scopes.length).toBeGreaterThan(0) expect(getFailedProjects(result)).toEqual([]) }) - test('an org-wide approval by a writable member succeeds', () => { - const result = getMockOAuthAppsAuthorizeApproveResult( + test('a member-bound, all-projects approval by a writable member succeeds', () => { + const result = approve( OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects, - { slug: 'fabrikam-industries', projectRefs: undefined } + 'fabrikam-industries', + undefined + ) + + expect(isRoleValidationFailure(result)).toBe(false) + }) + + test('an org-bound approval by a non-admin fails on the organization branch, without failed_scopes', () => { + const result = approve(OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide, NORTHWIND_SLUG, undefined) + + if (!isRoleValidationFailure(result)) throw new Error('expected a role validation failure') + + expect(result.validation.scope_target).toBe('organization') + if (result.validation.scope_target !== 'organization') return + expect(result.validation.role.name).toBe('Developer') + expect('failed_scopes' in result.validation).toBe(false) + }) + + test('an org-bound approval by an admin succeeds', () => { + const result = approve( + OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide, + 'fabrikam-industries', + undefined ) expect(isRoleValidationFailure(result)).toBe(false) @@ -256,47 +320,22 @@ describe('post-submit role validation', () => { }) describe('authorized apps overview fixtures', () => { - test('both statuses the table renders have a fixture', () => { - const statuses = new Set(getMockOAuthAppsOverview().data.map((app) => app.status)) + test('an app with an organization grant has a fixture', () => { + const withOrgGrant = getMockOAuthApprovals().data.find((app) => app.org_grant !== null) - expect(statuses).toEqual(new Set(['active', 'legacy'])) - }) - - test('the legacy app carries an organization grant', () => { - const legacy = getMockOAuthAppsOverview().data.find((app) => app.status === 'legacy') - - expect(legacy?.org_grant).not.toBeNull() - }) - - test('a singular and a plural grant count both have a fixture', () => { - const counts = getMockOAuthAppsOverview().data.map((app) => app.member_grant_count) - - expect(counts).toContain(1) - expect(counts.some((count) => count > 1)).toBe(true) + expect(withOrgGrant).toBeDefined() }) test('app ids are unique so the table can key rows on them', () => { - const apps = getMockOAuthAppsOverview().data + const apps = getMockOAuthApprovals().data expect(new Set(apps.map((app) => app.id)).size).toBe(apps.length) }) }) -describe('blocked apps fixtures', () => { - test('lists at least one blocked app with who blocked it', () => { - const blocked = getMockOAuthBlockedApps().data - - expect(blocked.length).toBeGreaterThan(0) - blocked.forEach((app) => { - expect(app.blocked_at).toBeTruthy() - expect(app.blocked_by.email).toBeTruthy() - }) - }) -}) - describe('app grants fixtures', () => { test('every app in the overview resolves a grant list', () => { - getMockOAuthAppsOverview().data.forEach((app) => { + getMockOAuthApprovals().data.forEach((app) => { expect(Array.isArray(getMockOAuthAppGrants(app.id).data)).toBe(true) }) }) @@ -305,28 +344,46 @@ describe('app grants fixtures', () => { expect(getMockOAuthAppGrants('not-an-app').data).toEqual([]) }) - test('an organization-bound grant has no user and no project refs', () => { - const grants = getMockOAuthAppsOverview().data.flatMap( - (app) => getMockOAuthAppGrants(app.id).data - ) + test('an organization-bound grant has no user and no projects', () => { + const grants = getMockOAuthApprovals().data.flatMap((app) => getMockOAuthAppGrants(app.id).data) const orgBound = grants.filter((grant) => grant.kind === 'organization_bound') expect(orgBound.length).toBeGreaterThan(0) orgBound.forEach((grant) => { expect(grant.user).toBeNull() - expect(grant.project_refs).toBeNull() + expect(grant.projects).toBeNull() }) }) - test('member-bound grants carry a user', () => { - const grants = getMockOAuthAppsOverview().data.flatMap( - (app) => getMockOAuthAppGrants(app.id).data - ) + test('member-bound grants carry a user and hydrated projects', () => { + const grants = getMockOAuthApprovals().data.flatMap((app) => getMockOAuthAppGrants(app.id).data) const memberBound = grants.filter((grant) => grant.kind === 'member_bound') expect(memberBound.length).toBeGreaterThan(0) memberBound.forEach((grant) => { expect(grant.user?.email).toBeTruthy() + expect(grant.projects?.length).toBeGreaterThan(0) + grant.projects?.forEach((project) => { + expect(project.ref).toBeTruthy() + expect(project.name).toBeTruthy() + }) + }) + }) +}) + +describe('own grants fixtures', () => { + test("lists at least one grant across the member's orgs", () => { + const grants = getMockOAuthOwnGrants().data + + expect(grants.length).toBeGreaterThan(0) + grants.forEach((grant) => { + expect(grant.app.name).toBeTruthy() + expect(grant.organization.slug).toBeTruthy() + expect(grant.approved_scopes.length).toBeGreaterThan(0) + grant.projects?.forEach((project) => { + expect(project.ref).toBeTruthy() + expect(project.name).toBeTruthy() + }) }) }) }) diff --git a/apps/studio/data/oauth-apps/mocks.ts b/apps/studio/data/oauth-apps/mocks.ts index 22fedb5327e..27f1b81884e 100644 --- a/apps/studio/data/oauth-apps/mocks.ts +++ b/apps/studio/data/oauth-apps/mocks.ts @@ -1,18 +1,18 @@ import type { OAuthAppsAuthorizeIdentity } from './oauth-apps-authorize-organizations-query' import type { - ListAppGrantsResponse, - ListBlockedAppsResponse, - ListOAuthAppsOverviewResponse, + ListOAuthApprovalsResponse, + ListOrgAppGrantsResponse, + ListOwnGrantsResponse, OAuthAppsAuthorizeApproveResult, OAuthAppsAuthorizeOrganizationProject, + OAuthAppsAuthorizePreflightResult, OAuthAppsAuthorizeRequest, OAuthExistingGrant, OAuthOrganizationRole, OAuthOrgAppDetails, OAuthScope, - OAuthScopeGroup, - OAuthScopeLevel, } from './types' +import { isPreflightValidationFailure, isWriteScope } from './types' import type { OrganizationRole } from '@/data/organization-members/organization-roles-query' const ENABLE_MOCKS = true @@ -25,7 +25,6 @@ export const OAUTH_APPS_MOCK_SCENARIOS = { vercelOrgAdmin: 'mock-vercel-org-admin', vercelManyProjects: 'mock-vercel-many-projects', vercelRoleValidation: 'mock-vercel-role-validation', - vercelBlocked: 'mock-vercel-blocked', vercelOptionalProjects: 'mock-vercel-optional-projects', vercelAllProjects: 'mock-vercel-all-projects', vercelReconsentAllProjects: 'mock-vercel-reconsent-all-projects', @@ -66,24 +65,16 @@ export const READ_ONLY_ROLE: OrganizationRole = { projects: [], } -const VERCEL_SCOPE_GROUPS: OAuthScopeGroup[] = [ - { - name: 'Database, Environment, Secrets', - level: 'read_write', - scopes: [ - 'database:read', - 'database:write', - 'environment:read', - 'environment:write', - 'secrets:read', - 'secrets:write', - ], - }, - { - name: 'Projects, Edge Functions, Storage', - level: 'read', - scopes: ['projects:read', 'edge_functions:read', 'storage:read'], - }, +const VERCEL_SCOPES: OAuthScope[] = [ + 'database:read', + 'database:write', + 'environment:read', + 'environment:write', + 'secrets:read', + 'secrets:write', + 'projects:read', + 'edge_functions:read', + 'storage:read', ] const VERCEL_REQUEST: OAuthAppsAuthorizeRequest = { @@ -96,22 +87,20 @@ const VERCEL_REQUEST: OAuthAppsAuthorizeRequest = { registration_type: 'manual', expires_at: '2026-09-17T12:00:00.000Z', grant_kind: 'member_bound', - project_scoping_mode: 'required', - allow_partial_grants: false, - scopes: VERCEL_SCOPE_GROUPS, + project_scoping_mode: true, + scopes: VERCEL_SCOPES, } const VERCEL_OPTIONAL_PROJECTS_REQUEST: OAuthAppsAuthorizeRequest = { ...VERCEL_REQUEST, app_id: 'vercel-optional', - project_scoping_mode: 'optional', - allow_partial_grants: true, + project_scoping_mode: true, } const VERCEL_ALL_PROJECTS_REQUEST: OAuthAppsAuthorizeRequest = { ...VERCEL_REQUEST, app_id: 'vercel-org-wide', - project_scoping_mode: 'off', + project_scoping_mode: false, } const DYNAMIC_MCP_CLIENT_REQUEST: OAuthAppsAuthorizeRequest = { @@ -123,6 +112,9 @@ const DYNAMIC_MCP_CLIENT_REQUEST: OAuthAppsAuthorizeRequest = { domain: 'mcp.northwind.example', redirect_uri: 'https://mcp.northwind.example/callback', registration_type: 'dynamic', + // DCR (MCP) oauth apps are forced org-bound, unscoped. + grant_kind: 'organization_bound', + project_scoping_mode: false, } const KEMAL_BOT_REQUEST: OAuthAppsAuthorizeRequest = { @@ -135,21 +127,14 @@ const KEMAL_BOT_REQUEST: OAuthAppsAuthorizeRequest = { registration_type: 'manual', expires_at: '2026-09-17T12:00:00.000Z', grant_kind: 'organization_bound', - project_scoping_mode: 'required', - allow_partial_grants: false, - scopes: [ - { - name: 'Projects', - level: 'read', - scopes: ['projects:read'], - }, - ], + project_scoping_mode: true, + scopes: ['projects:read'], } const KEMAL_BOT_ORG_WIDE_REQUEST: OAuthAppsAuthorizeRequest = { ...KEMAL_BOT_REQUEST, app_id: 'kemal-bot-org-wide', - project_scoping_mode: 'off', + project_scoping_mode: false, } const MOCK_AUTHORIZE_REQUESTS: Record = { @@ -159,7 +144,6 @@ const MOCK_AUTHORIZE_REQUESTS: Record = { [OAUTH_APPS_MOCK_SCENARIOS.vercelOrgAdmin]: VERCEL_REQUEST, [OAUTH_APPS_MOCK_SCENARIOS.vercelManyProjects]: VERCEL_REQUEST, [OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation]: VERCEL_REQUEST, - [OAUTH_APPS_MOCK_SCENARIOS.vercelBlocked]: VERCEL_REQUEST, [OAUTH_APPS_MOCK_SCENARIOS.vercelOptionalProjects]: VERCEL_OPTIONAL_PROJECTS_REQUEST, [OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects]: VERCEL_ALL_PROJECTS_REQUEST, [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsentAllProjects]: VERCEL_OPTIONAL_PROJECTS_REQUEST, @@ -234,10 +218,6 @@ const MOCK_IDENTITIES: Record = { email: 'admin@example.com', organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], }, - [OAUTH_APPS_MOCK_SCENARIOS.vercelBlocked]: { - email: 'admin@example.com', - organizations: [LITWARE_DEVELOPER, NORTHWIND_TRADERS_DEVELOPER], - }, [OAUTH_APPS_MOCK_SCENARIOS.vercelOptionalProjects]: { email: 'admin@example.com', organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], @@ -309,93 +289,66 @@ const TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT: OAuthExistingGrant = { } const DEFAULT_ORG_APP_DETAILS: OAuthOrgAppDetails = { - organization_settings: { require_project_scoping: false }, - blocked_reason: null, - existing_grant: null, -} - -const FABRIKAM_ORG_APP_DETAILS: OAuthOrgAppDetails = { - organization_settings: { require_project_scoping: true }, - blocked_reason: null, existing_grant: null, } const MOCK_ORG_APP_DETAILS: Record> = { 'tailspin-toys': { - vercel: { ...DEFAULT_ORG_APP_DETAILS, existing_grant: TAILSPIN_VERCEL_EXISTING_GRANT }, - 'vercel-optional': { - ...DEFAULT_ORG_APP_DETAILS, - existing_grant: TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT, - }, - }, - 'fabrikam-industries': { - vercel: FABRIKAM_ORG_APP_DETAILS, - 'vercel-org-wide': { - ...FABRIKAM_ORG_APP_DETAILS, - blocked_reason: 'org_requires_project_scoping', - }, - 'kemal-bot-org-wide': { - ...FABRIKAM_ORG_APP_DETAILS, - blocked_reason: 'org_requires_project_scoping', - }, - }, - 'litware-inc': { - vercel: { ...DEFAULT_ORG_APP_DETAILS, blocked_reason: 'app_blocked_for_organization' }, + vercel: { existing_grant: TAILSPIN_VERCEL_EXISTING_GRANT }, + 'vercel-optional': { existing_grant: TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT }, }, } -const MOCK_APPS_OVERVIEW: ListOAuthAppsOverviewResponse = { +// Real oauth_apps config, keyed by app_id — preflight-validation is org+app scoped, not tied to +// an in-flight authorization request. +const MOCK_APPS_BY_ID: Record = { + vercel: VERCEL_REQUEST, + 'vercel-optional': VERCEL_OPTIONAL_PROJECTS_REQUEST, + 'vercel-org-wide': VERCEL_ALL_PROJECTS_REQUEST, + 'dynamic-mcp-client': DYNAMIC_MCP_CLIENT_REQUEST, + 'kemal-bot': KEMAL_BOT_REQUEST, + 'kemal-bot-org-wide': KEMAL_BOT_ORG_WIDE_REQUEST, +} + +// The current member's org-level role, keyed by org slug — same reasoning as MOCK_APPS_BY_ID. +const MOCK_ORGANIZATIONS_BY_SLUG: Record = { + 'northwind-traders': NORTHWIND_TRADERS_READ_ONLY, + 'tailspin-toys': TAILSPIN_TOYS_ADMIN, + 'fabrikam-industries': FABRIKAM_OWNER, + 'contoso-labs': CONTOSO_LABS, + 'wingtip-toys': WINGTIP_TOYS_DEVELOPER, + 'litware-inc': LITWARE_DEVELOPER, +} + +const MOCK_APPROVALS: ListOAuthApprovalsResponse = { data: [ { id: 'vercel', name: 'Vercel', icon: null, - status: 'active', - member_grant_count: 33, - last_used_at: '2026-09-16T08:12:00.000Z', org_grant: null, }, { id: 'dynamic-mcp-client', name: 'Northwind MCP', icon: null, - status: 'active', - member_grant_count: 1, - last_used_at: '2026-09-01T08:45:00.000Z', org_grant: null, }, { id: 'contoso-analytics', name: 'Contoso Analytics', icon: null, - status: 'legacy', - member_grant_count: 0, - last_used_at: '2026-07-02T10:00:00.000Z', org_grant: { grant_id: 'grant-contoso-analytics-org', approved_scopes: ['analytics:read', 'projects:read'], approved_at: '2025-11-03T14:20:00.000Z', - last_used_at: '2026-07-02T10:00:00.000Z', }, }, ], pagination: { next_cursor: null }, } -const MOCK_BLOCKED_APPS: ListBlockedAppsResponse = { - data: [ - { - app_id: 'kemal-bot', - name: 'kemal-bot', - icon: null, - blocked_at: '2026-09-10T15:30:00.000Z', - blocked_by: { gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000001', email: 'admin@example.com' }, - }, - ], - pagination: { next_cursor: null }, -} - -const MOCK_APP_GRANTS: Record = { +const MOCK_APP_GRANTS: Record = { vercel: { data: [ { @@ -405,10 +358,12 @@ const MOCK_APP_GRANTS: Record = { gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000001', email: 'admin@example.com', }, - project_refs: ['northwindstorefront1', 'northwindcms1'], + projects: [ + { ref: 'northwindstorefront1', name: 'northwind-storefront' }, + { ref: 'northwindcms1', name: 'northwind-cms' }, + ], approved_scopes: ['database:read', 'database:write', 'projects:read'], approved_at: '2026-08-18T09:12:00.000Z', - last_used_at: '2026-09-16T08:12:00.000Z', }, { grant_id: 'grant-vercel-developer', @@ -418,10 +373,9 @@ const MOCK_APP_GRANTS: Record = { email: 'developer@example.com', avatar_url: 'https://avatars.example/developer.png', }, - project_refs: ['northwindcms1'], + projects: [{ ref: 'northwindcms1', name: 'northwind-cms' }], approved_scopes: ['projects:read'], approved_at: '2026-08-16T11:30:00.000Z', - last_used_at: null, }, ], pagination: { next_cursor: null }, @@ -435,10 +389,9 @@ const MOCK_APP_GRANTS: Record = { gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000003', email: 'ops@example.com', }, - project_refs: ['northwindstorefront1'], + projects: [{ ref: 'northwindstorefront1', name: 'northwind-storefront' }], approved_scopes: ['database:read', 'database:write'], approved_at: '2026-09-01T08:45:00.000Z', - last_used_at: '2026-09-01T08:45:00.000Z', }, ], pagination: { next_cursor: null }, @@ -449,28 +402,54 @@ const MOCK_APP_GRANTS: Record = { grant_id: 'grant-contoso-analytics-org', kind: 'organization_bound', user: null, - project_refs: null, + projects: null, approved_scopes: ['analytics:read', 'projects:read'], approved_at: '2025-11-03T14:20:00.000Z', - last_used_at: '2026-07-02T10:00:00.000Z', }, ], pagination: { next_cursor: null }, }, } -export function getMockOAuthAppsOverview(): ListOAuthAppsOverviewResponse { - return MOCK_APPS_OVERVIEW +const MOCK_OWN_GRANTS: ListOwnGrantsResponse = { + data: [ + { + grant_id: 'grant-vercel-admin', + app: { id: 'vercel', name: 'Vercel', icon: null }, + organization: { slug: 'northwind-traders', name: 'Northwind Traders' }, + projects: [ + { ref: 'northwindstorefront1', name: 'northwind-storefront' }, + { ref: 'northwindcms1', name: 'northwind-cms' }, + ], + approved_scopes: ['database:read', 'database:write', 'projects:read'], + approved_at: '2026-08-18T09:12:00.000Z', + }, + { + grant_id: 'grant-northwind-mcp-ops', + app: { id: 'dynamic-mcp-client', name: 'Northwind MCP', icon: null }, + organization: { slug: 'northwind-traders', name: 'Northwind Traders' }, + projects: [{ ref: 'northwindstorefront1', name: 'northwind-storefront' }], + approved_scopes: ['database:read', 'database:write'], + approved_at: '2026-09-01T08:45:00.000Z', + }, + ], + pagination: { next_cursor: null }, } -export function getMockOAuthBlockedApps(): ListBlockedAppsResponse { - return MOCK_BLOCKED_APPS +// Fixture lists are small and static — cursor is accepted for real-endpoint parity but not +// used to actually paginate the mock data. +export function getMockOAuthApprovals(_cursor?: string): ListOAuthApprovalsResponse { + return MOCK_APPROVALS } -export function getMockOAuthAppGrants(appId: string): ListAppGrantsResponse { +export function getMockOAuthAppGrants(appId: string, _cursor?: string): ListOrgAppGrantsResponse { return MOCK_APP_GRANTS[appId] ?? { data: [], pagination: { next_cursor: null } } } +export function getMockOAuthOwnGrants(_cursor?: string): ListOwnGrantsResponse { + return MOCK_OWN_GRANTS +} + export function getMockOAuthAppsAuthorizeRequest(authId: string): OAuthAppsAuthorizeRequest { const request = MOCK_AUTHORIZE_REQUESTS[authId] if (!request) throw new Error(`No mock authorize request for id "${authId}"`) @@ -493,6 +472,12 @@ export function getMockOAuthOrgAppDetails(slug: string, appId: string): OAuthOrg return MOCK_ORG_APP_DETAILS[slug]?.[appId] ?? DEFAULT_ORG_APP_DETAILS } +function findMockOrganization(authId: string, slug: string): OAuthOrganizationRole | undefined { + return getMockOAuthAppsAuthorizeIdentity(authId).organizations.find( + (candidate) => candidate.slug === slug + ) +} + const MOCK_OAUTH_STATE = 'mock_state_9f2c1b' function buildMockRedirectUrl(redirectUri: string, params: Record) { @@ -523,14 +508,57 @@ export function getMockOAuthAppsAuthorizeRedirect( const ROLE_VALIDATED_SCENARIOS = new Set([ OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects, + OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide, ]) -const WRITE_SCOPE_LEVELS: OAuthScopeLevel[] = ['write', 'read_write'] - function isReadOnlyRole(role: OrganizationRole) { return role.id === READ_ONLY_ROLE.id } +function isOwnerOrAdmin(role: OrganizationRole) { + return role.id === OWNER_ROLE.id || role.id === ADMINISTRATOR_ROLE.id +} + +// Upfront org-level check shared by preflight-validation and the "all projects" branch of approve. +// Never checks project-scoped roles (that's approve's job once project_refs are known). +function evaluateGrantEligibility( + request: OAuthAppsAuthorizeRequest, + organization: OAuthOrganizationRole +): OAuthAppsAuthorizePreflightResult { + if (request.grant_kind === 'organization_bound') { + if (isOwnerOrAdmin(organization.default_role)) return { ok: true } + return { + error_code: 'role_validation_failed', + message: `Your ${organization.default_role.name} role cannot install this app for the organization.`, + validation: { scope_target: 'organization', role: organization.default_role }, + } + } + + const failedScopes = request.scopes.filter(isWriteScope) + if (failedScopes.length === 0 || !isReadOnlyRole(organization.default_role)) return { ok: true } + + return { + error_code: 'role_validation_failed', + message: `Your ${organization.default_role.name} role cannot satisfy this app's scopes for all projects.`, + validation: { + scope_target: 'all_projects', + role: organization.default_role, + failed_scopes: failedScopes, + }, + } +} + +export function getMockOAuthAppsPreflightValidation( + slug: string, + appId: string +): OAuthAppsAuthorizePreflightResult { + const request = MOCK_APPS_BY_ID[appId] + const organization = MOCK_ORGANIZATIONS_BY_SLUG[slug] + if (!request || !organization) return { ok: true } + + return evaluateGrantEligibility(request, organization) +} + export function getMockOAuthAppsAuthorizeApproveResult( authId: string, { slug, projectRefs }: { slug: string; projectRefs: string[] | undefined } @@ -538,28 +566,18 @@ export function getMockOAuthAppsAuthorizeApproveResult( const approved = getMockOAuthAppsAuthorizeRedirect(authId, { approved: true }) if (!ROLE_VALIDATED_SCENARIOS.has(authId)) return approved - const failedScopes: OAuthScope[] = getMockOAuthAppsAuthorizeRequest(authId) - .scopes.filter((scopeGroup) => WRITE_SCOPE_LEVELS.includes(scopeGroup.level)) - .flatMap((scopeGroup) => scopeGroup.scopes) - if (failedScopes.length === 0) return approved + const request = getMockOAuthAppsAuthorizeRequest(authId) + const organization = findMockOrganization(authId, slug) + if (!organization) return approved - if (projectRefs === undefined) { - const organization = getMockOAuthAppsAuthorizeIdentity(authId).organizations.find( - (candidate) => candidate.slug === slug - ) - if (!organization || !isReadOnlyRole(organization.default_role)) return approved - - return { - error_code: 'role_validation_failed', - message: `Your ${organization.default_role.name} role cannot grant write access to this organization.`, - validation: { - scope_target: 'organization', - role: organization.default_role, - failed_scopes: failedScopes, - }, - } + if (projectRefs === undefined || request.grant_kind === 'organization_bound') { + const result = evaluateGrantEligibility(request, organization) + return isPreflightValidationFailure(result) ? result : approved } + const failedScopes = request.scopes.filter(isWriteScope) + if (failedScopes.length === 0) return approved + const blocked = getMockOAuthAppsAuthorizeOrganizationProjects(slug).filter( (project) => projectRefs.includes(project.ref) && isReadOnlyRole(project.role) ) diff --git a/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts b/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts new file mode 100644 index 00000000000..f440618196b --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthApprovals, USE_MOCKS } from './mocks' +import type { ListOAuthApprovalsResponse } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthApprovalsVariables = { + slug?: string + cursor?: string +} + +export type { ListOAuthApprovalsResponse, OAuthApprovalItem } from './types' + +export async function getOAuthApprovals({ + slug, + cursor, +}: OAuthApprovalsVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) throw new Error('OAuth app approvals are not yet implemented') + + return getMockOAuthApprovals(cursor) +} + +export type OAuthApprovalsData = Awaited> +export type OAuthApprovalsError = ResponseError + +export const useOAuthApprovalsQuery = ( + { slug, cursor }: OAuthApprovalsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.approvals(slug, cursor), + queryFn: () => getOAuthApprovals({ slug, cursor }), + enabled: enabled && USE_MOCKS && Boolean(slug), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorized-apps-query.ts b/apps/studio/data/oauth-apps/oauth-apps-authorized-apps-query.ts deleted file mode 100644 index 19e4d742806..00000000000 --- a/apps/studio/data/oauth-apps/oauth-apps-authorized-apps-query.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -import { oauthAppsKeys } from './keys' -import { getMockOAuthAppsOverview, USE_MOCKS } from './mocks' -import type { ListOAuthAppsOverviewResponse } from './types' -import type { ResponseError, UseCustomQueryOptions } from '@/types' - -export type OAuthAuthorizedAppsVariables = { - slug?: string -} - -export type { ListOAuthAppsOverviewResponse, OAuthAppOverviewItem } from './types' - -export async function getOAuthAuthorizedApps({ - slug, -}: OAuthAuthorizedAppsVariables): Promise { - if (!slug) throw new Error('Organization slug is required') - if (!USE_MOCKS) throw new Error('OAuth authorized apps are not yet implemented') - - return getMockOAuthAppsOverview() -} - -export type OAuthAuthorizedAppsData = Awaited> -export type OAuthAuthorizedAppsError = ResponseError - -export const useOAuthAuthorizedAppsQuery = ( - { slug }: OAuthAuthorizedAppsVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions = {} -) => - useQuery({ - queryKey: oauthAppsKeys.authorizedApps(slug), - queryFn: () => getOAuthAuthorizedApps({ slug }), - enabled: enabled && USE_MOCKS && Boolean(slug), - ...options, - }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts b/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts new file mode 100644 index 00000000000..f5f300fc160 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthOwnGrants, USE_MOCKS } from './mocks' +import type { ListOwnGrantsResponse } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthGrantsVariables = { + cursor?: string +} + +export type { ListOwnGrantsResponse, MemberOauthGrantItem, OAuthGrantProject } from './types' + +export async function getOAuthGrants({ + cursor, +}: OAuthGrantsVariables = {}): Promise { + if (!USE_MOCKS) throw new Error('OAuth grants are not yet implemented') + + return getMockOAuthOwnGrants(cursor) +} + +export type OAuthGrantsData = Awaited> +export type OAuthGrantsError = ResponseError + +export const useOAuthGrantsQuery = ( + { cursor }: OAuthGrantsVariables = {}, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.grants(cursor), + queryFn: () => getOAuthGrants({ cursor }), + enabled: enabled && USE_MOCKS, + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts b/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts index cc2be0cca96..6cef0997500 100644 --- a/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts +++ b/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts @@ -2,40 +2,42 @@ import { useQuery } from '@tanstack/react-query' import { oauthAppsKeys } from './keys' import { getMockOAuthAppGrants, USE_MOCKS } from './mocks' -import type { ListAppGrantsResponse } from './types' +import type { ListOrgAppGrantsResponse } from './types' import type { ResponseError, UseCustomQueryOptions } from '@/types' export type OAuthAppMemberGrantsVariables = { slug?: string appId?: string + cursor?: string } -export type { ListAppGrantsResponse, OAuthGrantItem } from './types' +export type { ListOrgAppGrantsResponse, OAuthGrantItem, OAuthGrantProject } from './types' export async function getOAuthAppMemberGrants({ slug, appId, -}: OAuthAppMemberGrantsVariables): Promise { + cursor, +}: OAuthAppMemberGrantsVariables): Promise { if (!slug) throw new Error('Organization slug is required') if (!appId) throw new Error('App id is required') if (!USE_MOCKS) throw new Error('OAuth app member grants are not yet implemented') - return getMockOAuthAppGrants(appId) + return getMockOAuthAppGrants(appId, cursor) } export type OAuthAppMemberGrantsData = Awaited> export type OAuthAppMemberGrantsError = ResponseError export const useOAuthAppMemberGrantsQuery = ( - { slug, appId }: OAuthAppMemberGrantsVariables, + { slug, appId, cursor }: OAuthAppMemberGrantsVariables, { enabled = true, ...options }: UseCustomQueryOptions = {} ) => useQuery({ - queryKey: oauthAppsKeys.appMemberGrants(slug, appId), - queryFn: () => getOAuthAppMemberGrants({ slug, appId }), + queryKey: oauthAppsKeys.appMemberGrants(slug, appId, cursor), + queryFn: () => getOAuthAppMemberGrants({ slug, appId, cursor }), enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId), ...options, }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts b/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts new file mode 100644 index 00000000000..5bf8f6a2e68 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsPreflightValidation, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizePreflightResult } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsPreflightValidationVariables = { + slug?: string + appId?: string +} + +export type { OAuthAppsAuthorizePreflightResult } from './types' + +export async function getOAuthAppsPreflightValidation({ + slug, + appId, +}: OAuthAppsPreflightValidationVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth app preflight validation is not yet implemented') + + return getMockOAuthAppsPreflightValidation(slug, appId) +} + +export type OAuthAppsPreflightValidationData = Awaited< + ReturnType +> +export type OAuthAppsPreflightValidationError = ResponseError + +export const useOAuthAppsPreflightValidationQuery = ( + { slug, appId }: OAuthAppsPreflightValidationVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsPreflightValidationData, + OAuthAppsPreflightValidationError, + TData + > = {} +) => + useQuery({ + queryKey: oauthAppsKeys.preflightValidation(slug, appId), + queryFn: () => getOAuthAppsPreflightValidation({ slug, appId }), + enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts new file mode 100644 index 00000000000..e7fe1faa1b4 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts @@ -0,0 +1,48 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { oauthAppsKeys } from './keys' +import { USE_MOCKS } from './mocks' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthGrantRevokeVariables = { + slug: string + grantId: string +} + +export async function revokeOAuthGrant({ slug, grantId }: OAuthGrantRevokeVariables) { + if (!slug) throw new Error('Organization slug is required') + if (!grantId) throw new Error('Grant id is required') + if (!USE_MOCKS) throw new Error('OAuth grant revocation is not yet implemented') + + // 204 on success — no response body. +} + +type OAuthGrantRevokeData = Awaited> + +export const useOAuthGrantRevokeMutation = ({ + onError, + onSuccess, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => revokeOAuthGrant(vars), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ queryKey: oauthAppsKeys.grants() }) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to revoke OAuth grant: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts index 8fcd8feb84f..72f80a5b592 100644 --- a/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts +++ b/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts @@ -33,9 +33,16 @@ export const useOAuthAppRevokeMutation = ({ return useMutation({ mutationFn: (vars) => revokeOAuthApp(vars), async onSuccess(data, variables, context) { - await queryClient.invalidateQueries({ - queryKey: oauthAppsKeys.authorizedApps(variables.slug), - }) + // Revoking an app deletes every row for (app_id, organization_id), both kinds — the + // org's approvals overview, this app's grant list, and any member's own-grants view are + // all stale. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: oauthAppsKeys.approvals(variables.slug) }), + queryClient.invalidateQueries({ + queryKey: oauthAppsKeys.appMemberGrants(variables.slug, variables.appId), + }), + queryClient.invalidateQueries({ queryKey: oauthAppsKeys.grants() }), + ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/oauth-apps/types.test.ts b/apps/studio/data/oauth-apps/types.test.ts index e781ded5dd2..0af62eee06a 100644 --- a/apps/studio/data/oauth-apps/types.test.ts +++ b/apps/studio/data/oauth-apps/types.test.ts @@ -97,7 +97,7 @@ describe('isRoleValidationFailure', () => { isRoleValidationFailure({ error_code: 'role_validation_failed', message: 'nope', - validation: { scope_target: 'organization', role: READ_ONLY_ROLE, failed_scopes: [] }, + validation: { scope_target: 'organization', role: READ_ONLY_ROLE }, }) ).toBe(true) }) @@ -130,11 +130,7 @@ describe('getFailedProjects', () => { getFailedProjects({ error_code: 'role_validation_failed', message: 'nope', - validation: { - scope_target: 'organization', - role: READ_ONLY_ROLE, - failed_scopes: ['database:write'], - }, + validation: { scope_target: 'organization', role: READ_ONLY_ROLE }, }) ).toEqual([]) }) diff --git a/apps/studio/data/oauth-apps/types.ts b/apps/studio/data/oauth-apps/types.ts index cdac5596894..59c05e55565 100644 --- a/apps/studio/data/oauth-apps/types.ts +++ b/apps/studio/data/oauth-apps/types.ts @@ -9,30 +9,20 @@ export type OAuthAppsAuthorizeLiveFields = Pick< 'name' | 'website' | 'domain' | 'icon' | 'redirect_uri' | 'registration_type' | 'expires_at' > -// TODO(rfc): RFC has one OAuthScope type; confirm the vocabulary is unchanged from the live coarse enum. export type OAuthScope = NonNullable[number] -export type OAuthScopeLevel = 'read' | 'write' | 'read_write' - -// TODO(rfc): OAuthScopeGroup is referenced but undefined in the RFC; shape below is our guess and drives the scope badges. -export type OAuthScopeGroup = { - name: string - level: OAuthScopeLevel - scopes: OAuthScope[] +export function isWriteScope(scope: OAuthScope): boolean { + return scope.endsWith(':write') } export type OAuthGrantKind = 'organization_bound' | 'member_bound' -export type OAuthProjectScopingMode = 'off' | 'optional' | 'required' - export type OAuthAppsAuthorizeRequest = OAuthAppsAuthorizeLiveFields & { app_id: string - // TODO(rfc): confirm whether app_name supersedes the live name field. app_name: string grant_kind: OAuthGrantKind - project_scoping_mode: OAuthProjectScopingMode - allow_partial_grants: boolean - scopes: OAuthScopeGroup[] + project_scoping_mode: boolean + scopes: OAuthScope[] } export type OAuthOrganizationRole = { @@ -49,24 +39,14 @@ export type OAuthAppsAuthorizeOrganizationProject = { export type OAuthExistingGrant = { approved_scopes: OAuthScope[] | null - // TODO(rfc): non-nullable here but OAuthGrantItem uses null for "all projects"; confirm how an all-projects grant is represented. project_refs: string[] approved_at: string } -export type OAuthBlockedReason = 'org_requires_project_scoping' | 'app_blocked_for_organization' - export type OAuthOrgAppDetails = { - organization_settings: { require_project_scoping: boolean } - blocked_reason: OAuthBlockedReason | null existing_grant: OAuthExistingGrant | null } -export type OAuthOrgScopeCheck = { - role: OrganizationRole - failed_scopes: OAuthScope[] -} - export function getPreselectedProjectRefs({ existingGrant, projectRef, @@ -86,6 +66,8 @@ export function getPreselectedProjectRefs({ } export type OAuthAuthorizeApproveRequest = { + // not allowed when project_scoping_mode is false. + // when project_scoping_mode is true, if omitted, this means all projects project_refs?: string[] } @@ -96,6 +78,10 @@ export type OAuthScopeValidationResult = | { scope_target: 'organization' role: OrganizationRole + } + | { + scope_target: 'all_projects' + role: OrganizationRole failed_scopes: OAuthScope[] } | { @@ -136,51 +122,49 @@ export function getFailedProjects( return failure.validation.scope_target === 'projects' ? failure.validation.failures : [] } -export type OAuthAppOverviewItem = { +export type OAuthAppsAuthorizePreflightSuccess = { ok: true } + +export type OAuthAppsAuthorizePreflightResult = + | OAuthAppsAuthorizePreflightSuccess + | OAuthAppsAuthorizeRoleValidationFailure + +export function isPreflightValidationFailure( + result: OAuthAppsAuthorizePreflightResult +): result is OAuthAppsAuthorizeRoleValidationFailure { + return 'error_code' in result && result.error_code === 'role_validation_failed' +} + +export type OAuthApprovalItem = { id: string name: string icon: string | null - status: 'active' | 'legacy' - // TODO(rfc): may become string ("50+"); RFC undecided. - member_grant_count: number - last_used_at: string | null org_grant: { grant_id: string approved_scopes: string[] approved_at: string - last_used_at: string | null } | null } -export type ListOAuthAppsOverviewResponse = { - data: OAuthAppOverviewItem[] +export type ListOAuthApprovalsResponse = { + data: OAuthApprovalItem[] pagination: { next_cursor: string | null } } -export type OAuthBlockedAppItem = { - app_id: string +export type OAuthGrantProject = { + ref: string name: string - icon: string | null - blocked_at: string - blocked_by: { gotrue_id: string; email: string } -} - -export type ListBlockedAppsResponse = { - data: OAuthBlockedAppItem[] - pagination: { next_cursor: string | null } } export type OAuthGrantItem = { grant_id: string kind: OAuthGrantKind user: { gotrue_id: string; email: string; avatar_url?: string } | null - project_refs: string[] | null + projects: OAuthGrantProject[] | null approved_scopes: string[] approved_at: string - last_used_at: string | null } -export type ListAppGrantsResponse = { +export type ListOrgAppGrantsResponse = { data: OAuthGrantItem[] pagination: { next_cursor: string | null } } @@ -189,19 +173,12 @@ export type MemberOauthGrantItem = { grant_id: string app: { id: string; name: string; icon: string | null } organization: { slug: string; name: string } - project_refs: string[] | null + projects: OAuthGrantProject[] | null approved_scopes: string[] approved_at: string - last_used_at: string | null - access_affected: boolean - access_affected_reason: 'role_below_granted_scopes' | 'project_access_revoked' | null } export type ListOwnGrantsResponse = { data: MemberOauthGrantItem[] pagination: { next_cursor: string | null } } - -export type OAuthOrganizationSettings = { require_project_scoping: boolean } - -export type OAuthOrganizationSettingsUpdate = { require_project_scoping: boolean }