mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
2657f82ef1
* feat(auth): add contextual OAuth workspace grants * fix(auth): register RFC 9396 detail types * fix(openapi): restore delegated CLI auth profile * fix(store): recover paid capacity fulfillment * fix(auth): close OAuth contract gaps * fix(store): resume verified x402 settlements * chore(deps): update zpan cloud sdk to 2.5.2 * test(store): cover verified settlement recovery * fix(auth): localize standard consent scopes * fix(docker): include dependency patches before install * fix(store): harden x402 purchase responses * test(auth): cover OAuth authorization boundaries * docs: add PR 550 verification screenshots * chore: remove temporary verification screenshots * docs(openapi): document exhausted capacity response
209 lines
6.7 KiB
TypeScript
209 lines
6.7 KiB
TypeScript
import type { AuthorizationScope } from '@shared/authorization'
|
|
import type { Context } from 'hono'
|
|
import { createMiddleware } from 'hono/factory'
|
|
import { recordAuditEffect } from '../lib/audit'
|
|
import { forbidden, unauthorized } from '../usecases/ports'
|
|
import { type AuthzContext, anonymousAuthzContext, type Env, workspaceOrgId } from './platform'
|
|
|
|
const ROLE_LEVELS: Record<string, number> = {
|
|
owner: 3,
|
|
admin: 3,
|
|
editor: 2,
|
|
viewer: 1,
|
|
member: 1,
|
|
}
|
|
|
|
export type TeamRole = 'viewer' | 'editor' | 'owner'
|
|
export type RequiredAuthorizationScopes = readonly [AuthorizationScope, ...AuthorizationScope[]]
|
|
|
|
export type ScopedAuthorizationPolicy = {
|
|
scopes: RequiredAuthorizationScopes
|
|
oauth?: boolean
|
|
minTeamRole?: TeamRole
|
|
siteRole?: 'admin'
|
|
auditDenied?: boolean
|
|
}
|
|
|
|
export type RouteAuthorizationDeclaration = { public: true } | ScopedAuthorizationPolicy
|
|
|
|
export type AuthzDenialReason =
|
|
| 'missing_credential'
|
|
| 'actor_not_allowed'
|
|
| 'missing_scope'
|
|
| 'workspace_required'
|
|
| 'insufficient_role'
|
|
| 'insufficient_site_role'
|
|
|
|
export type AuthzDecision =
|
|
| { allowed: true; effectiveOrgId: string | null; reason: 'allowed' }
|
|
| { allowed: false; status: 401 | 403; reason: AuthzDenialReason; audit: boolean }
|
|
|
|
type AuthzDeps = {
|
|
getMemberRole(orgId: string, userId: string): Promise<string | null>
|
|
findPersonalOrg(userId: string): Promise<string | null>
|
|
}
|
|
|
|
type SessionWithPlugins = {
|
|
user: { id: string; role?: string }
|
|
}
|
|
|
|
export async function evaluateAuthorization(input: {
|
|
context: AuthzContext
|
|
declaration: RouteAuthorizationDeclaration
|
|
deps: AuthzDeps
|
|
}): Promise<AuthzDecision> {
|
|
const { context, declaration, deps } = input
|
|
if ('public' in declaration) return allow(workspaceOrgId(context))
|
|
return evaluateScopedPolicy(context, declaration, deps)
|
|
}
|
|
|
|
async function evaluateScopedPolicy(
|
|
context: AuthzContext,
|
|
policy: ScopedAuthorizationPolicy,
|
|
deps: AuthzDeps,
|
|
): Promise<AuthzDecision> {
|
|
if (context.credential === 'anonymous') return deny(context, 401, 'missing_credential', policy)
|
|
if (policy.oauth === false && context.credential === 'oauth') return deny(context, 403, 'actor_not_allowed', policy)
|
|
|
|
for (const scope of policy.scopes) {
|
|
if (!context.grantedScopes.has(scope)) return deny(context, 403, 'missing_scope', policy)
|
|
}
|
|
|
|
if (policy.siteRole === 'admin' && contextSiteRole(context) !== 'admin') {
|
|
return deny(context, 403, 'insufficient_site_role', policy)
|
|
}
|
|
|
|
return evaluateRole(context, policy.minTeamRole, policy, deps)
|
|
}
|
|
|
|
function contextSiteRole(context: Exclude<AuthzContext, { credential: 'anonymous' }>): string | undefined {
|
|
return 'role' in context.state && typeof context.state.role === 'string' ? context.state.role : undefined
|
|
}
|
|
|
|
async function evaluateRole(
|
|
context: AuthzContext,
|
|
minTeamRole: TeamRole | undefined,
|
|
declaration: RouteAuthorizationDeclaration,
|
|
deps: AuthzDeps,
|
|
): Promise<AuthzDecision> {
|
|
if (!minTeamRole) return allow(workspaceOrgId(context))
|
|
const userId = context.userId
|
|
if (!userId) return deny(context, 401, 'actor_not_allowed', declaration)
|
|
const orgId = workspaceOrgId(context)
|
|
if (!orgId) return deny(context, 401, 'workspace_required', declaration)
|
|
|
|
const role = await deps.getMemberRole(orgId, userId)
|
|
if (role !== null) {
|
|
return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS[minTeamRole]
|
|
? allow(orgId)
|
|
: deny(context, 403, 'insufficient_role', declaration)
|
|
}
|
|
|
|
const personalOrgId = await deps.findPersonalOrg(userId)
|
|
return personalOrgId === orgId ? allow(orgId) : deny(context, 403, 'insufficient_role', declaration)
|
|
}
|
|
|
|
export function authorize(declaration: RouteAuthorizationDeclaration) {
|
|
return createMiddleware<Env>(async (c, next) => {
|
|
if ('public' in declaration) {
|
|
await next()
|
|
return
|
|
}
|
|
const context = await currentAuthorizationContext(c, declaration)
|
|
const decision = await evaluateAuthorization({
|
|
context,
|
|
declaration,
|
|
deps: {
|
|
getMemberRole: (orgId, userId) => c.get('deps').org.getMemberRole(orgId, userId),
|
|
findPersonalOrg: (userId) => c.get('deps').org.findPersonalOrg(userId),
|
|
},
|
|
})
|
|
if (decision.allowed) {
|
|
if (decision.effectiveOrgId) c.set('orgId', decision.effectiveOrgId)
|
|
await next()
|
|
return
|
|
}
|
|
if (decision.audit) {
|
|
await recordAuditEffect('authorization_denied', () => recordDenialAudit(c, decision.reason))
|
|
}
|
|
if (decision.status === 401) throw unauthorized('Unauthorized')
|
|
throw forbidden('Forbidden')
|
|
})
|
|
}
|
|
|
|
async function currentAuthorizationContext(
|
|
c: Context<Env>,
|
|
declaration: RouteAuthorizationDeclaration,
|
|
): Promise<AuthzContext> {
|
|
const context = c.get('authzContext')
|
|
if (context.credential !== 'session' || isSafeMethod(c.req.method) || !containsSiteAdmin(declaration)) {
|
|
return context
|
|
}
|
|
|
|
const freshSession = (await c.get('auth').api.getSession({
|
|
headers: c.req.raw.headers,
|
|
query: { disableCookieCache: true },
|
|
})) as SessionWithPlugins | null
|
|
if (!freshSession?.user?.id) return anonymousAuthzContext()
|
|
return {
|
|
...context,
|
|
userId: freshSession.user.id,
|
|
actor: { type: 'user', ref: freshSession.user.id },
|
|
state: { firstParty: true, role: freshSession.user.role },
|
|
}
|
|
}
|
|
|
|
function containsSiteAdmin(declaration: RouteAuthorizationDeclaration): boolean {
|
|
if ('public' in declaration) return false
|
|
return declaration.siteRole === 'admin'
|
|
}
|
|
|
|
function isSafeMethod(method: string): boolean {
|
|
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
|
|
}
|
|
|
|
function allow(effectiveOrgId: string | null): AuthzDecision {
|
|
return { allowed: true, effectiveOrgId, reason: 'allowed' }
|
|
}
|
|
|
|
function deny(
|
|
context: AuthzContext,
|
|
status: 401 | 403,
|
|
reason: AuthzDenialReason,
|
|
declaration: RouteAuthorizationDeclaration,
|
|
): AuthzDecision {
|
|
return {
|
|
allowed: false,
|
|
status,
|
|
reason,
|
|
audit: status === 403 && context.credential !== 'anonymous' && shouldAudit(declaration),
|
|
}
|
|
}
|
|
|
|
function shouldAudit(declaration: RouteAuthorizationDeclaration): boolean {
|
|
if ('public' in declaration) return false
|
|
return declaration.auditDenied !== false
|
|
}
|
|
|
|
async function recordDenialAudit(c: Context<Env>, reason: AuthzDenialReason) {
|
|
const context = c.get('authzContext')
|
|
if (!context.actor) return
|
|
const orgId = workspaceOrgId(context) ?? c.get('orgId')
|
|
if (!orgId) return
|
|
await c.get('deps').audit.record({
|
|
orgId,
|
|
userId: context.userId,
|
|
actorType: context.actor.type,
|
|
actorRef: context.actor.ref,
|
|
actorIssuer: 'issuer' in context.actor ? context.actor.issuer : null,
|
|
action: 'authorization_denied',
|
|
targetType: 'route',
|
|
targetName: 'scoped route',
|
|
metadata: {
|
|
method: c.req.method.toUpperCase(),
|
|
credential: context.credential,
|
|
reason,
|
|
},
|
|
})
|
|
}
|