diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index c649f0b93..8f1880493 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -4,8 +4,18 @@ export type { BearerAuthSession, CookieAuthSession, CustomAuthSession, + AccessRecord, AccessQuery, AccessDecision, + AccessJsonPrimitive, + AccessJsonObject, + AccessJsonArray, + AccessJsonValue, + KnownAccessReason, + AccessReason, + AccessConstraint, + AccessMeta, + AccessEvaluation, AuthLoginInput, AuthCallbackInput, AuthCheckResult, @@ -13,11 +23,13 @@ export type { AuthTransitionNone, AuthTransition, AuthPermissions, + AuthIdentity, AuthTransportRequest, AuthTransportResponse, AuthTransportAdapter, AuthTransportErrorEvent, AuthCapabilities, + AuthCapabilitiesResolved, AuthState, AuthStateEvent, AuthStateListener, diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 6564b98a3..f413854b8 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -47,7 +47,10 @@ export interface AuthContext { // Access control // --------------------------------------------------------------------------- -export interface AccessQuery = Record> { +/** Scalar-keyed record attached to an access query. */ +export type AccessRecord = Record; + +export interface AccessQuery { action: string; resource: string; record?: TRecord; @@ -55,6 +58,51 @@ export interface AccessQuery; + +/** Recursive JSON-compatible value for extensible constraints/meta fields. */ +export type AccessJsonValue = + | AccessJsonPrimitive + | AccessJsonArray + | AccessJsonObject; + +/** Common reason hints used by framework fallbacks and common providers. */ +export type KnownAccessReason = + | 'allowed' + | 'denied' + | 'unauthenticated' + | 'policy_not_found' + | 'transport_error' + | 'timeout' + | 'aborted' + | 'unsupported' + | 'malformed' + | 'provider_error'; + +/** Provider-defined reason string (framework-known values + custom PDP reason codes). */ +export type AccessReason = KnownAccessReason | (string & {}); + +/** Provider-defined constraint entry (typed passthrough object). */ +export type AccessConstraint = Readonly>; + +/** Provider-defined evaluation metadata (typed passthrough object). */ +export type AccessMeta = Readonly>; + +/** Full evaluation result. evaluateAccess() resolves to this; canAccess() maps to decision only. */ +export interface AccessEvaluation { + decision: AccessDecision; + constraints?: ReadonlyArray; + reason?: AccessReason; + meta?: AccessMeta; +} + // --------------------------------------------------------------------------- // Inputs // --------------------------------------------------------------------------- @@ -108,6 +156,18 @@ export interface AuthPermissions { permissions?: string[]; } +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +/** Subject identity derived from OIDC ID token / userinfo (UX hint only). */ +export interface AuthIdentity { + /** Subject identifier (sub claim). */ + sub: string; + /** Raw claims from ID token / userinfo endpoint. */ + claims?: Readonly>; +} + // --------------------------------------------------------------------------- // Transport adapter (interfaces only — for future @cyberfabric/api binding) // --------------------------------------------------------------------------- @@ -140,6 +200,7 @@ export interface AuthTransportErrorEvent { // Capabilities (optional metadata) // --------------------------------------------------------------------------- +/** Advisory capability hints declared by the provider (advisory only; runtime probes methods). */ export interface AuthCapabilities { canLogin?: boolean; canRefresh?: boolean; @@ -147,6 +208,22 @@ export interface AuthCapabilities { canCallback?: boolean; supportsPermissions?: boolean; supportsCanAccess?: boolean; + supportsGetIdentity?: boolean; + supportsCanAccessMany?: boolean; + supportsEvaluateAccess?: boolean; + supportsEvaluateMany?: boolean; +} + +/** Resolved capability flags built by the framework at provider-attach time (method presence probe). */ +export interface AuthCapabilitiesResolved { + readonly hasGetIdentity: boolean; + readonly hasGetPermissions: boolean; + readonly hasCanAccess: boolean; + readonly hasCanAccessMany: boolean; + readonly hasEvaluateAccess: boolean; + readonly hasEvaluateMany: boolean; + readonly hasRefresh: boolean; + readonly hasSubscribe: boolean; } // --------------------------------------------------------------------------- @@ -180,12 +257,21 @@ export interface AuthProvider { refresh?(ctx?: AuthContext): Promise; destroy?(): void | Promise; - // --- Optional permissions --- + // --- Optional identity & permissions --- + getIdentity?(ctx?: AuthContext): Promise; getPermissions?(ctx?: AuthContext): Promise; - canAccess? = Record>( + + // --- Optional access control --- + canAccess?( query: AccessQuery, ctx?: AuthContext, ): Promise; + canAccessMany?(queries: ReadonlyArray, ctx?: AuthContext): Promise>; + evaluateAccess?( + query: AccessQuery, + ctx?: AuthContext, + ): Promise; + evaluateMany?(queries: ReadonlyArray, ctx?: AuthContext): Promise>; // --- Optional events --- onTransportError?(event: AuthTransportErrorEvent): void; diff --git a/packages/framework/src/__tests__/auth.test.ts b/packages/framework/src/__tests__/auth.test.ts index b352263d1..13dcbd266 100644 --- a/packages/framework/src/__tests__/auth.test.ts +++ b/packages/framework/src/__tests__/auth.test.ts @@ -18,7 +18,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { apiRegistry, RestProtocol } from '@cyberfabric/api'; import { createStore } from '@cyberfabric/state'; import type { RestPlugin, RestRequestContext } from '@cyberfabric/api'; -import type { AuthProvider, AuthSession } from '@cyberfabric/auth'; +import type { AccessEvaluation, AuthProvider, AuthSession } from '@cyberfabric/auth'; import { createHAI3 } from '../createHAI3'; import { auth, hai3ApiTransport } from '../plugins/auth'; import type { AuthTransportBinder } from '../plugins/auth'; @@ -632,4 +632,725 @@ describe('auth plugin', () => { expect(refreshFn).toHaveBeenCalledTimes(1); }); }); + + // ------------------------------------------------------------------------- + // 15. Capability probe via app.auth runtime + // ------------------------------------------------------------------------- + describe('auth capabilities', () => { + it('sets all flags false for a minimal provider', () => { + const app = createHAI3().use(auth({ provider: makeNullSessionProvider() })).build(); + + expect(app.auth?.capabilities.hasCanAccess).toBe(false); + expect(app.auth?.capabilities.hasCanAccessMany).toBe(false); + expect(app.auth?.capabilities.hasEvaluateAccess).toBe(false); + expect(app.auth?.capabilities.hasEvaluateMany).toBe(false); + expect(app.auth?.capabilities.hasGetIdentity).toBe(false); + expect(app.auth?.capabilities.hasGetPermissions).toBe(false); + expect(app.auth?.capabilities.hasRefresh).toBe(false); + expect(app.auth?.capabilities.hasSubscribe).toBe(false); + + app.destroy(); + }); + + it('sets hasCanAccess=true when provider implements canAccess', () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: vi.fn().mockResolvedValue('allow'), + }; + const app = createHAI3().use(auth({ provider })).build(); + + expect(app.auth?.capabilities.hasCanAccess).toBe(true); + app.destroy(); + }); + + it('sets individual flags independently', () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + refresh: vi.fn().mockResolvedValue(null), + subscribe: vi.fn().mockReturnValue(vi.fn()), + getPermissions: vi.fn().mockResolvedValue({ roles: [] }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + expect(app.auth?.capabilities.hasRefresh).toBe(true); + expect(app.auth?.capabilities.hasSubscribe).toBe(true); + expect(app.auth?.capabilities.hasGetPermissions).toBe(true); + expect(app.auth?.capabilities.hasCanAccess).toBe(false); + expect(app.auth?.capabilities.hasGetIdentity).toBe(false); + app.destroy(); + }); + + it('app.auth.capabilities reflects probe result', () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: vi.fn().mockResolvedValue('allow'), + evaluateAccess: vi.fn().mockResolvedValue({ decision: 'allow' }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + expect(app.auth?.capabilities.hasCanAccess).toBe(true); + expect(app.auth?.capabilities.hasEvaluateAccess).toBe(true); + expect(app.auth?.capabilities.hasCanAccessMany).toBe(false); + expect(app.auth?.capabilities.hasEvaluateMany).toBe(false); + + app.destroy(); + }); + }); + + // ------------------------------------------------------------------------- + // 16. Fail-closed canAccess + // ------------------------------------------------------------------------- + describe('fail-closed canAccess', () => { + it('returns allow when provider resolves allow and session exists', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: vi.fn().mockResolvedValue('allow'), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const decision = await app.auth!.canAccess({ action: 'read', resource: 'doc' }); + + expect(decision).toBe('allow'); + app.destroy(); + }); + + it('returns deny when provider has no canAccess', async () => { + const provider = makeBearerProvider('tok'); // no canAccess + const app = createHAI3().use(auth({ provider })).build(); + + const decision = await app.auth!.canAccess({ action: 'read', resource: 'doc' }); + + expect(decision).toBe('deny'); + app.destroy(); + }); + + it('returns deny when session is null', async () => { + const provider: AuthProvider = { + ...makeNullSessionProvider(), + canAccess: vi.fn().mockResolvedValue('allow'), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const decision = await app.auth!.canAccess({ action: 'read', resource: 'doc' }); + + expect(decision).toBe('deny'); + expect(provider.canAccess).not.toHaveBeenCalled(); + app.destroy(); + }); + + it('returns deny when getSession throws', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + getSession: vi.fn().mockRejectedValue(new Error('session error')), + canAccess: vi.fn().mockResolvedValue('allow'), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const decision = await app.auth!.canAccess({ action: 'read', resource: 'doc' }); + + expect(decision).toBe('deny'); + expect(provider.canAccess).not.toHaveBeenCalled(); + app.destroy(); + }); + + it('returns deny when provider.canAccess throws', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: vi.fn().mockRejectedValue(new Error('pdp error')), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const decision = await app.auth!.canAccess({ action: 'read', resource: 'doc' }); + + expect(decision).toBe('deny'); + app.destroy(); + }); + + it('returns deny when provider.canAccess resolves malformed decision', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: vi.fn().mockResolvedValue('maybe' as unknown as 'allow' | 'deny'), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const decision = await app.auth!.canAccess({ action: 'read', resource: 'doc' }); + + expect(decision).toBe('deny'); + app.destroy(); + }); + + it('returns deny for malformed query (missing action)', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: vi.fn().mockResolvedValue('allow'), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const decision = await app.auth!.canAccess({ action: '', resource: 'doc' }); + + expect(decision).toBe('deny'); + expect(provider.canAccess).not.toHaveBeenCalled(); + app.destroy(); + }); + + it('returns deny for malformed query (missing resource)', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: vi.fn().mockResolvedValue('allow'), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const decision = await app.auth!.canAccess({ action: 'read', resource: '' }); + + expect(decision).toBe('deny'); + expect(provider.canAccess).not.toHaveBeenCalled(); + app.destroy(); + }); + + it('does not throw when provider.canAccess rejects', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: vi.fn().mockRejectedValue(new Error('unexpected')), + }; + const app = createHAI3().use(auth({ provider })).build(); + + await expect(app.auth!.canAccess({ action: 'read', resource: 'doc' })).resolves.toBe('deny'); + app.destroy(); + }); + }); + + // ------------------------------------------------------------------------- + // 17. Fail-closed evaluateAccess + // ------------------------------------------------------------------------- + describe('fail-closed evaluateAccess', () => { + it('returns allow evaluation when provider resolves allow and session exists', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockResolvedValue({ decision: 'allow', reason: 'allowed' }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result.decision).toBe('allow'); + app.destroy(); + }); + + it('returns deny+unsupported when provider lacks evaluateAccess', async () => { + const provider = makeBearerProvider('tok'); // no evaluateAccess + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('unsupported'); + app.destroy(); + }); + + it('returns deny+unauthenticated when session is null', async () => { + const provider: AuthProvider = { + ...makeNullSessionProvider(), + evaluateAccess: vi.fn().mockResolvedValue({ decision: 'allow' }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('unauthenticated'); + expect(provider.evaluateAccess).not.toHaveBeenCalled(); + app.destroy(); + }); + + it('returns deny+unauthenticated when getSession throws', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + getSession: vi.fn().mockRejectedValue(new Error('session error')), + evaluateAccess: vi.fn().mockResolvedValue({ decision: 'allow' }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('unauthenticated'); + app.destroy(); + }); + + it('returns deny+provider_error when provider.evaluateAccess throws', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockRejectedValue(new Error('pdp error')), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('provider_error'); + app.destroy(); + }); + + it('returns deny+provider_error when provider.evaluateAccess resolves malformed payload', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockResolvedValue({} as unknown as { decision: 'allow' | 'deny' }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('provider_error'); + app.destroy(); + }); + + it('returns deny+provider_error when provider.evaluateAccess resolves malformed constraints', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockResolvedValue({ + decision: 'allow', + constraints: { field: 'tenantId', op: 'eq', value: 'acme' }, + } as AccessEvaluation), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result).toEqual({ decision: 'deny', reason: 'provider_error' }); + app.destroy(); + }); + + it('returns deny+provider_error when provider.evaluateAccess resolves malformed reason shape', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockResolvedValue({ + decision: 'allow', + reason: { code: 'wrong_reason' }, + } as AccessEvaluation), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result).toEqual({ decision: 'deny', reason: 'provider_error' }); + app.destroy(); + }); + + it('returns deny+provider_error when provider.evaluateAccess resolves malformed meta shape', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockResolvedValue({ + decision: 'allow', + meta: ['policy-meta'], + } as AccessEvaluation), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }); + + expect(result).toEqual({ decision: 'deny', reason: 'provider_error' }); + app.destroy(); + }); + + it('passes through provider-defined reason and nested constraints/meta payloads', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockResolvedValue({ + decision: 'deny', + reason: 'tenant_scope_conflict', + constraints: [ + { + predicate: { + field: 'tenant.id', + op: 'custom_eq', + value: 'tenant-1', + }, + }, + ], + meta: { + source: 'pdp', + audit: { + policyId: 'p-1', + }, + }, + } as AccessEvaluation), + }; + const app = createHAI3().use(auth({ provider })).build(); + + await expect( + app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }), + ).resolves.toEqual({ + decision: 'deny', + reason: 'tenant_scope_conflict', + constraints: [ + { + predicate: { + field: 'tenant.id', + op: 'custom_eq', + value: 'tenant-1', + }, + }, + ], + meta: { + source: 'pdp', + audit: { + policyId: 'p-1', + }, + }, + }); + app.destroy(); + }); + + it('returns deny+malformed for missing action', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockResolvedValue({ decision: 'allow' }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess({ action: '', resource: 'doc' }); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('malformed'); + expect(provider.evaluateAccess).not.toHaveBeenCalled(); + app.destroy(); + }); + + it('returns deny+aborted when signal is aborted on provider throw', async () => { + const controller = new AbortController(); + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockImplementation(() => { + controller.abort(); + return Promise.reject(new Error('aborted')); + }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess( + { action: 'read', resource: 'doc' }, + { signal: controller.signal }, + ); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('aborted'); + app.destroy(); + }); + + it('returns deny+timeout when signal is aborted with TimeoutError', async () => { + const controller = new AbortController(); + const timeoutErr = Object.assign(new Error('Timeout'), { name: 'TimeoutError' }); + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockImplementation(() => { + controller.abort(timeoutErr); + return Promise.reject(timeoutErr); + }), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const result = await app.auth!.evaluateAccess( + { action: 'read', resource: 'doc' }, + { signal: controller.signal }, + ); + + expect(result.decision).toBe('deny'); + expect(result.reason).toBe('timeout'); + app.destroy(); + }); + + it('does not throw from evaluateAccess in any error path', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: vi.fn().mockRejectedValue(new Error('unexpected')), + }; + const app = createHAI3().use(auth({ provider })).build(); + + await expect( + app.auth!.evaluateAccess({ action: 'read', resource: 'doc' }), + ).resolves.toMatchObject({ decision: 'deny' }); + app.destroy(); + }); + }); + + // ------------------------------------------------------------------------- + // 18. Fail-closed canAccessMany + // ------------------------------------------------------------------------- + describe('fail-closed canAccessMany', () => { + const queries = [ + { action: 'read', resource: 'doc' }, + { action: 'write', resource: 'doc' }, + ]; + + it('returns empty array for empty input', async () => { + const provider = makeBearerProvider('tok'); + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.canAccessMany([]); + + expect(results).toHaveLength(0); + app.destroy(); + }); + + it('returns all deny when provider lacks canAccess and canAccessMany', async () => { + const provider = makeBearerProvider('tok'); // no access methods + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.canAccessMany(queries); + + expect(results).toEqual(['deny', 'deny']); + app.destroy(); + }); + + it('preserves order: fallback delegates to safeCanAccess per query', async () => { + const canAccessFn = vi.fn() + .mockResolvedValueOnce('allow') + .mockResolvedValueOnce('deny'); + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccess: canAccessFn, + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.canAccessMany(queries); + + expect(results).toEqual(['allow', 'deny']); + expect(canAccessFn).toHaveBeenCalledTimes(2); + app.destroy(); + }); + + it('delegates to provider.canAccessMany when available', async () => { + const canAccessManyFn = vi.fn().mockResolvedValue(['allow', 'deny']); + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccessMany: canAccessManyFn, + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.canAccessMany(queries); + + expect(results).toEqual(['allow', 'deny']); + expect(canAccessManyFn).toHaveBeenCalledTimes(1); + app.destroy(); + }); + + it('returns all deny when provider.canAccessMany throws', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccessMany: vi.fn().mockRejectedValue(new Error('batch error')), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.canAccessMany(queries); + + expect(results).toEqual(['deny', 'deny']); + app.destroy(); + }); + + it('returns all deny when provider.canAccessMany returns wrong result length', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccessMany: vi.fn().mockResolvedValue(['allow']), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.canAccessMany(queries); + + expect(results).toEqual(['deny', 'deny']); + app.destroy(); + }); + + it('normalizes malformed decisions from provider.canAccessMany to deny', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + canAccessMany: vi.fn().mockResolvedValue([ + 'allow', + 'maybe' as unknown as 'allow' | 'deny', + ]), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.canAccessMany(queries); + + expect(results).toEqual(['allow', 'deny']); + app.destroy(); + }); + + it('returns all deny when session is null and provider has canAccessMany', async () => { + const provider: AuthProvider = { + ...makeNullSessionProvider(), + canAccessMany: vi.fn().mockResolvedValue(['allow', 'allow']), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.canAccessMany(queries); + + expect(results).toEqual(['deny', 'deny']); + expect(provider.canAccessMany).not.toHaveBeenCalled(); + app.destroy(); + }); + }); + + // ------------------------------------------------------------------------- + // 19. Fail-closed evaluateMany + // ------------------------------------------------------------------------- + describe('fail-closed evaluateMany', () => { + const queries = [ + { action: 'read', resource: 'doc' }, + { action: 'write', resource: 'doc' }, + ]; + + it('returns empty array for empty input', async () => { + const provider = makeBearerProvider('tok'); + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany([]); + + expect(results).toHaveLength(0); + app.destroy(); + }); + + it('returns all deny+unsupported when provider lacks evaluateAccess and evaluateMany', async () => { + const provider = makeBearerProvider('tok'); // no evaluate methods + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany(queries); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ decision: 'deny', reason: 'unsupported' }); + expect(results[1]).toMatchObject({ decision: 'deny', reason: 'unsupported' }); + app.destroy(); + }); + + it('delegates to provider.evaluateMany when available', async () => { + const evalManyFn = vi.fn().mockResolvedValue([ + { decision: 'allow', reason: 'allowed' }, + { decision: 'deny', reason: 'denied' }, + ]); + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateMany: evalManyFn, + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany(queries); + + expect(results[0].decision).toBe('allow'); + expect(results[1].decision).toBe('deny'); + expect(evalManyFn).toHaveBeenCalledTimes(1); + app.destroy(); + }); + + it('fallback: preserves order via safeEvaluateAccess', async () => { + const evaluateAccessFn = vi.fn() + .mockResolvedValueOnce({ decision: 'allow', reason: 'allowed' }) + .mockResolvedValueOnce({ decision: 'deny', reason: 'denied' }); + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateAccess: evaluateAccessFn, + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany(queries); + + expect(results[0].decision).toBe('allow'); + expect(results[1].decision).toBe('deny'); + expect(evaluateAccessFn).toHaveBeenCalledTimes(2); + app.destroy(); + }); + + it('returns all deny+unauthenticated when session is null and provider has evaluateMany', async () => { + const provider: AuthProvider = { + ...makeNullSessionProvider(), + evaluateMany: vi.fn().mockResolvedValue([{ decision: 'allow' }, { decision: 'allow' }]), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany(queries); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ decision: 'deny', reason: 'unauthenticated' }); + expect(results[1]).toMatchObject({ decision: 'deny', reason: 'unauthenticated' }); + expect(provider.evaluateMany).not.toHaveBeenCalled(); + app.destroy(); + }); + + it('returns all deny+provider_error when provider.evaluateMany throws', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateMany: vi.fn().mockRejectedValue(new Error('batch error')), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany(queries); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ decision: 'deny', reason: 'provider_error' }); + expect(results[1]).toMatchObject({ decision: 'deny', reason: 'provider_error' }); + app.destroy(); + }); + + it('returns all deny+provider_error when provider.evaluateMany returns wrong result length', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateMany: vi.fn().mockResolvedValue([{ decision: 'allow' }]), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany(queries); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ decision: 'deny', reason: 'provider_error' }); + expect(results[1]).toMatchObject({ decision: 'deny', reason: 'provider_error' }); + app.destroy(); + }); + + it('normalizes malformed entries from provider.evaluateMany to deny+provider_error', async () => { + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateMany: vi.fn().mockResolvedValue([ + { decision: 'allow', reason: 'allowed' }, + {} as unknown as { decision: 'allow' | 'deny' }, + ]), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany(queries); + + expect(results[0]).toMatchObject({ decision: 'allow', reason: 'allowed' }); + expect(results[1]).toMatchObject({ decision: 'deny', reason: 'provider_error' }); + app.destroy(); + }); + + it('normalizes malformed constraints while preserving provider-defined reason/meta entries', async () => { + const malformedQueries = [ + { action: 'read', resource: 'doc' }, + { action: 'update', resource: 'doc' }, + { action: 'delete', resource: 'doc' }, + { action: 'list', resource: 'doc' }, + ]; + + const provider: AuthProvider = { + ...makeBearerProvider('tok'), + evaluateMany: vi.fn().mockResolvedValue([ + { decision: 'allow', reason: 'allowed' }, + { decision: 'allow', constraints: { field: 'tenantId', op: 'eq', value: 'acme' } }, + { decision: 'deny', reason: 'unknown_reason' }, + { decision: 'allow', meta: { source: 'scope', nested: { id: 'p-1' } } }, + ] as ReadonlyArray), + }; + const app = createHAI3().use(auth({ provider })).build(); + + const results = await app.auth!.evaluateMany(malformedQueries); + + expect(results).toEqual([ + { decision: 'allow', reason: 'allowed' }, + { decision: 'deny', reason: 'provider_error' }, + { decision: 'deny', reason: 'unknown_reason' }, + { decision: 'allow', meta: { source: 'scope', nested: { id: 'p-1' } } }, + ]); + app.destroy(); + }); + }); }); diff --git a/packages/framework/src/__tests__/rbacProviders.test.ts b/packages/framework/src/__tests__/rbacProviders.test.ts new file mode 100644 index 000000000..6bad14120 --- /dev/null +++ b/packages/framework/src/__tests__/rbacProviders.test.ts @@ -0,0 +1,557 @@ +/** + * Contract tests for RBAC provider behavior. + * + * Covers provider-specific mock mappings for: + * - Keycloak-like claims: realm_access.roles + resource_access..roles + * - Auth0-like claims: scope + optional permissions claim + * - Runtime fail-closed behavior when provider methods throw or return malformed payloads + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createHAI3 } from '../createHAI3'; +import { auth } from '../plugins/auth'; +import type { + AccessDecision, + AccessEvaluation, + AccessQuery, + AuthIdentity, + AuthPermissions, + AuthProvider, + AuthSession, +} from '@cyberfabric/auth'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +type KeycloakClaims = { + sub: string; + preferred_username: string; + realm_access: { + roles: string[]; + }; + resource_access: Record; +}; + +type Auth0Claims = { + sub: string; + scope: string; + permissions?: string[]; +}; + +const ownedApps: Array<{ destroy: () => void }> = []; + +function buildApp(provider: AuthProvider) { + const app = createHAI3().use(auth({ provider })).build(); + ownedApps.push(app); + return app; +} + +function makeBearerSession(token: string): AuthSession { + return { kind: 'bearer', token }; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function splitScope(scope: string): string[] { + return scope.split(/\s+/).filter(Boolean); +} + +function toPermissionKey(resource: string, action: string): string { + return `${resource}:${action}`; +} + +function keycloakPermissions(claims: KeycloakClaims): AuthPermissions { + return { + roles: unique(claims.realm_access.roles), + permissions: unique( + Object.entries(claims.resource_access).flatMap(([client, value]) => + value.roles.map((role) => `${client}:${role}`), + ), + ), + }; +} + +function keycloakCanAccess( + claims: KeycloakClaims, + query: AccessQuery, + throwOnMissingResourceAccess: boolean, +): AccessDecision { + const resourceRoles = claims.resource_access[query.resource]?.roles; + if (!resourceRoles) { + if (throwOnMissingResourceAccess) { + throw new Error(`missing resource_access.${query.resource}`); + } + return 'deny'; + } + + return resourceRoles.includes(query.action) ? 'allow' : 'deny'; +} + +function keycloakEvaluation( + claims: KeycloakClaims, + query: AccessQuery, + throwOnMissingResourceAccess: boolean, +): AccessEvaluation { + const decision = keycloakCanAccess(claims, query, throwOnMissingResourceAccess); + if (decision === 'allow') { + return { + decision: 'allow', + reason: 'allowed', + meta: { + source: `resource_access.${query.resource}`, + subject: claims.sub, + }, + }; + } + + return { + decision: 'deny', + reason: 'policy_not_found', + meta: { + source: `resource_access.${query.resource}`, + subject: claims.sub, + }, + }; +} + +function makeKeycloakProvider( + claims: KeycloakClaims, + opts?: { throwOnMissingResourceAccess?: boolean }, +): AuthProvider { + const session = makeBearerSession('kc-token'); + const throwOnMissingResourceAccess = opts?.throwOnMissingResourceAccess ?? false; + + return { + getSession: vi.fn().mockResolvedValue(session), + checkAuth: vi.fn().mockResolvedValue({ authenticated: true, session }), + logout: vi.fn().mockResolvedValue({ type: 'none' }), + getIdentity: vi.fn().mockResolvedValue({ + sub: claims.sub, + claims, + } as AuthIdentity), + getPermissions: vi.fn().mockResolvedValue(keycloakPermissions(claims)), + canAccess: vi.fn().mockImplementation((query: AccessQuery) => + Promise.resolve(keycloakCanAccess(claims, query, throwOnMissingResourceAccess)), + ), + evaluateAccess: vi.fn().mockImplementation((query: AccessQuery) => + Promise.resolve(keycloakEvaluation(claims, query, throwOnMissingResourceAccess)), + ), + }; +} + +function auth0Permissions(claims: Auth0Claims): AuthPermissions { + return { + roles: unique(splitScope(claims.scope)), + permissions: unique(claims.permissions ?? []), + }; +} + +function auth0CanAccess(claims: Auth0Claims, query: AccessQuery): AccessDecision { + const permissionKey = toPermissionKey(query.resource, query.action); + const scopeMatches = splitScope(claims.scope).includes(permissionKey); + const permissionMatches = (claims.permissions ?? []).includes(permissionKey); + return scopeMatches || permissionMatches ? 'allow' : 'deny'; +} + +function auth0Evaluation( + claims: Auth0Claims, + query: AccessQuery, + malformed?: { + malformedDecisionResource?: string; + malformedConstraintsResource?: string; + malformedReasonShapeResource?: string; + malformedMetaShapeResource?: string; + extensiblePayloadResource?: string; + }, +): AccessEvaluation { + if (malformed?.malformedDecisionResource && query.resource === malformed.malformedDecisionResource) { + return { decision: 'maybe' } as AccessEvaluation; + } + if (malformed?.malformedConstraintsResource && query.resource === malformed.malformedConstraintsResource) { + return { + decision: 'allow', + constraints: { field: 'tenantId', op: 'eq', value: 'acme' }, + } as AccessEvaluation; + } + if (malformed?.malformedReasonShapeResource && query.resource === malformed.malformedReasonShapeResource) { + return { + decision: 'allow', + reason: { code: 'not_a_reason' }, + } as AccessEvaluation; + } + if (malformed?.malformedMetaShapeResource && query.resource === malformed.malformedMetaShapeResource) { + return { + decision: 'allow', + meta: ['scope'], + } as AccessEvaluation; + } + if (malformed?.extensiblePayloadResource && query.resource === malformed.extensiblePayloadResource) { + return { + decision: 'deny', + reason: 'tenant_scope_conflict', + constraints: [ + { + predicate: { + field: 'tenant.id', + op: 'custom_eq', + value: 'acme', + }, + }, + ], + meta: { + source: 'scope', + nested: { policyId: 'p-1' }, + }, + } as AccessEvaluation; + } + + const permissionKey = toPermissionKey(query.resource, query.action); + const scopeMatches = splitScope(claims.scope).includes(permissionKey); + const permissionMatches = (claims.permissions ?? []).includes(permissionKey); + const source = scopeMatches ? 'scope' : permissionMatches ? 'permissions' : 'none'; + + if (scopeMatches || permissionMatches) { + return { + decision: 'allow', + reason: 'allowed', + meta: { + source, + subject: claims.sub, + }, + }; + } + + return { + decision: 'deny', + reason: 'policy_not_found', + meta: { + source, + subject: claims.sub, + }, + }; +} + +function makeAuth0Provider( + claims: Auth0Claims, + opts?: { + malformedDecisionResource?: string; + malformedConstraintsResource?: string; + malformedReasonShapeResource?: string; + malformedMetaShapeResource?: string; + extensiblePayloadResource?: string; + }, +): AuthProvider { + const session = makeBearerSession('auth0-token'); + + return { + getSession: vi.fn().mockResolvedValue(session), + checkAuth: vi.fn().mockResolvedValue({ authenticated: true, session }), + logout: vi.fn().mockResolvedValue({ type: 'none' }), + getIdentity: vi.fn().mockResolvedValue({ + sub: claims.sub, + claims, + } as AuthIdentity), + getPermissions: vi.fn().mockResolvedValue(auth0Permissions(claims)), + canAccess: vi.fn().mockImplementation((query: AccessQuery) => + Promise.resolve(auth0CanAccess(claims, query)), + ), + canAccessMany: vi.fn().mockImplementation((queries: ReadonlyArray) => + Promise.resolve(queries.map((query) => auth0CanAccess(claims, query))), + ), + evaluateAccess: vi.fn().mockImplementation((query: AccessQuery) => + Promise.resolve(auth0Evaluation(claims, query, opts)), + ), + evaluateMany: vi.fn().mockImplementation((queries: ReadonlyArray) => + Promise.resolve(queries.map((query) => auth0Evaluation(claims, query, opts))), + ), + }; +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +afterEach(() => { + while (ownedApps.length > 0) { + ownedApps.pop()?.destroy(); + } + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('RBAC provider contracts', () => { + describe('Keycloak-like provider', () => { + it('maps realm_access and resource_access into identity and permission hints', async () => { + const claims: KeycloakClaims = { + sub: 'kc-user-1', + preferred_username: 'karen', + realm_access: { + roles: ['realm-auditor'], + }, + resource_access: { + invoice: { roles: ['read', 'write'] }, + audit: { roles: ['read'] }, + }, + }; + const provider = makeKeycloakProvider(claims); + const app = buildApp(provider); + + const identity = await app.auth!.getIdentity(); + const permissions = await app.auth!.getPermissions(); + + expect(identity).toEqual({ + sub: 'kc-user-1', + claims, + }); + expect(permissions).toEqual({ + roles: ['realm-auditor'], + permissions: ['invoice:read', 'invoice:write', 'audit:read'], + }); + }); + + it('allows and denies typical access queries through the runtime contract', async () => { + const claims: KeycloakClaims = { + sub: 'kc-user-2', + preferred_username: 'kelly', + realm_access: { + roles: ['realm-auditor'], + }, + resource_access: { + invoice: { roles: ['read', 'write'] }, + audit: { roles: ['read'] }, + }, + }; + const provider = makeKeycloakProvider(claims); + const app = buildApp(provider); + + const allowQuery: AccessQuery = { action: 'read', resource: 'invoice' }; + const denyQuery: AccessQuery = { action: 'delete', resource: 'invoice' }; + const auditQuery: AccessQuery = { action: 'read', resource: 'audit' }; + + await expect(app.auth!.canAccess(allowQuery)).resolves.toBe('allow'); + await expect(app.auth!.canAccess(denyQuery)).resolves.toBe('deny'); + await expect(app.auth!.evaluateAccess(allowQuery)).resolves.toMatchObject({ + decision: 'allow', + reason: 'allowed', + meta: { + source: 'resource_access.invoice', + subject: 'kc-user-2', + }, + }); + await expect(app.auth!.evaluateAccess(denyQuery)).resolves.toMatchObject({ + decision: 'deny', + reason: 'policy_not_found', + meta: { + source: 'resource_access.invoice', + subject: 'kc-user-2', + }, + }); + await expect(app.auth!.evaluateAccess(auditQuery)).resolves.toMatchObject({ + decision: 'allow', + reason: 'allowed', + meta: { + source: 'resource_access.audit', + subject: 'kc-user-2', + }, + }); + }); + + it('fails closed when the provider throws on malformed resource access', async () => { + const claims: KeycloakClaims = { + sub: 'kc-user-3', + preferred_username: 'kelvin', + realm_access: { + roles: ['realm-auditor'], + }, + resource_access: { + audit: { roles: ['read'] }, + }, + }; + const provider = makeKeycloakProvider(claims, { + throwOnMissingResourceAccess: true, + }); + const app = buildApp(provider); + + await expect( + app.auth!.canAccess({ action: 'read', resource: 'invoice' }), + ).resolves.toBe('deny'); + }); + }); + + describe('Auth0-like provider', () => { + it('maps scope and permissions claims into identity and permission hints', async () => { + const claims: Auth0Claims = { + sub: 'auth0-user-1', + scope: 'invoice:read invoice:export', + permissions: ['audit:read'], + }; + const provider = makeAuth0Provider(claims); + const app = buildApp(provider); + + const identity = await app.auth!.getIdentity(); + const permissions = await app.auth!.getPermissions(); + + expect(identity).toEqual({ + sub: 'auth0-user-1', + claims, + }); + expect(permissions).toEqual({ + roles: ['invoice:read', 'invoice:export'], + permissions: ['audit:read'], + }); + }); + + it('supports canAccess, canAccessMany, evaluateAccess, and evaluateMany on typical queries', async () => { + const claims: Auth0Claims = { + sub: 'auth0-user-2', + scope: 'invoice:read invoice:export', + permissions: ['audit:read'], + }; + const provider = makeAuth0Provider(claims); + const app = buildApp(provider); + + const queries: AccessQuery[] = [ + { action: 'read', resource: 'invoice' }, + { action: 'delete', resource: 'invoice' }, + { action: 'read', resource: 'audit' }, + ]; + + await expect(app.auth!.canAccess(queries[0])).resolves.toBe('allow'); + await expect(app.auth!.canAccess(queries[1])).resolves.toBe('deny'); + await expect(app.auth!.canAccessMany(queries)).resolves.toEqual(['allow', 'deny', 'allow']); + + await expect(app.auth!.evaluateAccess(queries[0])).resolves.toMatchObject({ + decision: 'allow', + reason: 'allowed', + meta: { + source: 'scope', + subject: 'auth0-user-2', + }, + }); + await expect(app.auth!.evaluateAccess(queries[1])).resolves.toMatchObject({ + decision: 'deny', + reason: 'policy_not_found', + meta: { + source: 'none', + subject: 'auth0-user-2', + }, + }); + await expect(app.auth!.evaluateMany(queries)).resolves.toEqual([ + { + decision: 'allow', + reason: 'allowed', + meta: { + source: 'scope', + subject: 'auth0-user-2', + }, + }, + { + decision: 'deny', + reason: 'policy_not_found', + meta: { + source: 'none', + subject: 'auth0-user-2', + }, + }, + { + decision: 'allow', + reason: 'allowed', + meta: { + source: 'permissions', + subject: 'auth0-user-2', + }, + }, + ]); + }); + + it('fails closed when evaluateAccess returns a malformed payload', async () => { + const claims: Auth0Claims = { + sub: 'auth0-user-3', + scope: 'invoice:read', + permissions: ['audit:read'], + }; + const provider = makeAuth0Provider(claims, { + malformedDecisionResource: 'broken', + }); + const app = buildApp(provider); + + await expect( + app.auth!.evaluateAccess({ action: 'read', resource: 'broken' }), + ).resolves.toMatchObject({ + decision: 'deny', + reason: 'provider_error', + }); + }); + + it('fails closed when evaluateAccess returns malformed constraints/reason/meta shape', async () => { + const claims: Auth0Claims = { + sub: 'auth0-user-4', + scope: 'invoice:read', + permissions: ['audit:read'], + }; + const provider = makeAuth0Provider(claims, { + malformedConstraintsResource: 'broken-constraints', + malformedReasonShapeResource: 'broken-reason', + malformedMetaShapeResource: 'broken-meta', + }); + const app = buildApp(provider); + + await expect( + app.auth!.evaluateAccess({ action: 'read', resource: 'broken-constraints' }), + ).resolves.toEqual({ + decision: 'deny', + reason: 'provider_error', + }); + await expect( + app.auth!.evaluateAccess({ action: 'read', resource: 'broken-reason' }), + ).resolves.toEqual({ + decision: 'deny', + reason: 'provider_error', + }); + await expect( + app.auth!.evaluateAccess({ action: 'read', resource: 'broken-meta' }), + ).resolves.toEqual({ + decision: 'deny', + reason: 'provider_error', + }); + }); + + it('passes through provider-defined reason and nested constraints/meta payloads', async () => { + const claims: Auth0Claims = { + sub: 'auth0-user-5', + scope: 'invoice:read', + permissions: ['audit:read'], + }; + const provider = makeAuth0Provider(claims, { + extensiblePayloadResource: 'extensible', + }); + const app = buildApp(provider); + + await expect( + app.auth!.evaluateAccess({ action: 'read', resource: 'extensible' }), + ).resolves.toEqual({ + decision: 'deny', + reason: 'tenant_scope_conflict', + constraints: [ + { + predicate: { + field: 'tenant.id', + op: 'custom_eq', + value: 'acme', + }, + }, + ], + meta: { + source: 'scope', + nested: { policyId: 'p-1' }, + }, + }); + }); + }); +}); diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 4b3222c6c..8f5bfaa52 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -55,9 +55,15 @@ export type { AuthCallbackInput, AuthTransition, AuthPermissions, + AuthIdentity, + AccessRecord, AccessQuery, AccessDecision, + AccessConstraint, + AccessEvaluation, + AccessReason, AuthCapabilities, + AuthCapabilitiesResolved, AuthState, AuthStateEvent, AuthStateListener, diff --git a/packages/framework/src/plugins/auth.ts b/packages/framework/src/plugins/auth.ts index 90b4bed73..06674b55b 100644 --- a/packages/framework/src/plugins/auth.ts +++ b/packages/framework/src/plugins/auth.ts @@ -2,8 +2,14 @@ import type { AuthCallbackInput, AuthCheckResult, AuthContext, + AccessConstraint, AccessDecision, + AccessEvaluation, AccessQuery, + AccessReason, + AccessRecord, + AuthCapabilitiesResolved, + AuthIdentity, AuthLoginInput, AuthPermissions, AuthProvider, @@ -24,14 +30,36 @@ import type { HAI3Plugin } from '../types'; export type AuthRuntime = { provider: AuthProvider; + /** Resolved capability flags built by method-presence probe at provider-attach time. */ + capabilities: AuthCapabilitiesResolved; getSession: (ctx?: AuthContext) => Promise; checkAuth: (ctx?: AuthContext) => Promise; logout: (ctx?: AuthContext) => Promise; login?: (input: AuthLoginInput, ctx?: AuthContext) => Promise; handleCallback?: (input: AuthCallbackInput, ctx?: AuthContext) => Promise; refresh?: (ctx?: AuthContext) => Promise; + getIdentity?: (ctx?: AuthContext) => Promise; getPermissions?: (ctx?: AuthContext) => Promise; - canAccess?: = Record>(query: AccessQuery, ctx?: AuthContext) => Promise; + /** Fail-closed: always resolves to 'allow' | 'deny'. Never throws. */ + canAccess: ( + query: AccessQuery, + ctx?: AuthContext, + ) => Promise; + /** Fail-closed: always resolves to ReadonlyArray. Never throws. Order preserved. */ + canAccessMany: ( + queries: ReadonlyArray, + ctx?: AuthContext, + ) => Promise>; + /** Fail-closed: always resolves to AccessEvaluation with deny+reason on any error path. Never throws. */ + evaluateAccess: ( + query: AccessQuery, + ctx?: AuthContext, + ) => Promise; + /** Fail-closed: always resolves to ReadonlyArray. Never throws. Order preserved. */ + evaluateMany: ( + queries: ReadonlyArray, + ctx?: AuthContext, + ) => Promise>; subscribe?: (listener: AuthStateListener) => AuthUnsubscribe; }; @@ -66,6 +94,269 @@ export type AuthPluginConfig = { hai3Api?: Hai3ApiAuthTransportConfig; }; +// --------------------------------------------------------------------------- +// Capability probe +// --------------------------------------------------------------------------- + +function buildCapabilities(provider: AuthProvider): AuthCapabilitiesResolved { + return { + hasGetIdentity: typeof provider.getIdentity === 'function', + hasGetPermissions: typeof provider.getPermissions === 'function', + hasCanAccess: typeof provider.canAccess === 'function', + hasCanAccessMany: typeof provider.canAccessMany === 'function', + hasEvaluateAccess: typeof provider.evaluateAccess === 'function', + hasEvaluateMany: typeof provider.evaluateMany === 'function', + hasRefresh: typeof provider.refresh === 'function', + hasSubscribe: typeof provider.subscribe === 'function', + }; +} + +// --------------------------------------------------------------------------- +// Fail-closed access wrappers +// --------------------------------------------------------------------------- + +function classifyAbortReason(signal: AbortSignal): AccessReason { + const reason = signal.reason; + if (reason instanceof Error && reason.name === 'TimeoutError') return 'timeout'; + return 'aborted'; +} + +function isAccessDecision(value: unknown): value is AccessDecision { + return value === 'allow' || value === 'deny'; +} + +function normalizeDecision(value: unknown): AccessDecision { + return value === 'allow' ? 'allow' : 'deny'; +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isAccessReason(value: unknown): value is AccessReason { + return typeof value === 'string'; +} + +function isAccessJsonValue(value: unknown, seen: WeakSet = new WeakSet()): boolean { + if ( + value === null + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + ) { + return true; + } + + if (Array.isArray(value)) { + if (seen.has(value)) return false; + seen.add(value); + return value.every((entry) => isAccessJsonValue(entry, seen)); + } + + if (isPlainObject(value)) { + if (seen.has(value)) return false; + seen.add(value); + return Object.values(value).every((entry) => isAccessJsonValue(entry, seen)); + } + + return false; +} + +function isAccessConstraint(value: unknown): value is AccessConstraint { + return isPlainObject(value) + && Object.values(value).every((entry) => isAccessJsonValue(entry)); +} + +function isAccessConstraints(value: unknown): value is ReadonlyArray { + return Array.isArray(value) && value.every((entry) => isAccessConstraint(entry)); +} + +function isAccessMeta(value: unknown): value is NonNullable { + return isPlainObject(value) + && Object.values(value).every((entry) => isAccessJsonValue(entry)); +} + +function normalizeEvaluation(value: unknown): AccessEvaluation { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { decision: 'deny', reason: 'provider_error' }; + } + + const maybeEvaluation = value as { + decision?: unknown; + constraints?: unknown; + reason?: unknown; + meta?: unknown; + }; + if (!isAccessDecision(maybeEvaluation.decision)) { + return { decision: 'deny', reason: 'provider_error' }; + } + + if (maybeEvaluation.constraints !== undefined && !isAccessConstraints(maybeEvaluation.constraints)) { + return { decision: 'deny', reason: 'provider_error' }; + } + + if (maybeEvaluation.reason !== undefined && !isAccessReason(maybeEvaluation.reason)) { + return { decision: 'deny', reason: 'provider_error' }; + } + + if (maybeEvaluation.meta !== undefined && !isAccessMeta(maybeEvaluation.meta)) { + return { decision: 'deny', reason: 'provider_error' }; + } + + const normalized: AccessEvaluation = { decision: maybeEvaluation.decision }; + if (maybeEvaluation.constraints !== undefined) { + normalized.constraints = maybeEvaluation.constraints; + } + if (maybeEvaluation.reason !== undefined) { + normalized.reason = maybeEvaluation.reason; + } + if (maybeEvaluation.meta !== undefined) { + normalized.meta = maybeEvaluation.meta; + } + return normalized; +} + +async function safeCanAccess( + provider: AuthProvider, + caps: AuthCapabilitiesResolved, + query: AccessQuery, + ctx?: AuthContext, +): Promise { + if (!query.action || !query.resource) return 'deny'; + if (!caps.hasCanAccess) return 'deny'; + + let session: AuthSession | null; + try { + session = await provider.getSession(ctx); + } catch { + return 'deny'; + } + if (session === null) return 'deny'; + + try { + return normalizeDecision(await provider.canAccess!(query, ctx)); + } catch { + return 'deny'; + } +} + +async function safeEvaluateAccess( + provider: AuthProvider, + caps: AuthCapabilitiesResolved, + query: AccessQuery, + ctx?: AuthContext, +): Promise { + if (!query.action || !query.resource) { + return { decision: 'deny', reason: 'malformed' }; + } + if (!caps.hasEvaluateAccess) { + return { decision: 'deny', reason: 'unsupported' }; + } + + let session: AuthSession | null; + try { + session = await provider.getSession(ctx); + } catch { + return { decision: 'deny', reason: 'unauthenticated' }; + } + if (session === null) { + return { decision: 'deny', reason: 'unauthenticated' }; + } + + try { + return normalizeEvaluation(await provider.evaluateAccess!(query, ctx)); + } catch { + const signal = ctx?.signal; + if (signal?.aborted) { + return { decision: 'deny', reason: classifyAbortReason(signal) }; + } + return { decision: 'deny', reason: 'provider_error' }; + } +} + +async function safeCanAccessMany( + provider: AuthProvider, + caps: AuthCapabilitiesResolved, + queries: ReadonlyArray, + ctx?: AuthContext, +): Promise> { + if (queries.length === 0) return []; + + if (caps.hasCanAccessMany) { + let session: AuthSession | null; + try { + session = await provider.getSession(ctx); + } catch { + return queries.map(() => 'deny' as const); + } + if (session === null) return queries.map(() => 'deny' as const); + + try { + const decisions = await provider.canAccessMany!(queries, ctx); + if (!Array.isArray(decisions) || decisions.length !== queries.length) { + return queries.map(() => 'deny' as const); + } + return decisions.map((decision) => normalizeDecision(decision)); + } catch { + return queries.map(() => 'deny' as const); + } + } + + const results: AccessDecision[] = []; + for (const query of queries) { + results.push(await safeCanAccess(provider, caps, query, ctx)); + } + return results; +} + +async function safeEvaluateMany( + provider: AuthProvider, + caps: AuthCapabilitiesResolved, + queries: ReadonlyArray, + ctx?: AuthContext, +): Promise> { + if (queries.length === 0) return []; + + if (caps.hasEvaluateMany) { + let session: AuthSession | null; + try { + session = await provider.getSession(ctx); + } catch { + return queries.map(() => ({ decision: 'deny' as const, reason: 'unauthenticated' as const })); + } + if (session === null) { + return queries.map(() => ({ decision: 'deny' as const, reason: 'unauthenticated' as const })); + } + + try { + const evaluations = await provider.evaluateMany!(queries, ctx); + if (!Array.isArray(evaluations) || evaluations.length !== queries.length) { + return queries.map(() => ({ decision: 'deny' as const, reason: 'provider_error' as const })); + } + return evaluations.map((evaluation) => normalizeEvaluation(evaluation)); + } catch { + const signal = ctx?.signal; + if (signal?.aborted) { + const reason = classifyAbortReason(signal); + return queries.map(() => ({ decision: 'deny' as const, reason })); + } + return queries.map(() => ({ decision: 'deny' as const, reason: 'provider_error' as const })); + } + } + + const results: AccessEvaluation[] = []; + for (const query of queries) { + results.push(await safeEvaluateAccess(provider, caps, query, ctx)); + } + return results; +} + +// --------------------------------------------------------------------------- +// REST transport helpers +// --------------------------------------------------------------------------- + function isSupportedAuthTransportMethod( method: RestRequestContext['method'] ): method is AuthTransportRequest['method'] { @@ -253,6 +544,7 @@ export function hai3ApiTransport(): AuthTransportBinder { export function auth(config: AuthPluginConfig): HAI3Plugin { const transport = config.transport ?? hai3ApiTransport(); let binding: AuthTransportBinding | null = null; + const caps = buildCapabilities(config.provider); return { name: 'auth', @@ -260,15 +552,27 @@ export function auth(config: AuthPluginConfig): HAI3Plugin { app: { auth: { provider: config.provider, + capabilities: caps, getSession: (ctx?: AuthContext) => config.provider.getSession(ctx), checkAuth: (ctx?: AuthContext) => config.provider.checkAuth(ctx), logout: (ctx?: AuthContext) => config.provider.logout(ctx), login: config.provider.login?.bind(config.provider), handleCallback: config.provider.handleCallback?.bind(config.provider), refresh: config.provider.refresh?.bind(config.provider), - + getIdentity: config.provider.getIdentity?.bind(config.provider), getPermissions: config.provider.getPermissions?.bind(config.provider), - canAccess: config.provider.canAccess?.bind(config.provider), + canAccess: ( + query: AccessQuery, + ctx?: AuthContext, + ) => safeCanAccess(config.provider, caps, query as AccessQuery, ctx), + canAccessMany: (queries, ctx) => + safeCanAccessMany(config.provider, caps, queries, ctx), + evaluateAccess: ( + query: AccessQuery, + ctx?: AuthContext, + ) => safeEvaluateAccess(config.provider, caps, query as AccessQuery, ctx), + evaluateMany: (queries, ctx) => + safeEvaluateMany(config.provider, caps, queries, ctx), subscribe: config.provider.subscribe?.bind(config.provider), } satisfies AuthRuntime, }, diff --git a/packages/react/__tests__/rbacGuards.test.tsx b/packages/react/__tests__/rbacGuards.test.tsx new file mode 100644 index 000000000..8a1b1cfcb --- /dev/null +++ b/packages/react/__tests__/rbacGuards.test.tsx @@ -0,0 +1,451 @@ +/** + * Tests for RBAC guard API: useCanAccess hook and CanAccess component. + * + * Covers: + * - allow: true when canAccess resolves 'allow' + * - allow: false (deny) when canAccess resolves 'deny' + * - isResolving: true while pending, false after resolution + * - default deny while resolving (pessimistic strategy) + * - deny + isResolving: false when app.auth is absent + * - deny + isResolving: false on provider error (canAccess throws) + * - query change re-pessimizes (Allowed -> Pending -> Allowed/Denied) + * - abort signal sent on unmount + * - CanAccess renders allowed / denied / loading slots correctly + * + * @packageDocumentation + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { createHAI3, auth } from '@cyberfabric/framework'; +import type { + AuthProvider, + AuthContext, + AccessQuery, + AccessDecision, + AuthCheckResult, + AuthSession, + AuthTransition, +} from '@cyberfabric/framework'; +import { HAI3Provider } from '../src/HAI3Provider'; +import { useCanAccess } from '../src/hooks/useCanAccess'; +import { CanAccess } from '../src/components/CanAccess'; + +// ============================================================================ +// Helpers +// ============================================================================ + +const ownedApps: import('@cyberfabric/framework').HAI3App[] = []; + +afterEach(() => { + ownedApps.forEach((a) => a.destroy()); + ownedApps.length = 0; +}); + +const MOCK_SESSION: AuthSession = { kind: 'bearer', token: 'tok' }; + +/** Build a minimal AuthProvider stub. Only getSession + canAccess are tested. */ +function makeProvider( + canAccessFn: (query: AccessQuery, ctx?: AuthContext) => Promise, +): AuthProvider { + return { + getSession: vi.fn().mockResolvedValue(MOCK_SESSION), + checkAuth: vi.fn<() => Promise>().mockResolvedValue({ authenticated: true }), + logout: vi.fn<() => Promise>().mockResolvedValue({ type: 'none' }), + canAccess: vi.fn().mockImplementation(canAccessFn), + }; +} + +function makeProviderWithoutCanAccess(): AuthProvider { + return { + getSession: vi.fn().mockResolvedValue(MOCK_SESSION), + checkAuth: vi.fn<() => Promise>().mockResolvedValue({ authenticated: true }), + logout: vi.fn<() => Promise>().mockResolvedValue({ type: 'none' }), + }; +} + +function buildApp(provider: AuthProvider): import('@cyberfabric/framework').HAI3App { + const app = createHAI3().use(auth({ provider })).build(); + ownedApps.push(app); + return app; +} + +function buildAppNoAuth(): import('@cyberfabric/framework').HAI3App { + const app = createHAI3().build(); + ownedApps.push(app); + return app; +} + +function makeWrapper(app: import('@cyberfabric/framework').HAI3App) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +const QUERY_READ: AccessQuery = { action: 'read', resource: 'invoice' }; +const QUERY_WRITE: AccessQuery = { action: 'write', resource: 'invoice' }; +const QUERY_WITH_RECORD: AccessQuery = { + action: 'read', + resource: 'invoice', + record: { id: '42', status: 'active' }, +}; + +// ============================================================================ +// useCanAccess +// ============================================================================ + +describe('useCanAccess', () => { + it('returns isResolving: true and allow: false while the decision is pending', () => { + const provider = makeProvider(() => new Promise(() => undefined)); + const wrapper = makeWrapper(buildApp(provider)); + + const { result } = renderHook(() => useCanAccess(QUERY_READ), { wrapper }); + + expect(result.current.allow).toBe(false); + expect(result.current.isResolving).toBe(true); + }); + + it('resolves to allow: true when canAccess returns allow', async () => { + const provider = makeProvider(() => Promise.resolve('allow')); + const wrapper = makeWrapper(buildApp(provider)); + + const { result } = renderHook(() => useCanAccess(QUERY_READ), { wrapper }); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(result.current.allow).toBe(true); + }); + + it('resolves to allow: false when canAccess returns deny', async () => { + const provider = makeProvider(() => Promise.resolve('deny')); + const wrapper = makeWrapper(buildApp(provider)); + + const { result } = renderHook(() => useCanAccess(QUERY_READ), { wrapper }); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(result.current.allow).toBe(false); + }); + + it('resolves to allow: false, isResolving: false when auth plugin is not registered', async () => { + const wrapper = makeWrapper(buildAppNoAuth()); + + const { result } = renderHook(() => useCanAccess(QUERY_READ), { wrapper }); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(result.current.allow).toBe(false); + }); + + it('resolves to deny when canAccess throws (error path)', async () => { + const provider = makeProvider(() => Promise.reject(new Error('provider error'))); + // canAccess in AuthRuntime is fail-closed, so it resolves to 'deny' on throw. + // But even if the runtime leaked an error, useCanAccess catches it. + const wrapper = makeWrapper(buildApp(provider)); + + const { result } = renderHook(() => useCanAccess(QUERY_READ), { wrapper }); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(result.current.allow).toBe(false); + }); + + it('resolves to deny when provider has no canAccess method', async () => { + const wrapper = makeWrapper(buildApp(makeProviderWithoutCanAccess())); + + const { result } = renderHook(() => useCanAccess(QUERY_READ), { wrapper }); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(result.current.allow).toBe(false); + }); + + it('re-pessimizes to allow: false, isResolving: true when query changes after allow', async () => { + let resolveFirst!: (d: AccessDecision) => void; + const firstCall = new Promise((r) => { resolveFirst = r; }); + let callCount = 0; + + const provider = makeProvider(() => { + callCount += 1; + if (callCount === 1) return firstCall; + return new Promise(() => undefined); // second call never resolves + }); + + const wrapper = makeWrapper(buildApp(provider)); + + const { result, rerender } = renderHook( + (q: AccessQuery) => useCanAccess(q), + { wrapper, initialProps: QUERY_READ }, + ); + + // Resolve first call with allow. + await act(async () => { resolveFirst('allow'); }); + await waitFor(() => expect(result.current.allow).toBe(true)); + expect(result.current.isResolving).toBe(false); + + // Change query — hook must immediately re-pessimize. + rerender(QUERY_WRITE); + + expect(result.current.allow).toBe(false); + expect(result.current.isResolving).toBe(true); + }); + + it('re-pessimizes when query changes after deny', async () => { + let callCount = 0; + const provider = makeProvider(() => { + callCount += 1; + if (callCount === 1) return Promise.resolve('deny'); + return new Promise(() => undefined); + }); + + const wrapper = makeWrapper(buildApp(provider)); + + const { result, rerender } = renderHook( + (q: AccessQuery) => useCanAccess(q), + { wrapper, initialProps: QUERY_READ }, + ); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(result.current.allow).toBe(false); + + rerender(QUERY_WRITE); + + expect(result.current.allow).toBe(false); + expect(result.current.isResolving).toBe(true); + }); + + it('does not re-run when a new query object with the same content is passed', async () => { + const canAccess = vi.fn().mockResolvedValue('allow' as AccessDecision); + const provider: AuthProvider = { + getSession: vi.fn().mockResolvedValue(MOCK_SESSION), + checkAuth: vi.fn<() => Promise>().mockResolvedValue({ authenticated: true }), + logout: vi.fn<() => Promise>().mockResolvedValue({ type: 'none' }), + canAccess, + }; + + const wrapper = makeWrapper(buildApp(provider)); + + const queryA: AccessQuery = { + action: 'read', + resource: 'invoice', + record: { status: 'active', id: '42' }, + }; + const queryB: AccessQuery = { + action: 'read', + resource: 'invoice', + record: { id: '42', status: 'active' }, + }; + + const { result, rerender } = renderHook( + (q: AccessQuery) => useCanAccess(q), + { wrapper, initialProps: queryA }, + ); + + await waitFor(() => expect(result.current.allow).toBe(true)); + const firstCallCount = canAccess.mock.calls.length; + + // Same logical content, different key order — key stays stable. + rerender(queryB); + + expect(canAccess.mock.calls.length).toBe(firstCallCount); + }); + + it('treats typed record values as distinct query keys (1 !== "1")', async () => { + const canAccess = vi.fn().mockResolvedValue('allow' as AccessDecision); + const provider: AuthProvider = { + getSession: vi.fn().mockResolvedValue(MOCK_SESSION), + checkAuth: vi.fn<() => Promise>().mockResolvedValue({ authenticated: true }), + logout: vi.fn<() => Promise>().mockResolvedValue({ type: 'none' }), + canAccess, + }; + + const wrapper = makeWrapper(buildApp(provider)); + const numberQuery: AccessQuery<{ id: number }> = { + action: 'read', + resource: 'invoice', + record: { id: 1 }, + }; + const stringQuery: AccessQuery<{ id: string }> = { + action: 'read', + resource: 'invoice', + record: { id: '1' }, + }; + + const { result, rerender } = renderHook( + (q: AccessQuery) => useCanAccess(q), + { wrapper, initialProps: numberQuery as AccessQuery }, + ); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(result.current.allow).toBe(true); + + rerender(stringQuery as AccessQuery); + expect(result.current.isResolving).toBe(true); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(canAccess).toHaveBeenCalledTimes(2); + }); + + it('passes record fields into the query and uses them for change detection', async () => { + let resolveRecord1!: (d: AccessDecision) => void; + const call1 = new Promise((r) => { resolveRecord1 = r; }); + let callCount = 0; + + const provider = makeProvider(() => { + callCount += 1; + if (callCount === 1) return call1; + return new Promise(() => undefined); + }); + + const wrapper = makeWrapper(buildApp(provider)); + + const queryA: AccessQuery = { action: 'read', resource: 'invoice', record: { id: '1' } }; + const queryB: AccessQuery = { action: 'read', resource: 'invoice', record: { id: '2' } }; + + const { result, rerender } = renderHook( + (q: AccessQuery) => useCanAccess(q), + { wrapper, initialProps: queryA }, + ); + + await act(async () => { resolveRecord1('allow'); }); + await waitFor(() => expect(result.current.allow).toBe(true)); + + // Different record.id — must re-pessimize. + rerender(queryB); + + expect(result.current.allow).toBe(false); + expect(result.current.isResolving).toBe(true); + }); + + it('aborts the in-flight request on unmount', async () => { + const signals: AbortSignal[] = []; + const provider = makeProvider((_q, ctx) => { + if (ctx?.signal) signals.push(ctx.signal); + return new Promise(() => undefined); + }); + + const wrapper = makeWrapper(buildApp(provider)); + + const { unmount } = renderHook(() => useCanAccess(QUERY_READ), { wrapper }); + + await waitFor(() => expect(signals.length).toBeGreaterThan(0)); + expect(signals[0].aborted).toBe(false); + + unmount(); + + expect(signals[0].aborted).toBe(true); + }); +}); + +// ============================================================================ +// CanAccess component +// ============================================================================ + +describe('CanAccess', () => { + it('renders denied slot while the decision is resolving', () => { + const provider = makeProvider(() => new Promise(() => undefined)); + const app = buildApp(provider); + + render( + + yes} + denied={no} + /> + , + ); + + expect(screen.getByTestId('denied')).toBeTruthy(); + expect(screen.queryByTestId('allowed')).toBeNull(); + }); + + it('renders loading slot while resolving when provided', () => { + const provider = makeProvider(() => new Promise(() => undefined)); + const app = buildApp(provider); + + render( + + yes} + denied={no} + loading={} + /> + , + ); + + expect(screen.getByTestId('loading')).toBeTruthy(); + expect(screen.queryByTestId('denied')).toBeNull(); + expect(screen.queryByTestId('allowed')).toBeNull(); + }); + + it('renders allowed slot after an allow decision', async () => { + const provider = makeProvider(() => Promise.resolve('allow')); + const app = buildApp(provider); + + render( + + yes} + denied={no} + /> + , + ); + + await waitFor(() => expect(screen.queryByTestId('allowed')).toBeTruthy()); + expect(screen.queryByTestId('denied')).toBeNull(); + }); + + it('renders denied slot after a deny decision', async () => { + const provider = makeProvider(() => Promise.resolve('deny')); + const app = buildApp(provider); + + render( + + yes} + denied={no} + /> + , + ); + + await waitFor(() => { + expect(screen.queryByTestId('allowed')).toBeNull(); + expect(screen.getByTestId('denied')).toBeTruthy(); + }); + }); + + it('renders null when denied slot is omitted and access is denied', async () => { + const provider = makeProvider(() => Promise.resolve('deny')); + const app = buildApp(provider); + + const { container } = render( + + yes} + /> + , + ); + + await waitFor(() => expect(screen.queryByTestId('allowed')).toBeNull()); + expect(container.firstChild).toBeNull(); + }); + + it('works with a record-bearing query', async () => { + const provider = makeProvider(() => Promise.resolve('allow')); + const app = buildApp(provider); + + render( + + yes} + denied={no} + /> + , + ); + + await waitFor(() => expect(screen.getByTestId('allowed')).toBeTruthy()); + }); +}); diff --git a/packages/react/__tests__/rbacProviderMocks.test.tsx b/packages/react/__tests__/rbacProviderMocks.test.tsx new file mode 100644 index 000000000..919806a82 --- /dev/null +++ b/packages/react/__tests__/rbacProviderMocks.test.tsx @@ -0,0 +1,300 @@ +/** + * Provider-mock RBAC demos for useCanAccess and CanAccess. + * + * Covers: + * - Keycloak-like claim model: allow and deny rendering paths + * - Auth0-like claim model: pessimistic pending state, loading slot, and allow after resolution + * - Fail-closed UI behavior when the provider rejects + * + * @vitest-environment jsdom + */ + +import React from 'react'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { auth, createHAI3 } from '@cyberfabric/framework'; +import type { + AccessDecision, + AccessQuery, + AuthCheckResult, + AuthIdentity, + AuthPermissions, + AuthProvider, + AuthSession, + AuthTransition, + HAI3App, +} from '@cyberfabric/framework'; +import { CanAccess } from '../src/components/CanAccess'; +import { HAI3Provider } from '../src/HAI3Provider'; +import { useCanAccess } from '../src/hooks/useCanAccess'; + +type DeferredGate = { + promise: Promise; + resolve: () => void; +}; + +type KeycloakClaims = { + sub: string; + preferred_username: string; + realm_access: { + roles: ReadonlyArray; + }; + resource_access: Record }>; +}; + +type Auth0Claims = { + sub: string; + permissions: ReadonlyArray; + 'https://frontx.example/roles'?: ReadonlyArray; +}; + +const RESOURCE_INVOICE = 'invoice'; +const ACTION_READ = 'read'; +const ACTION_DELETE = 'delete'; +const KEYCLOAK_CLIENT = 'frontx'; +const KEYCLOAK_READ_ROLE = 'invoice:read'; +const KEYCLOAK_DELETE_ROLE = 'invoice:delete'; +const AUTH0_READ_PERMISSION = 'invoice:read'; + +const ownedApps: HAI3App[] = []; + +afterEach(() => { + ownedApps.forEach((app) => app.destroy()); + ownedApps.length = 0; + vi.restoreAllMocks(); +}); + +function createDeferredGate(): DeferredGate { + let resolve!: () => void; + const promise = new Promise((innerResolve) => { + resolve = () => innerResolve(); + }); + + return { promise, resolve }; +} + +function buildApp(provider: AuthProvider): HAI3App { + const app = createHAI3().use(auth({ provider })).build(); + ownedApps.push(app); + return app; +} + +function makeWrapper(app: HAI3App) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +function makeSession(): AuthSession { + return { kind: 'bearer', token: 'mock-token' }; +} + +function makeAuthCheckResult(): AuthCheckResult { + return { authenticated: true, session: makeSession() }; +} + +function makeNoopTransition(): AuthTransition { + return { type: 'none' }; +} + +function collectKeycloakRoles(claims: KeycloakClaims): ReadonlyArray { + const clientRoles = claims.resource_access[KEYCLOAK_CLIENT]?.roles ?? []; + return [...claims.realm_access.roles, ...clientRoles]; +} + +function keycloakDecision(claims: KeycloakClaims, query: AccessQuery): AccessDecision { + if (query.resource !== RESOURCE_INVOICE) return 'deny'; + + const roles = new Set(collectKeycloakRoles(claims)); + if (query.action === ACTION_READ && roles.has(KEYCLOAK_READ_ROLE)) return 'allow'; + if (query.action === ACTION_DELETE && roles.has(KEYCLOAK_DELETE_ROLE)) return 'allow'; + return 'deny'; +} + +function makeKeycloakLikeProvider(claims: KeycloakClaims): AuthProvider { + const permissions: AuthPermissions = { roles: [...collectKeycloakRoles(claims)] }; + + return { + getSession: vi.fn().mockResolvedValue(makeSession()), + checkAuth: vi.fn().mockResolvedValue(makeAuthCheckResult()), + logout: vi.fn().mockResolvedValue(makeNoopTransition()), + getIdentity: vi.fn<() => Promise>().mockResolvedValue({ sub: claims.sub }), + getPermissions: vi.fn().mockResolvedValue(permissions), + canAccess: vi.fn().mockImplementation(async (query: AccessQuery) => keycloakDecision(claims, query)), + }; +} + +function auth0Decision(claims: Auth0Claims, query: AccessQuery): AccessDecision { + if (query.resource !== RESOURCE_INVOICE) return 'deny'; + if (query.action !== ACTION_READ) return 'deny'; + return claims.permissions.includes(AUTH0_READ_PERMISSION) ? 'allow' : 'deny'; +} + +function makeAuth0LikeProvider( + claims: Auth0Claims, + gate?: DeferredGate, + shouldReject = false, +): AuthProvider { + const permissions: AuthPermissions = { permissions: [...claims.permissions] }; + + return { + getSession: vi.fn().mockResolvedValue(makeSession()), + checkAuth: vi.fn().mockResolvedValue(makeAuthCheckResult()), + logout: vi.fn().mockResolvedValue(makeNoopTransition()), + getIdentity: vi.fn<() => Promise>().mockResolvedValue({ sub: claims.sub }), + getPermissions: vi.fn().mockResolvedValue(permissions), + canAccess: vi.fn().mockImplementation(async (query: AccessQuery) => { + if (gate) { + await gate.promise; + } + if (shouldReject) { + throw new Error('auth0 policy service failed'); + } + return auth0Decision(claims, query); + }), + }; +} + +describe('provider-mock RBAC demos', () => { + it('useCanAccess resolves allow after an Auth0-like provider gate opens', async () => { + const gate = createDeferredGate(); + const app = buildApp( + makeAuth0LikeProvider( + { + sub: 'auth0|demo-user', + permissions: [AUTH0_READ_PERMISSION], + }, + gate, + ), + ); + const wrapper = makeWrapper(app); + + const { result } = renderHook(() => useCanAccess({ action: ACTION_READ, resource: RESOURCE_INVOICE }), { + wrapper, + }); + + expect(result.current.allow).toBe(false); + expect(result.current.isResolving).toBe(true); + + await act(async () => { + gate.resolve(); + }); + + await waitFor(() => expect(result.current.isResolving).toBe(false)); + expect(result.current.allow).toBe(true); + }); + + it('CanAccess renders the allowed slot for a Keycloak-like role match', async () => { + const app = buildApp( + makeKeycloakLikeProvider({ + sub: 'kc-user-1', + preferred_username: 'kc-reader', + realm_access: { roles: ['offline_access'] }, + resource_access: { + [KEYCLOAK_CLIENT]: { roles: [KEYCLOAK_READ_ROLE] }, + }, + }), + ); + + render( + + allowed} + denied={denied} + /> + , + ); + + await waitFor(() => expect(screen.getByTestId('keycloak-allowed')).toBeTruthy()); + expect(screen.queryByTestId('keycloak-denied')).toBeNull(); + }); + + it('CanAccess renders the denied slot for a Keycloak-like role miss', async () => { + const app = buildApp( + makeKeycloakLikeProvider({ + sub: 'kc-user-2', + preferred_username: 'kc-guest', + realm_access: { roles: ['offline_access'] }, + resource_access: { + [KEYCLOAK_CLIENT]: { roles: ['invoice:view'] }, + }, + }), + ); + + render( + + allowed} + denied={denied} + /> + , + ); + + await waitFor(() => expect(screen.getByTestId('keycloak-denied')).toBeTruthy()); + expect(screen.queryByTestId('keycloak-allowed')).toBeNull(); + }); + + it('CanAccess shows loading while an Auth0-like provider is pending, then allows after resolution', async () => { + const gate = createDeferredGate(); + const app = buildApp( + makeAuth0LikeProvider( + { + sub: 'auth0|demo-user', + permissions: [AUTH0_READ_PERMISSION], + 'https://frontx.example/roles': ['invoice-reader'], + }, + gate, + ), + ); + + render( + + allowed} + denied={denied} + loading={loading} + /> + , + ); + + expect(screen.getByTestId('auth0-loading')).toBeTruthy(); + expect(screen.queryByTestId('auth0-allowed')).toBeNull(); + expect(screen.queryByTestId('auth0-denied')).toBeNull(); + + await act(async () => { + gate.resolve(); + }); + + await waitFor(() => expect(screen.getByTestId('auth0-allowed')).toBeTruthy()); + expect(screen.queryByTestId('auth0-loading')).toBeNull(); + }); + + it('CanAccess fails closed to the denied slot when the provider rejects', async () => { + const app = buildApp( + makeAuth0LikeProvider( + { + sub: 'auth0|error-user', + permissions: [AUTH0_READ_PERMISSION], + }, + undefined, + true, + ), + ); + + render( + + allowed} + denied={denied} + /> + , + ); + + await waitFor(() => expect(screen.getByTestId('error-denied')).toBeTruthy()); + expect(screen.queryByTestId('error-allowed')).toBeNull(); + }); +}); diff --git a/packages/react/src/components/CanAccess.tsx b/packages/react/src/components/CanAccess.tsx new file mode 100644 index 000000000..03b581564 --- /dev/null +++ b/packages/react/src/components/CanAccess.tsx @@ -0,0 +1,34 @@ +import type { ReactElement } from 'react'; +import type { AccessRecord } from '@cyberfabric/framework'; +import { useCanAccess } from '../hooks/useCanAccess'; +import type { CanAccessProps } from '../types'; + +/** + * Declarative RBAC guard component. + * + * Pessimistic: renders `denied` (or `loading` if provided) until an explicit + * 'allow' decision arrives from app.auth.canAccess. Only renders `allowed` + * after a confirmed allow. + * + * Example: + * } + * denied={} + * loading={} + * /> + */ +export function CanAccess({ + query, + allowed, + denied = null, + loading, +}: CanAccessProps): ReactElement | null { + const { allow, isResolving } = useCanAccess(query); + + if (isResolving) { + return (loading !== undefined ? loading : denied) as ReactElement | null; + } + + return (allow ? allowed : denied) as ReactElement | null; +} diff --git a/packages/react/src/hooks/useCanAccess.ts b/packages/react/src/hooks/useCanAccess.ts new file mode 100644 index 000000000..bad2e4ec6 --- /dev/null +++ b/packages/react/src/hooks/useCanAccess.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react'; +import type { + AccessQuery, + AccessRecord, + AuthRuntime, + HAI3App, +} from '@cyberfabric/framework'; +import { useHAI3 } from '../HAI3Context'; +import type { UseCanAccessResult } from '../types'; + +/** + * Stable string key for an AccessQuery. + * Record keys are sorted so { b:1, a:2 } and { a:2, b:1 } yield the same key. + * Values include an explicit type prefix to avoid collisions: + * 1 !== "1", null !== "null", true !== "true". + */ +function serializeRecordValue(value: AccessRecord[string]): string { + if (value === null) return 'n:'; + if (typeof value === 'string') return `s:${value}`; + if (typeof value === 'number') { + // Preserve -0 distinction for completeness. + return `d:${Object.is(value, -0) ? '-0' : String(value)}`; + } + return value ? 'b:1' : 'b:0'; +} + +function accessQueryKey(query: AccessQuery): string { + const { action, resource, record } = query; + if (!record) return `${action}\x00${resource}`; + const pairs = Object.keys(record) + .sort() + .map((k) => `${k}\x01${serializeRecordValue(record[k])}`) + .join('\x02'); + return `${action}\x00${resource}\x00${pairs}`; +} + +type HAI3AuthAppContract = HAI3App & { + auth?: AuthRuntime; +}; + +function getAuthRuntime(app: HAI3App): AuthRuntime | null { + return (app as HAI3AuthAppContract).auth ?? null; +} + +/** + * Declarative RBAC guard hook. + * + * Pessimistic: `allow` is false until an explicit 'allow' decision arrives. + * Aborts the in-flight canAccess call on unmount and on query change. + * + * State machine: + * mount -> Pending (allow=false, isResolving=true) + * Pending -> 'allow' -> Allowed (allow=true, isResolving=false) + * Pending -> 'deny' -> Denied (allow=false, isResolving=false) + * Pending -> error -> Denied (allow=false, isResolving=false) + * Allowed -> query-change -> Pending (re-pessimize) + * Denied -> query-change -> Pending (re-pessimize) + */ +export function useCanAccess( + query: AccessQuery, +): UseCanAccessResult { + const app = useHAI3(); + + const stableKey = accessQueryKey(query as AccessQuery); + + // Always keep the latest query in a ref so the effect closure stays fresh. + const queryRef = useRef(query as AccessQuery); + queryRef.current = query as AccessQuery; + + // prevKey tracks the key from the previous render cycle. + // Calling setPrevKey during render triggers an immediate synchronous re-render + // so the Pending state is visible before the next commit (React derived-state pattern). + const [prevKey, setPrevKey] = useState(stableKey); + const [result, setResult] = useState({ allow: false, isResolving: true }); + + if (prevKey !== stableKey) { + setPrevKey(stableKey); + setResult({ allow: false, isResolving: true }); + } + + useEffect(() => { + const auth = getAuthRuntime(app); + if (!auth) { + setResult({ allow: false, isResolving: false }); + return; + } + + setResult({ allow: false, isResolving: true }); + + let alive = true; + const controller = new AbortController(); + + void auth + .canAccess(queryRef.current, { signal: controller.signal }) + .then((decision) => { + if (alive) { + setResult({ allow: decision === 'allow', isResolving: false }); + } + }) + .catch(() => { + if (alive) { + setResult({ allow: false, isResolving: false }); + } + }); + + return () => { + alive = false; + controller.abort(); + }; + }, [app, prevKey]); + + return result; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f34d5fbb1..4e8424536 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -16,6 +16,8 @@ export { HAI3Provider } from './HAI3Provider'; export { HAI3Context, useHAI3 } from './HAI3Context'; export { invalidateQueryCacheForApp } from './queryClient'; +export { CanAccess } from './components/CanAccess'; +export type { CanAccessProps } from './types'; // ============================================================================ // Hooks @@ -36,6 +38,7 @@ export { useApiStream, useQueryCache, } from './hooks'; +export { useCanAccess } from './hooks/useCanAccess'; export type { ApiQueryOverrides } from './hooks/useApiQuery'; export type { @@ -96,6 +99,7 @@ export type { ApiInfiniteQueryResult, ApiSuspenseInfiniteQueryResult, ApiMutationResult, + UseCanAccessResult, } from './types'; // ============================================================================ diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index b973b7b71..331577248 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -14,6 +14,8 @@ import type { RootState, Language, Formatters, + AccessQuery, + AccessRecord, } from '@cyberfabric/framework'; import type { MfeContextValue } from './mfe/MfeContext'; @@ -229,3 +231,27 @@ export interface UseThemeReturn { * References @cyberfabric/i18n Formatters so signatures stay in sync. */ export type UseFormattersReturn = Formatters; + +/** + * useCanAccess Hook Return Type + * Result of an RBAC access check. + * allow is false until an explicit 'allow' decision arrives (pessimistic default). + */ +export interface UseCanAccessResult { + allow: boolean; + isResolving: boolean; +} + +/** + * CanAccess Component Props. + * Pessimistic: renders `denied` (or `loading`) until an authoritative 'allow' arrives. + */ +export interface CanAccessProps { + query: AccessQuery; + /** Content rendered when access is explicitly allowed. */ + allowed: ReactNode; + /** Content rendered on deny or error. Defaults to null. */ + denied?: ReactNode; + /** Optional slot shown while the decision is resolving. Falls back to `denied`. */ + loading?: ReactNode; +}