feat: centralize application framework primitives
This commit is contained in:
@@ -4,7 +4,7 @@ import type { DecisionPolicy } from "./advanced.ts";
|
||||
export interface CatalogSource {
|
||||
/** File or package that declared this module, used in conflict messages. */
|
||||
source: string;
|
||||
module: AuthzModule;
|
||||
module: AuthzModule<never, never>;
|
||||
}
|
||||
|
||||
/** Structural equality for declaration metadata. Key order is irrelevant. */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getDb } from "@wrnexus/db";
|
||||
import { dbPermissionStore } from "./db.ts";
|
||||
import type { PermissionStore } from "./store.ts";
|
||||
|
||||
export type AuthzIdentityEvent = "signup" | "invitation";
|
||||
|
||||
interface DefaultRoleState {
|
||||
signup: string[];
|
||||
invitation: string[];
|
||||
store?: PermissionStore;
|
||||
}
|
||||
|
||||
const key = Symbol.for("@wrnexus/authz:default-roles:v1");
|
||||
|
||||
function state(): DefaultRoleState {
|
||||
const global = globalThis as Record<PropertyKey, unknown>;
|
||||
return (global[key] ??= { signup: [], invitation: [] }) as DefaultRoleState;
|
||||
}
|
||||
|
||||
export function setDefaultAuthzRoles(roles: Partial<DefaultRoleState>): void {
|
||||
state().signup = [...(roles.signup ?? [])];
|
||||
state().invitation = [...(roles.invitation ?? [])];
|
||||
}
|
||||
|
||||
/** Bind lifecycle role assignment to the same store used by authorization middleware. */
|
||||
export function setDefaultAuthzRoleStore(store: PermissionStore): void {
|
||||
state().store = store;
|
||||
}
|
||||
|
||||
/** Assign configured identity lifecycle roles through the framework-owned store. */
|
||||
export async function assignDefaultAuthzRoles(
|
||||
subjectId: string,
|
||||
event: AuthzIdentityEvent,
|
||||
): Promise<void> {
|
||||
if (!subjectId) return;
|
||||
const roles = state()[event];
|
||||
if (!roles.length) return;
|
||||
const store = state().store ?? dbPermissionStore(getDb());
|
||||
await Promise.all(roles.map((role) => store.assignRole(subjectId, role)));
|
||||
}
|
||||
@@ -157,8 +157,11 @@ export {
|
||||
guardPermission,
|
||||
filterCan,
|
||||
AUTHZ_LOCALS_KEY,
|
||||
getRequestAuthorization,
|
||||
} from "./middleware.ts";
|
||||
export type { GuardOptions } from "./middleware.ts";
|
||||
export type { GuardOptions, AuthorizationResponses, RequestAuthorization } from "./middleware.ts";
|
||||
export { defineAuthorizedHandler, defineOwnedResource } from "./resource.ts";
|
||||
export type { AuthorizedHandlerOptions, OwnedResourceDefinition } from "./resource.ts";
|
||||
export type {
|
||||
AuthzScope,
|
||||
AuthzCatalog,
|
||||
@@ -169,3 +172,7 @@ export type {
|
||||
} from "./types.ts";
|
||||
export type { AuthorizeDecisionOptions } from "./advanced.ts";
|
||||
export { generatePermissionTypes } from "./codegen.ts";
|
||||
export { authzPlugin } from "./plugin.ts";
|
||||
export type { AuthzConfig } from "./plugin.ts";
|
||||
export { assignDefaultAuthzRoles, setDefaultAuthzRoles } from "./defaults.ts";
|
||||
export type { AuthzIdentityEvent } from "./defaults.ts";
|
||||
|
||||
@@ -10,6 +10,27 @@ import type { AuthzScope } from "./types.ts";
|
||||
*/
|
||||
export const AUTHZ_LOCALS_KEY = "_authz";
|
||||
|
||||
export interface AuthorizationResponses {
|
||||
forbidden(decision?: AuthorizationDecision, options?: { exposeReason?: boolean }): Response;
|
||||
notFoundOrForbidden(options?: {
|
||||
mayDiscover?: boolean;
|
||||
decision?: AuthorizationDecision;
|
||||
exposeReason?: boolean;
|
||||
}): Response;
|
||||
}
|
||||
|
||||
export interface RequestAuthorization extends AuthorizationResponses {
|
||||
can(permission: string, resource?: unknown): Promise<boolean>;
|
||||
decide(permission: string, resource?: unknown): Promise<AuthorizationDecision>;
|
||||
}
|
||||
|
||||
declare module "@wrnexus/core" {
|
||||
interface Context {
|
||||
/** Installed by authzMiddleware for handlers that prefer context-local authorization. */
|
||||
authz?: RequestAuthorization;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestAuthz {
|
||||
resolver: AuthzResolver;
|
||||
/**
|
||||
@@ -49,10 +70,39 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware {
|
||||
byValue: new Map(),
|
||||
};
|
||||
ctx.locals[AUTHZ_LOCALS_KEY] = request;
|
||||
ctx.authz = {
|
||||
can: (permission, resource) => can(ctx, permission, resource),
|
||||
decide: (permission, resource) => decideFor(ctx, permission, resource),
|
||||
forbidden: (decision, responseOptions) =>
|
||||
forbiddenResponse(decision, responseOptions?.exposeReason),
|
||||
notFoundOrForbidden: (responseOptions = {}) =>
|
||||
responseOptions.mayDiscover
|
||||
? Response.json(
|
||||
{ ok: false, error: "Not Found" },
|
||||
{ status: 404, headers: NO_STORE_HEADERS },
|
||||
)
|
||||
: forbiddenResponse(responseOptions.decision, responseOptions.exposeReason),
|
||||
};
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
function forbiddenResponse(decision?: AuthorizationDecision, exposeReason = false): Response {
|
||||
return Response.json(
|
||||
exposeReason
|
||||
? { ok: false, error: "Forbidden", reason: decision?.reason, policy: decision?.policy }
|
||||
: { ok: false, error: "Forbidden" },
|
||||
{ status: 403, headers: NO_STORE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
export function getRequestAuthorization(ctx: Context): RequestAuthorization {
|
||||
if (!ctx.authz) {
|
||||
throw new Error("WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request.");
|
||||
}
|
||||
return ctx.authz;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the tenant from the context at decision time, not at middleware time:
|
||||
* a request that switches tenant mid-flight must not keep the old scope.
|
||||
@@ -236,10 +286,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
|
||||
reason: "Resource unavailable",
|
||||
at: Date.now(),
|
||||
});
|
||||
return Response.json(
|
||||
{ ok: false, error: "Forbidden" },
|
||||
{ status: 403, headers: NO_STORE_HEADERS },
|
||||
);
|
||||
return forbiddenResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,12 +311,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
options.exposeReason
|
||||
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
|
||||
: { ok: false, error: "Forbidden" },
|
||||
{ status: 403, headers: NO_STORE_HEADERS },
|
||||
);
|
||||
return forbiddenResponse(result, options.exposeReason);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
||||
import { authzMigrationSql } from "./migrations.ts";
|
||||
import { setDefaultAuthzRoles } from "./defaults.ts";
|
||||
|
||||
export interface AuthzConfig {
|
||||
enabled?: boolean;
|
||||
declarations?: string;
|
||||
store?: "database" | "memory";
|
||||
migrations?: boolean;
|
||||
middleware?: boolean;
|
||||
strict?: boolean;
|
||||
defaultRoles?: {
|
||||
signup?: string[];
|
||||
invitation?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolvedAuthzConfig {
|
||||
enabled: boolean;
|
||||
store: "database" | "memory";
|
||||
migrations: boolean;
|
||||
middleware: boolean;
|
||||
strict: boolean;
|
||||
defaultRoles: { signup: string[]; invitation: string[] };
|
||||
driver: "sqlite" | "postgres" | "mysql";
|
||||
}
|
||||
|
||||
const moduleRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const runtimeExtension = basename(moduleRoot) === "dist" ? ".js" : ".ts";
|
||||
const middlewareEntry = join(moduleRoot, `runtime-middleware${runtimeExtension}`);
|
||||
const memoryMiddlewareEntry = join(moduleRoot, `runtime-memory-middleware${runtimeExtension}`);
|
||||
const metadataKey = "@wrnexus/authz:config";
|
||||
|
||||
function resolved(context: PluginContext): ResolvedAuthzConfig {
|
||||
return (
|
||||
(context.metadata.get(metadataKey) as ResolvedAuthzConfig | undefined) ?? {
|
||||
enabled: false,
|
||||
store: "database",
|
||||
migrations: false,
|
||||
middleware: false,
|
||||
strict: true,
|
||||
defaultRoles: { signup: [], invitation: [] },
|
||||
driver: "sqlite",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function authzPlugin() {
|
||||
return definePlugin({
|
||||
name: "@wrnexus/authz",
|
||||
version: "0.8.0",
|
||||
after: ["@wrnexus/auth"],
|
||||
configure(config, context) {
|
||||
const raw = (config.authz ?? {}) as AuthzConfig;
|
||||
const enabled = raw.enabled !== false && Boolean(config.authz);
|
||||
const db = config.db as { driver?: ResolvedAuthzConfig["driver"] } | undefined;
|
||||
const value: ResolvedAuthzConfig = {
|
||||
enabled,
|
||||
store: raw.store ?? "database",
|
||||
migrations: raw.migrations ?? enabled,
|
||||
middleware: raw.middleware ?? enabled,
|
||||
strict: raw.strict ?? true,
|
||||
defaultRoles: {
|
||||
signup: [...(raw.defaultRoles?.signup ?? [])],
|
||||
invitation: [...(raw.defaultRoles?.invitation ?? [])],
|
||||
},
|
||||
driver: db?.driver ?? "sqlite",
|
||||
};
|
||||
if (enabled && value.store === "database" && !config.db) {
|
||||
throw new Error("WRN-AUTHZ-CONFIG: authz.store='database' requires config.db");
|
||||
}
|
||||
context.metadata.set(metadataKey, value);
|
||||
setDefaultAuthzRoles(value.defaultRoles);
|
||||
config.authz = { ...raw, ...value };
|
||||
},
|
||||
middleware(context) {
|
||||
const value = resolved(context);
|
||||
if (!value.enabled || !value.middleware) return [];
|
||||
return [value.store === "database" ? middlewareEntry : memoryMiddlewareEntry];
|
||||
},
|
||||
migrations(context) {
|
||||
const value = resolved(context);
|
||||
if (!value.enabled || !value.migrations || value.store !== "database") return [];
|
||||
const sql = authzMigrationSql(value.driver);
|
||||
return [
|
||||
{
|
||||
id: "wrnexus-authz-001",
|
||||
source: `-- +up\n${sql.up.join(";\n")}\n;\n-- +down\n${sql.down.join(";\n")}\n;`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default authzPlugin;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthzModule } from "./types.ts";
|
||||
import type { AuthzModule, DefinedAuthzModule } from "./types.ts";
|
||||
|
||||
const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
|
||||
|
||||
@@ -6,7 +6,9 @@ const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
|
||||
* Validate and freeze one authorization declaration. Called from
|
||||
* `app/authz/<name>.ts` as the module's default export.
|
||||
*/
|
||||
export function defineAuthz(module: AuthzModule): AuthzModule {
|
||||
export function defineAuthz<Subject = any, Resource = any>(
|
||||
module: AuthzModule<Subject, Resource>,
|
||||
): DefinedAuthzModule<Subject, Resource> {
|
||||
const permissions = module.permissions ?? {};
|
||||
const roles = module.roles ?? {};
|
||||
const policies = module.policies ?? {};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { decideFor } from "./middleware.ts";
|
||||
|
||||
export interface AuthorizedHandlerOptions<Resource, Result extends Response = Response> {
|
||||
permission: string;
|
||||
resource?: (ctx: Context) => Resource | null | undefined | Promise<Resource | null | undefined>;
|
||||
exposeReason?: boolean;
|
||||
mayDiscover?: (ctx: Context) => boolean | Promise<boolean>;
|
||||
handle(ctx: Context & { resource: Resource }): Result | Promise<Result>;
|
||||
}
|
||||
|
||||
/** Define an API handler whose resource loading and permission check cannot be skipped. */
|
||||
export function defineAuthorizedHandler<Resource = undefined, Result extends Response = Response>(
|
||||
options: AuthorizedHandlerOptions<Resource, Result>,
|
||||
): (ctx: Context) => Promise<Response> {
|
||||
return async (ctx) => {
|
||||
if (!ctx.user) return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
const resource = options.resource ? await options.resource(ctx) : (undefined as Resource);
|
||||
if (options.resource && resource == null) {
|
||||
const mayDiscover = await options.mayDiscover?.(ctx);
|
||||
return mayDiscover
|
||||
? Response.json({ ok: false, error: "Not Found" }, { status: 404 })
|
||||
: Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const decision = await decideFor(ctx, options.permission, resource);
|
||||
if (!decision.allowed) {
|
||||
return ctx.authz!.forbidden(decision, { exposeReason: options.exposeReason });
|
||||
}
|
||||
ctx.resource = resource;
|
||||
return options.handle(ctx as Context & { resource: Resource });
|
||||
};
|
||||
}
|
||||
|
||||
export interface OwnedResourceDefinition<Resource> {
|
||||
name: string;
|
||||
owner(resource: Resource): string | null | undefined;
|
||||
permissions: {
|
||||
read?: string;
|
||||
create?: string;
|
||||
update?: string;
|
||||
delete?: string;
|
||||
readAny?: string;
|
||||
writeAny?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared ownership and override vocabulary for application resource modules. */
|
||||
export function defineOwnedResource<Resource>(definition: OwnedResourceDefinition<Resource>) {
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
attributes(resource: Resource) {
|
||||
return { ownerId: definition.owner(resource) };
|
||||
},
|
||||
isOwner(subjectId: string, resource: Resource) {
|
||||
const ownerId = definition.owner(resource);
|
||||
return Boolean(subjectId && ownerId && subjectId === ownerId);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getAuthzCatalog } from "./client.ts";
|
||||
import { setDefaultAuthzRoleStore } from "./defaults.ts";
|
||||
import { authzMiddleware } from "./middleware.ts";
|
||||
import { memoryPermissionStore } from "./store.ts";
|
||||
|
||||
/** Process-local authorization storage for development and stateless tests. */
|
||||
const store = memoryPermissionStore();
|
||||
setDefaultAuthzRoleStore(store);
|
||||
export default function frameworkMemoryAuthz(ctx: Context, next: Next) {
|
||||
return authzMiddleware({ catalog: getAuthzCatalog(), store, strict: true })(ctx, next);
|
||||
}
|
||||
import type { Context, Next } from "@wrnexus/core";
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Context, Next } from "@wrnexus/core";
|
||||
import { getDb, type Db } from "@wrnexus/db";
|
||||
import { getAuthzCatalog } from "./client.ts";
|
||||
import { dbPermissionStore, ensureAuthzTables } from "./db.ts";
|
||||
import { setDefaultAuthzRoleStore } from "./defaults.ts";
|
||||
import { authzMiddleware } from "./middleware.ts";
|
||||
|
||||
const initialized = new WeakMap<Db, Promise<void>>();
|
||||
|
||||
function initialize(db: Db): Promise<void> {
|
||||
let pending = initialized.get(db);
|
||||
if (!pending) {
|
||||
pending = ensureAuthzTables(db);
|
||||
initialized.set(db, pending);
|
||||
pending.catch(() => initialized.delete(db));
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Package-installed authorization middleware with lazy database resolution. */
|
||||
export default async function frameworkAuthz(ctx: Context, next: Next): Promise<Response> {
|
||||
const db = getDb();
|
||||
await initialize(db);
|
||||
const store = dbPermissionStore(db);
|
||||
setDefaultAuthzRoleStore(store);
|
||||
return authzMiddleware({
|
||||
catalog: getAuthzCatalog(),
|
||||
store,
|
||||
strict: true,
|
||||
})(ctx, next);
|
||||
}
|
||||
@@ -18,15 +18,24 @@ export interface AttributeMeta {
|
||||
}
|
||||
|
||||
/** One `app/authz/<name>.ts` declaration. */
|
||||
export interface AuthzModule {
|
||||
export interface AuthzModule<Subject = any, Resource = any> {
|
||||
permissions?: Record<string, PermissionMeta>;
|
||||
roles?: Record<string, string[]>;
|
||||
policies?: Record<string, DecisionPolicy<never, never>>;
|
||||
policies?: Record<string, DecisionPolicy<Subject, Resource>>;
|
||||
attributes?: Record<string, AttributeMeta>;
|
||||
/** permission id -> policy names that must pass for it. */
|
||||
bindings?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** A declaration after defineAuthz() has supplied all optional collections. */
|
||||
export type DefinedAuthzModule<Subject = any, Resource = any> = {
|
||||
permissions: Record<string, PermissionMeta>;
|
||||
roles: Record<string, string[]>;
|
||||
policies: Record<string, DecisionPolicy<Subject, Resource>>;
|
||||
attributes: Record<string, AttributeMeta>;
|
||||
bindings: Record<string, string[]>;
|
||||
};
|
||||
|
||||
/** The merged, frozen view of every declaration in the app. */
|
||||
export interface AuthzCatalog {
|
||||
permissions: ReadonlyMap<string, PermissionMeta>;
|
||||
|
||||
Reference in New Issue
Block a user