fix(credentials): authorize shared credentials without requiring a workflow (#6571)

`authorizeCredentialUse` could only reach its member-based sharing branch when a
`workflowId` was supplied, so non-workflow surfaces — knowledge base connectors,
credential management — fell through to an owner-only path and rejected everyone
but the user who ran the OAuth flow.

A workflow now only pins which workspace a legacy account id resolves through; it
never grants access on its own. Access itself is decided by one rule everywhere:
active credential member, or derived credential admin.

- resolve legacy account ids through whichever workspace credential rows the
  caller can reach, instead of an owner-only fallback
- extract `canUseCredential` and replace the predicate hand-inlined at five sites
- reuse `resolveCredentialTokenIdentity` for owner resolution instead of a second
  local copy of the same invariant
- keep the workflow-pinned path from crossing a workspace boundary
This commit is contained in:
Waleed
2026-08-11 16:36:15 -07:00
committed by GitHub
parent be5db68644
commit 3595fa2b6d
9 changed files with 383 additions and 192 deletions
@@ -9,7 +9,7 @@ import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import {
getCanonicalScopesForProvider,
@@ -155,7 +155,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!workflowId) {
const access = await getCredentialActorContext(platformCredential.id, requesterUserId)
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
if (!canUseCredential(access)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
@@ -187,7 +187,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
} else {
const access = await getCredentialActorContext(platformCredential.id, requesterUserId)
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
if (!canUseCredential(access)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
+6 -2
View File
@@ -4,7 +4,11 @@ import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentia
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access'
import {
type CredentialActorContext,
canUseCredential,
getCredentialActorContext,
} from '@/lib/credentials/access'
import {
isProviderOutageCode,
performDeleteCredential,
@@ -49,7 +53,7 @@ export const GET = withRouteHandler(
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
if (!canUseCredential(access)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
@@ -11,6 +11,8 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
vi.mock('@/lib/credentials/access', () => ({
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential-id',
@@ -17,6 +17,8 @@ vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/lib/credentials/access', () => ({
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential',
@@ -12,6 +12,8 @@ const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTo
vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: mockGetCredentialActorContext,
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
}))
vi.mock('@/lib/oauth/credential-service', () => ({
getServiceAccountToken: mockGetServiceAccountToken,
+2 -2
View File
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access'
import { getServiceAccountToken, refreshTokenIfNeeded } from '@/lib/oauth/credential-service'
const logger = createLogger('VertexCredential')
@@ -48,7 +48,7 @@ export async function resolveVertexCredential({
})
throw new Error('Credential is not accessible from this workflow workspace')
}
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
if (!canUseCredential(access)) {
throw new Error('Not authorized to use this Vertex AI credential')
}
+238
View File
@@ -0,0 +1,238 @@
/**
* @vitest-environment node
*/
import { account, credential, credentialMember, workflow } from '@sim/db/schema'
import { createMockRequest, queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckSessionOrInternalAuth, mockResolveWorkspaceAccess, mockGetUserEntityPermissions } =
vi.hoisted(() => ({
mockCheckSessionOrInternalAuth: vi.fn(),
mockResolveWorkspaceAccess: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
checkWorkspaceAccess: mockResolveWorkspaceAccess,
getUserEntityPermissions: mockGetUserEntityPermissions,
resolveWorkspaceAccess: mockResolveWorkspaceAccess,
}))
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
afterAll(resetDbChainMock)
const OWNER = 'owner-user'
const WORKSPACE = 'ws-1'
const ACCOUNT_ID = 'acct-1'
const workspaceAdmin = { hasAccess: true, canWrite: true, canAdmin: true }
const workspaceWriter = { hasAccess: true, canWrite: true, canAdmin: false }
const noWorkspaceAccess = { hasAccess: false, canWrite: false, canAdmin: false }
const platformCredential = {
id: 'cred-1',
workspaceId: WORKSPACE,
type: 'oauth',
accountId: ACCOUNT_ID,
}
function actAs(userId: string) {
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId, authType: 'session' })
}
/** The rows `getCredentialActorContext` reads: the credential, then the caller's membership. */
function queueActorContext(
credentialRow: Record<string, unknown>,
membership: { role: string }[] = []
) {
queueTableRows(credential, [credentialRow])
queueTableRows(credentialMember, membership)
}
/** The rows `resolveCredentialTokenIdentity` reads: the credential, then its account. */
function queueTokenIdentity(
credentialRow: Record<string, unknown> | null,
ownerUserId: string | null
) {
queueTableRows(credential, credentialRow ? [credentialRow] : [])
queueTableRows(account, ownerUserId ? [{ userId: ownerUserId }] : [])
}
function authorize(credentialId: string, workflowId?: string) {
return authorizeCredentialUse(createMockRequest('POST'), { credentialId, workflowId })
}
describe('authorizeCredentialUse', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
actAs('acting-user')
mockGetUserEntityPermissions.mockResolvedValue('admin')
mockResolveWorkspaceAccess.mockResolvedValue(workspaceWriter)
})
describe('workspace-scoped credentials, without a workflow', () => {
it('authorizes a workspace admin who did not run the OAuth flow', async () => {
queueActorContext(platformCredential)
queueTokenIdentity(platformCredential, OWNER)
mockResolveWorkspaceAccess.mockResolvedValue(workspaceAdmin)
const result = await authorize('cred-1')
expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
expect(result.resolvedCredentialId).toBe(ACCOUNT_ID)
expect(result.workspaceId).toBe(WORKSPACE)
})
it('authorizes an active credential member who did not run the OAuth flow', async () => {
queueActorContext(platformCredential, [{ role: 'member' }])
queueTokenIdentity(platformCredential, OWNER)
const result = await authorize('cred-1')
expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
})
it('rejects a workspace member who is not a credential member', async () => {
queueActorContext(platformCredential)
const result = await authorize('cred-1')
expect(result.ok).toBe(false)
expect(result.error).toContain('add you as a member')
})
it('rejects a caller who has lost access to the credential workspace', async () => {
queueActorContext(platformCredential)
mockResolveWorkspaceAccess.mockResolvedValue(noWorkspaceAccess)
const result = await authorize('cred-1')
expect(result.ok).toBe(false)
expect(result.error).toBe('You do not have access to this workspace.')
})
it('rejects when the credential owner has lost access to the workspace', async () => {
queueActorContext(platformCredential)
queueTokenIdentity(platformCredential, OWNER)
mockResolveWorkspaceAccess.mockResolvedValue(workspaceAdmin)
mockGetUserEntityPermissions.mockResolvedValue(null)
const result = await authorize('cred-1')
expect(result.ok).toBe(false)
expect(result.error).toBe('Unauthorized')
})
})
describe('workflow scope', () => {
it('rejects a credential belonging to another workspace', async () => {
queueTableRows(workflow, [{ workspaceId: 'other-ws' }])
queueActorContext(platformCredential)
const result = await authorize('cred-1', 'wf-1')
expect(result.ok).toBe(false)
expect(result.error).toBe('Credential is not accessible from this workflow workspace')
})
})
describe('legacy account ids', () => {
const sharedRow = { id: 'cred-1', workspaceId: WORKSPACE, type: 'oauth' }
it('resolves through an accessible workspace credential without a workflow', async () => {
queueTableRows(credential, []) // platform lookup miss
queueTableRows(credential, [sharedRow]) // shared rows wrapping the account
queueActorContext(sharedRow)
queueTokenIdentity(null, OWNER)
mockResolveWorkspaceAccess.mockResolvedValue(workspaceAdmin)
const result = await authorize(ACCOUNT_ID)
expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
expect(result.workspaceId).toBe(WORKSPACE)
expect(result.resolvedCredentialId).toBe(ACCOUNT_ID)
})
it('rejects when no workspace credential is reachable by the caller', async () => {
queueTableRows(credential, [])
queueTableRows(credential, [sharedRow])
queueActorContext(sharedRow)
queueTableRows(account, [{ userId: OWNER }])
const result = await authorize(ACCOUNT_ID)
expect(result.ok).toBe(false)
expect(result.error).toContain('add you as a member')
})
it('still authorizes the owner when a shared row rejects them', async () => {
actAs(OWNER)
queueTableRows(credential, [])
queueTableRows(credential, [{ id: 'cred-1', workspaceId: 'other-ws', type: 'oauth' }])
queueActorContext({ id: 'cred-1', workspaceId: 'other-ws', type: 'oauth' })
queueTableRows(account, [{ userId: OWNER }])
const result = await authorize(ACCOUNT_ID)
expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
})
it('does not fall back to the owner path when a workflow pins the workspace', async () => {
actAs(OWNER)
queueTableRows(workflow, [{ workspaceId: WORKSPACE }])
queueTableRows(credential, [])
queueTableRows(credential, [])
queueTableRows(account, [{ userId: OWNER }])
const result = await authorize(ACCOUNT_ID, 'wf-1')
expect(result.ok).toBe(false)
expect(result.error).toBe('Credential not found')
})
it('keeps an unshared account private to its owner', async () => {
queueTableRows(credential, [])
queueTableRows(credential, [])
queueTableRows(account, [{ userId: OWNER }])
const result = await authorize(ACCOUNT_ID)
expect(result.ok).toBe(false)
expect(result.error).toBe('Unauthorized')
})
it('authorizes the owner of an unshared account', async () => {
actAs(OWNER)
queueTableRows(credential, [])
queueTableRows(credential, [])
queueTableRows(account, [{ userId: OWNER }])
const result = await authorize(ACCOUNT_ID)
expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
})
it('reports an unknown credential id', async () => {
queueTableRows(credential, [])
queueTableRows(credential, [])
queueTableRows(account, [])
const result = await authorize('nope')
expect(result.ok).toBe(false)
expect(result.error).toBe('Credential not found')
})
})
})
+116 -185
View File
@@ -1,9 +1,14 @@
import { db } from '@sim/db'
import { account, credential, credentialMember, workflow as workflowTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { account, credential, workflow as workflowTable } from '@sim/db/schema'
import { and, asc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
type CredentialActorContext,
canUseCredential,
getCredentialActorContext,
resolveCredentialTokenIdentity,
} from '@/lib/credentials/access'
export interface CredentialAccessResult {
ok: boolean
@@ -16,11 +21,34 @@ export interface CredentialAccessResult {
credentialType?: 'oauth' | 'service_account'
}
const NO_CREDENTIAL_ACCESS =
'You do not have access to this credential. Ask the credential admin to add you as a member.'
const NO_WORKSPACE_ACCESS = 'You do not have access to this workspace.'
/**
* Maps the canonical use rule (`canUseCredential`) onto the actionable message each
* denial deserves, so every surface authorizing a credential applies one predicate
* and only the wording is local to this module.
*/
function credentialAccessError(access: CredentialActorContext): string | null {
if (!access.credential) return 'Credential not found'
if (!access.hasWorkspaceAccess) return NO_WORKSPACE_ACCESS
if (!canUseCredential(access)) return NO_CREDENTIAL_ACCESS
return null
}
/**
* Centralizes auth + credential membership checks for OAuth usage.
* - Workspace-scoped credential IDs enforce active credential_member access.
* - Legacy account IDs are resolved to workspace-scoped credentials when workflowId is provided.
* - Direct legacy account-ID access without workflowId is restricted to account owners only.
*
* Every workspace-scoped credential whether addressed by its `credential.id` or
* by the legacy `account.id` it wraps — resolves to the same rule: active credential
* membership, or derived credential admin. A `workflowId`, when supplied, only pins
* which workspace a legacy account id is resolved through; it never grants access on
* its own, so surfaces without a workflow (knowledge base connectors, credential
* management) authorize identically to workflow surfaces.
*
* Raw account ids that belong to no workspace credential at all remain private to
* their owner.
*/
export async function authorizeCredentialUse(
request: NextRequest,
@@ -49,69 +77,41 @@ export async function authorizeCredentialUse(
}
const actingUserId = auth.userId
const authType = auth.authType as CredentialAccessResult['authType']
const [workflowContext] = workflowId
? await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
: [null]
const [workflowRows, platformAccess] = await Promise.all([
workflowId
? db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
: Promise.resolve([]),
getCredentialActorContext(credentialId, actingUserId),
])
if (workflowId && (!workflowContext || !workflowContext.workspaceId)) {
const workflowContext = workflowRows[0] ?? null
if (workflowId && !workflowContext?.workspaceId) {
return { ok: false, error: 'Workflow not found' }
}
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
accountId: credential.accountId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
const scopeWorkspaceId = workflowContext?.workspaceId ?? null
const platformCredential = platformAccess.credential
if (platformCredential) {
if (scopeWorkspaceId && scopeWorkspaceId !== platformCredential.workspaceId) {
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
}
const accessError = credentialAccessError(platformAccess)
if (accessError) return { ok: false, error: accessError }
if (platformCredential.type === 'service_account') {
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
}
const requesterPerm = await getUserEntityPermissions(
actingUserId,
'workspace',
platformCredential.workspaceId
)
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, actingUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (requesterPerm === null) {
return { ok: false, error: 'You do not have access to this workspace.' }
}
if (!membership && requesterPerm !== 'admin') {
return {
ok: false,
error:
'You do not have access to this credential. Ask the credential admin to add you as a member.',
}
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
authType,
requesterUserId: actingUserId,
credentialOwnerUserId: actingUserId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.id,
@@ -123,149 +123,80 @@ export async function authorizeCredentialUse(
return { ok: false, error: 'Unsupported credential type for OAuth access' }
}
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
}
const [accountRow] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, platformCredential.accountId))
.limit(1)
if (!accountRow) {
return { ok: false, error: 'Credential account not found' }
}
const requesterPerm = await getUserEntityPermissions(
actingUserId,
'workspace',
const identity = await resolveCredentialTokenIdentity(
platformCredential.id,
platformCredential.workspaceId
)
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, actingUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (requesterPerm === null) {
return {
ok: false,
error: 'You do not have access to this workspace.',
}
}
if (!membership && requesterPerm !== 'admin') {
return {
ok: false,
error: `You do not have access to this credential. Ask the credential admin to add you as a member.`,
}
}
const ownerPerm = await getUserEntityPermissions(
accountRow.userId,
'workspace',
platformCredential.workspaceId
)
if (ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
}
if (identity?.kind !== 'oauth') return { ok: false, error: 'Unauthorized' }
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: accountRow.userId,
authType,
requesterUserId: actingUserId,
credentialOwnerUserId: identity.userId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.accountId,
credentialType: 'oauth',
}
}
if (workflowContext?.workspaceId) {
const [workspaceCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
accountId: credential.accountId,
})
.from(credential)
.where(
and(
eq(credential.type, 'oauth'),
eq(credential.workspaceId, workflowContext.workspaceId),
eq(credential.accountId, credentialId)
)
/**
* Credentials predating the workspace-scoped `credential` table are addressed by
* raw account id. Each workspace that shares the account has its own credential
* row wrapping it, so authorization runs against the rows the caller can reach —
* pinned to the workflow's workspace when one was supplied.
*/
const workspaceCredentials = await db
.select({ id: credential.id, workspaceId: credential.workspaceId })
.from(credential)
.where(
and(
eq(credential.type, 'oauth'),
eq(credential.accountId, credentialId),
scopeWorkspaceId ? eq(credential.workspaceId, scopeWorkspaceId) : undefined
)
.limit(1)
if (!workspaceCredential?.accountId) {
return { ok: false, error: 'Credential not found' }
}
const [accountRow] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, workspaceCredential.accountId))
.limit(1)
if (!accountRow) {
return { ok: false, error: 'Credential account not found' }
}
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, workspaceCredential.id),
eq(credentialMember.userId, actingUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
const requesterPerm = await getUserEntityPermissions(
actingUserId,
'workspace',
workflowContext.workspaceId
)
if (requesterPerm !== 'admin') {
return {
ok: false,
error:
'You do not have access to this credential. Ask the credential admin to add you as a member.',
}
}
}
const ownerPerm = await getUserEntityPermissions(
accountRow.userId,
'workspace',
workflowContext.workspaceId
)
if (ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
.orderBy(asc(credential.createdAt))
let firstRejection: string | null = null
for (const workspaceCredential of workspaceCredentials) {
const accessError = credentialAccessError(
await getCredentialActorContext(workspaceCredential.id, actingUserId)
)
if (accessError) {
firstRejection ??= accessError
continue
}
const identity = await resolveCredentialTokenIdentity(
credentialId,
workspaceCredential.workspaceId
)
if (identity?.kind !== 'oauth') {
firstRejection ??= 'Unauthorized'
continue
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: accountRow.userId,
workspaceId: workflowContext.workspaceId,
resolvedCredentialId: workspaceCredential.accountId,
authType,
requesterUserId: actingUserId,
credentialOwnerUserId: identity.userId,
workspaceId: workspaceCredential.workspaceId,
resolvedCredentialId: credentialId,
credentialType: 'oauth',
}
}
/**
* A workflow pins the credential to that workflow's workspace, so an account that
* resolves to no reachable credential row there is out of scope — it must not fall
* through to the owner-only path and cross the workspace boundary.
*/
if (scopeWorkspaceId) {
return { ok: false, error: firstRejection ?? 'Credential not found' }
}
const [legacyAccount] = await db
.select({ userId: account.userId })
.from(account)
@@ -280,14 +211,14 @@ export async function authorizeCredentialUse(
return { ok: false, error: 'workflowId is required' }
}
if (auth.userId !== legacyAccount.userId) {
return { ok: false, error: 'Unauthorized' }
if (actingUserId !== legacyAccount.userId) {
return { ok: false, error: firstRejection ?? 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
authType,
requesterUserId: actingUserId,
credentialOwnerUserId: legacyAccount.userId,
resolvedCredentialId: credentialId,
credentialType: 'oauth',
+12
View File
@@ -112,6 +112,18 @@ export interface CredentialActorContext {
isAdmin: boolean
}
/**
* Whether a user may *use* a credential: they still have access to its workspace
* and are either an active member or a derived credential admin.
*
* Deliberately distinct from the admin-only rule that governs *managing* a
* credential (rename, delete, membership changes) — that one has no member
* fallback. Do not fold the two together.
*/
export function canUseCredential(access: CredentialActorContext): boolean {
return access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin)
}
/**
* Resolves user access context for a credential. Pass `workspaceAccess` when the
* caller has already resolved access for the credential's workspace to skip a