Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions packages/auth/src/authzClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -194,5 +250,94 @@ export function createAuthzClient(options: AuthzClientOptions): AuthzClient {
});
return decisions;
},

async grant(req: GrantRequest, token: string): Promise<Grant> {
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<void> {
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<GrantPage> {
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`,
);
},
};
}
94 changes: 93 additions & 1 deletion packages/auth/src/authzTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
/**
Expand All @@ -140,4 +212,24 @@ export interface AuthzClient {
* resource's `allow` to a different resource.
*/
bulkCheck(checks: AuthzCheck[], token: string): Promise<AuthzDecision[]>;
/**
* 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<Grant>;
/**
* 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<void>;
/**
* 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<GrantPage>;
}
5 changes: 5 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export type {
AuthzErrorCode,
ResourceRef,
FetchLike,
GrantRequest,
Grant,
GrantRevokeRequest,
GrantPage,
GrantListQuery,
} from './authzTypes';

export { createAuthzClient } from './authzClient';
Expand Down
Loading
Loading