diff --git a/packages/auth/src/authzClient.ts b/packages/auth/src/authzClient.ts index c70a3085e..eef3b1ab4 100644 --- a/packages/auth/src/authzClient.ts +++ b/packages/auth/src/authzClient.ts @@ -13,12 +13,19 @@ import { AuthzDecision, AuthzError, FetchLike, + Grant, + GrantRequest, + GrantRevokeRequest, + GrantPage, + GrantListQuery, } from './authzTypes'; /** Path of the single-decision endpoint, relative to `baseUrl`. */ const CHECK_PATH = '/api/v1/security/authz/check'; /** Path of the batch-decision endpoint, relative to `baseUrl`. */ const BULK_CHECK_PATH = '/api/v1/security/authz/bulk-check'; +/** Path of the grants endpoint, relative to `baseUrl`. */ +const GRANTS_PATH = '/api/v1/security/authz/grants'; const DEFAULT_TIMEOUT_MS = 3000; const DEFAULT_CACHE_MAX_ENTRIES = 1000; @@ -126,6 +133,55 @@ export function createAuthzClient(options: AuthzClientOptions): AuthzClient { } } + /** + * Make an HTTP request (POST, DELETE, GET) to the Security API. + * Distinguishes MALFORMED (400) from PROVIDER_ERROR (502). + * Returns status code and parsed JSON body (if applicable). + */ + async function request( + method: string, + path: string, + body: unknown | undefined, + token: string, + ): Promise<{ status: number; body: unknown }> { + const controller = typeof AbortController === 'function' ? new AbortController() : undefined; + const timer = controller + ? setTimeout(() => controller.abort(), timeoutMs) + : undefined; + try { + const reqInit: any = { + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + signal: controller?.signal, + }; + if (body !== undefined) { + reqInit.body = JSON.stringify(body); + } + const res = await resolvedFetch!(`${base}${path}`, reqInit); + let responseBody: unknown = undefined; + if (res.status !== 204) { + // 204 No Content has no body + try { + responseBody = await res.json(); + } catch { + responseBody = null; + } + } + return { status: res.status, body: responseBody }; + } catch (err) { + // Timeout or transport error — treat as 502 (provider error) + throw new AuthzError( + 'PROVIDER_ERROR', + `Security API request to ${path} failed: ${(err as Error)?.message ?? 'unknown error'}`, + ); + } finally { + if (timer) clearTimeout(timer); + } + } + /** Read `{ allow: boolean }` strictly. Anything else is not a decision. */ function readDecision(value: unknown): AuthzDecision { const allow = (value as { allow?: unknown } | null)?.allow; @@ -194,5 +250,94 @@ export function createAuthzClient(options: AuthzClientOptions): AuthzClient { }); return decisions; }, + + async grant(req: GrantRequest, token: string): Promise { + const { status, body } = await request('POST', GRANTS_PATH, req, token); + if (status === 201) { + // Success — body should be the created Grant + const grant = body as Grant; + if (!grant?.id) { + throw new AuthzError('PROVIDER_ERROR', 'Security API returned 201 but no grant id'); + } + return grant; + } + if (status === 400) { + const msg = + (body as { error?: string } | null)?.error || 'grant request malformed'; + throw new AuthzError('MALFORMED', msg); + } + if (status === 502) { + const msg = + (body as { error?: string } | null)?.error || 'provider error'; + throw new AuthzError('PROVIDER_ERROR', msg); + } + // Any other status is unexpected + throw new AuthzError( + 'PROVIDER_ERROR', + `Security API returned ${status} for grant; treating as transient error`, + ); + }, + + async revoke(req: GrantRevokeRequest, token: string): Promise { + const { status, body } = await request('DELETE', GRANTS_PATH, req, token); + if (status === 204) { + // Success + return; + } + if (status === 400) { + const msg = + (body as { error?: string } | null)?.error || 'revoke request malformed'; + throw new AuthzError('MALFORMED', msg); + } + // 502 or other error + if (status === 502) { + const msg = + (body as { error?: string } | null)?.error || 'provider error'; + throw new AuthzError('PROVIDER_ERROR', msg); + } + throw new AuthzError( + 'PROVIDER_ERROR', + `Security API returned ${status} for revoke; treating as transient error`, + ); + }, + + async listGrants(query: GrantListQuery, token: string): Promise { + if (!query.tenant) { + throw new AuthzError('MALFORMED', 'tenant is required for listGrants'); + } + const params = new URLSearchParams(); + params.set('tenant', query.tenant); + if (query.subject) params.set('subject', query.subject); + if (query.limit !== undefined) params.set('limit', String(query.limit)); + if (query.cursor) params.set('cursor', query.cursor); + const { status, body } = await request( + 'GET', + `${GRANTS_PATH}?${params.toString()}`, + undefined, + token, + ); + if (status === 200) { + // Success — body should be the page + const page = body as GrantPage; + if (!page?.items || !page?.page) { + throw new AuthzError('PROVIDER_ERROR', 'Security API returned 200 but malformed page'); + } + return page; + } + if (status === 400) { + const msg = + (body as { error?: string } | null)?.error || 'listGrants request malformed'; + throw new AuthzError('MALFORMED', msg); + } + if (status === 502) { + const msg = + (body as { error?: string } | null)?.error || 'provider error'; + throw new AuthzError('PROVIDER_ERROR', msg); + } + throw new AuthzError( + 'PROVIDER_ERROR', + `Security API returned ${status} for listGrants; treating as transient error`, + ); + }, }; } diff --git a/packages/auth/src/authzTypes.ts b/packages/auth/src/authzTypes.ts index 9bf7e39b8..c87227312 100644 --- a/packages/auth/src/authzTypes.ts +++ b/packages/auth/src/authzTypes.ts @@ -68,7 +68,11 @@ export type AuthzErrorCode = /** The Security API could not be reached, timed out, or answered non-200. Denied. */ | 'DECISION_UNAVAILABLE' /** The guard/client is misconfigured (e.g. no baseUrl). Denied. */ - | 'AUTHZ_MISCONFIGURED'; + | 'AUTHZ_MISCONFIGURED' + /** The caller supplied malformed/incomplete request arguments. */ + | 'MALFORMED' + /** The authorization provider (upstream backend service) failed. Transient. */ + | 'PROVIDER_ERROR'; /** Error raised by the authz client/guard. Mirrors `AuthError`'s shape and discipline. */ export class AuthzError extends Error { @@ -124,6 +128,74 @@ export interface AuthzClientOptions { cacheMaxEntries?: number; } +/** A grant request: assign a role and optional permission to a subject. */ +export interface GrantRequest { + /** Principal being granted (user or service client). */ + subject: string; + /** Tenant/org scope. */ + tenant: string; + /** Role key to assign. */ + role: string; + /** Optional explicit `resource:action` permission alongside the role. */ + permission?: string; + /** Optional resource instance to scope the grant (ReBAC). Omit for tenant-wide. */ + resource?: ResourceRef; +} + +/** A created, revocable grant. */ +export interface Grant { + /** Opaque grant identifier. */ + id: string; + /** Principal granted. */ + subject: string; + /** Tenant scope. */ + tenant: string; + /** Role assigned. */ + role: string; + /** Optional permission alongside the role. */ + permission?: string; + /** Optional resource instance scope. */ + resource?: ResourceRef; + /** Timestamp of creation (epoch ms). */ + createdAt?: number; +} + +/** Revoke a grant by id or by identity tuple (subject + tenant + role). */ +export interface GrantRevokeRequest { + /** Grant id to revoke. Omit to revoke by identity tuple. */ + grantId?: string; + /** Subject to revoke (required if revoking by tuple). */ + subject?: string; + /** Tenant to revoke within (required if revoking by tuple). */ + tenant?: string; + /** Role to revoke (required if revoking by tuple). */ + role?: string; + /** Optional resource to scope the revocation. */ + resource?: ResourceRef; +} + +/** Page of grants (cursor-paginated). */ +export interface GrantPage { + items: Grant[]; + page: { + nextCursor: string | null; + hasMore: boolean; + total?: number; + }; +} + +/** Query parameters for listing grants. */ +export interface GrantListQuery { + /** Subject whose grants to list. Defaults to caller. */ + subject?: string; + /** Tenant to list within. Required. */ + tenant: string; + /** Optional limit (enforced server-side). */ + limit?: number; + /** Opaque cursor for pagination. */ + cursor?: string; +} + /** The authz client: a thin, fail-closed HTTP binding to the Security API. */ export interface AuthzClient { /** @@ -140,4 +212,24 @@ export interface AuthzClient { * resource's `allow` to a different resource. */ bulkCheck(checks: AuthzCheck[], token: string): Promise; + /** + * Grant a role (and optional permission) to a subject within a tenant. + * Throws `AuthzError('MALFORMED')` if required fields are missing. + * Throws `AuthzError('PROVIDER_ERROR')` if the backend provider fails (transient). + * Resolves with the created grant on success. + */ + grant(req: GrantRequest, token: string): Promise; + /** + * Revoke a grant by id, or by subject+tenant+role identity tuple. + * Throws `AuthzError('MALFORMED')` if neither form is provided or required fields missing. + * Throws `AuthzError('PROVIDER_ERROR')` if the backend provider fails (transient). + * Resolves on success (204 No Content from API). + */ + revoke(req: GrantRevokeRequest, token: string): Promise; + /** + * List grants for a subject within a tenant (cursor-paginated). + * Throws `AuthzError('MALFORMED')` if tenant is missing. + * Resolves with a page of grants. + */ + listGrants(query: GrantListQuery, token: string): Promise; } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index aa78b5933..b775192d6 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -45,6 +45,11 @@ export type { AuthzErrorCode, ResourceRef, FetchLike, + GrantRequest, + Grant, + GrantRevokeRequest, + GrantPage, + GrantListQuery, } from './authzTypes'; export { createAuthzClient } from './authzClient'; diff --git a/packages/auth/tests/authz.test.ts b/packages/auth/tests/authz.test.ts index b55340a69..8373de57b 100644 --- a/packages/auth/tests/authz.test.ts +++ b/packages/auth/tests/authz.test.ts @@ -334,3 +334,279 @@ describe('requirePermission', () => { expect(() => requirePermission({ resource: 'x', action: 'y' } as any)).toThrow(AuthzError); }); }); + +describe('createAuthzClient — grant/revoke/listGrants', () => { + const clientWith = (responses: Array<{ status?: number; body?: unknown } | Error>) => + createAuthzClient({ baseUrl: BASE, fetch: mockFetch(responses) }); + + it('grants a role and resolves with the created grant', async () => { + const fetch = mockFetch([ + { + status: 201, + body: { + id: 'grant_001', + subject: 'user_1', + tenant: 'tenant_1', + role: 'admin', + permission: 'resource:write', + resource: { type: 'invoice', key: 'inv_123' }, + createdAt: 1_700_000_000, + }, + }, + ]); + const client = createAuthzClient({ baseUrl: BASE, fetch }); + + const grant = await client.grant( + { + subject: 'user_1', + tenant: 'tenant_1', + role: 'admin', + permission: 'resource:write', + resource: { type: 'invoice', key: 'inv_123' }, + }, + TOKEN, + ); + + expect(grant).toEqual({ + id: 'grant_001', + subject: 'user_1', + tenant: 'tenant_1', + role: 'admin', + permission: 'resource:write', + resource: { type: 'invoice', key: 'inv_123' }, + createdAt: 1_700_000_000, + }); + expect(fetch.calls[0].url).toBe(`${BASE}/api/v1/security/authz/grants`); + expect(fetch.calls[0].init.method).toBe('POST'); + expect(fetch.calls[0].init.headers.authorization).toBe(`Bearer ${TOKEN}`); + }); + + it('grants a tenant-wide role (no resource scope)', async () => { + const fetch = mockFetch([ + { + status: 201, + body: { + id: 'grant_002', + subject: 'user_2', + tenant: 'tenant_2', + role: 'viewer', + }, + }, + ]); + const client = createAuthzClient({ baseUrl: BASE, fetch }); + + const grant = await client.grant( + { + subject: 'user_2', + tenant: 'tenant_2', + role: 'viewer', + }, + TOKEN, + ); + + expect(grant.id).toBe('grant_002'); + expect(grant.resource).toBeUndefined(); + }); + + it('throws MALFORMED when grant 400s', async () => { + const client = clientWith([ + { + status: 400, + body: { error: 'subject, tenant and role are required', code: 'MALFORMED' }, + }, + ]); + + await expect( + client.grant( + { subject: 'user_1', tenant: 'tenant_1', role: 'admin' }, + TOKEN, + ), + ).rejects.toMatchObject({ code: 'MALFORMED', message: expect.stringMatching(/subject, tenant and role/) }); + }); + + it('throws PROVIDER_ERROR when grant 502s', async () => { + const client = clientWith([{ status: 502, body: { error: 'grant failed', code: 'PROVIDER_ERROR' } }]); + + await expect( + client.grant( + { subject: 'user_1', tenant: 'tenant_1', role: 'admin' }, + TOKEN, + ), + ).rejects.toMatchObject({ code: 'PROVIDER_ERROR' }); + }); + + it('treats timeout on grant as PROVIDER_ERROR', async () => { + const timeout = Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }); + const client = clientWith([timeout]); + + await expect( + client.grant( + { subject: 'user_1', tenant: 'tenant_1', role: 'admin' }, + TOKEN, + ), + ).rejects.toMatchObject({ code: 'PROVIDER_ERROR' }); + }); + + it('revokes by grantId and resolves with 204', async () => { + const fetch = mockFetch([{ status: 204 }]); + const client = createAuthzClient({ baseUrl: BASE, fetch }); + + await client.revoke({ grantId: 'grant_001' }, TOKEN); + + expect(fetch.calls[0].url).toBe(`${BASE}/api/v1/security/authz/grants`); + expect(fetch.calls[0].init.method).toBe('DELETE'); + expect(JSON.parse(fetch.calls[0].init.body)).toEqual({ grantId: 'grant_001' }); + }); + + it('revokes by subject+tenant+role tuple', async () => { + const fetch = mockFetch([{ status: 204 }]); + const client = createAuthzClient({ baseUrl: BASE, fetch }); + + await client.revoke( + { + subject: 'user_1', + tenant: 'tenant_1', + role: 'admin', + }, + TOKEN, + ); + + expect(fetch.calls[0].init.method).toBe('DELETE'); + expect(JSON.parse(fetch.calls[0].init.body)).toEqual({ + subject: 'user_1', + tenant: 'tenant_1', + role: 'admin', + }); + }); + + it('throws MALFORMED when revoke 400s', async () => { + const client = clientWith([ + { + status: 400, + body: { error: 'grantId or subject+tenant+role required', code: 'MALFORMED' }, + }, + ]); + + await expect( + client.revoke({ grantId: '' }, TOKEN), + ).rejects.toMatchObject({ + code: 'MALFORMED', + message: expect.stringMatching(/grantId or subject/), + }); + }); + + it('throws PROVIDER_ERROR when revoke 502s', async () => { + const client = clientWith([{ status: 502, body: { error: 'revoke failed', code: 'PROVIDER_ERROR' } }]); + + await expect( + client.revoke({ grantId: 'grant_001' }, TOKEN), + ).rejects.toMatchObject({ code: 'PROVIDER_ERROR' }); + }); + + it('lists grants for a tenant', async () => { + const fetch = mockFetch([ + { + status: 200, + body: { + items: [ + { + id: 'grant_001', + subject: 'user_1', + tenant: 'tenant_1', + role: 'admin', + }, + { + id: 'grant_002', + subject: 'user_1', + tenant: 'tenant_1', + role: 'viewer', + }, + ], + page: { + nextCursor: null, + hasMore: false, + total: 2, + }, + }, + }, + ]); + const client = createAuthzClient({ baseUrl: BASE, fetch }); + + const page = await client.listGrants({ tenant: 'tenant_1' }, TOKEN); + + expect(page.items).toHaveLength(2); + expect(page.items[0].id).toBe('grant_001'); + expect(page.page.hasMore).toBe(false); + expect(fetch.calls[0].url).toContain('/api/v1/security/authz/grants?'); + expect(fetch.calls[0].url).toContain('tenant=tenant_1'); + expect(fetch.calls[0].init.method).toBe('GET'); + }); + + it('listGrants with optional subject and cursor', async () => { + const fetch = mockFetch([ + { + status: 200, + body: { + items: [{ id: 'grant_003', subject: 'user_2', tenant: 'tenant_1', role: 'editor' }], + page: { + nextCursor: 'cursor_abc', + hasMore: true, + }, + }, + }, + ]); + const client = createAuthzClient({ baseUrl: BASE, fetch }); + + await client.listGrants( + { + tenant: 'tenant_1', + subject: 'user_2', + limit: 50, + cursor: 'prev_cursor', + }, + TOKEN, + ); + + expect(fetch.calls[0].url).toContain('tenant=tenant_1'); + expect(fetch.calls[0].url).toContain('subject=user_2'); + expect(fetch.calls[0].url).toContain('limit=50'); + expect(fetch.calls[0].url).toContain('cursor=prev_cursor'); + }); + + it('throws MALFORMED when listGrants has no tenant', async () => { + const client = clientWith([]); + + await expect( + client.listGrants({ tenant: '' }, TOKEN), + ).rejects.toMatchObject({ + code: 'MALFORMED', + message: expect.stringMatching(/tenant is required/), + }); + }); + + it('throws MALFORMED when listGrants 400s', async () => { + const client = clientWith([ + { + status: 400, + body: { error: 'tenant is required', code: 'MALFORMED' }, + }, + ]); + + await expect( + client.listGrants({ tenant: 'tenant_1' }, TOKEN), + ).rejects.toMatchObject({ code: 'MALFORMED' }); + }); + + it('throws PROVIDER_ERROR when listGrants 502s', async () => { + const client = clientWith([ + { + status: 502, + body: { error: 'provider error', code: 'PROVIDER_ERROR' }, + }, + ]); + + await expect( + client.listGrants({ tenant: 'tenant_1' }, TOKEN), + ).rejects.toMatchObject({ code: 'PROVIDER_ERROR' }); + }); +});