You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

286 lines
8.6 KiB

import {
PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_FALLBACK_DENY
} from '$libs/perm';
import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http';
import { permissionDecisionKey } from './keys.ts';
import {
LOGGER_CATEGORY,
PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS,
PERMISSION_CLIENT_PATH_BATCH,
PERMISSION_CLIENT_PATH_CHECK,
PERMISSION_CLIENT_PATH_EXPLAIN,
PERMISSION_CLIENT_PATH_WHAT,
PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX,
PERMISSION_HTTP_CREDENTIALS_INCLUDE,
PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED,
PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED,
PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED,
PERMISSION_REQUEST_FIELD_ACTION,
PERMISSION_REQUEST_FIELD_CHECKS,
PERMISSION_REQUEST_FIELD_CONTEXT,
PERMISSION_REQUEST_FIELD_RESOURCE,
PERMISSION_RESPONSE_FIELD_ACTIONS,
PERMISSION_RESPONSE_FIELD_DECISIONS,
PERMISSION_SNAPSHOT_GLOBAL_POLICY
} from './consts.ts';
import type {
PermissionClient,
PermissionClientBatchInput,
PermissionClientCheckInput,
PermissionClientOptions,
PermissionSnapshot
} from './types.ts';
import type { ExplainResult, PermissionDecision } from '$libs/perm';
interface CacheEntry {
readonly decision: PermissionDecision;
readonly expiresAt: number;
}
function now(): number {
return Date.now();
}
function joinUrl(base: string, path: string): string {
return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
}
async function postJson<T>(
options: PermissionClientOptions,
path: string,
body: unknown
): Promise<T> {
const url = joinUrl(options.endpoint, path);
if (options.http) {
const response = await options.http.post(url, { body: body as Record<string, unknown> });
if (response.ok) return response.value as T;
throw new Error(`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${url}`);
}
const fetcher = options.fetcher ?? fetch.bind(globalThis);
const response = await fetcher(url, {
method: HTTP_METHOD_POST,
headers: { [HTTP_HEADER_CONTENT_TYPE]: HTTP_CONTENT_TYPE_JSON },
credentials: PERMISSION_HTTP_CREDENTIALS_INCLUDE,
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(
`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}`
);
}
return (await response.json()) as T;
}
export function createPermissionClient(options: PermissionClientOptions): PermissionClient {
const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS;
const cache = new Map<string, CacheEntry>();
const pending = new Map<string, Promise<PermissionDecision>>();
const listeners = new Set<(snapshot: PermissionSnapshot) => void>();
let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} };
function emit(): void {
for (const listener of listeners) listener(currentSnapshot);
}
function decisionKey(input: PermissionClientCheckInput): string {
return permissionDecisionKey(input);
}
function snapshotStillValid(snapshot: PermissionSnapshot): boolean {
return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now();
}
function readSnapshotDecision(input: PermissionClientCheckInput): PermissionDecision | undefined {
if (!snapshotStillValid(currentSnapshot)) return undefined;
const key = decisionKey(input);
const direct = currentSnapshot.decisions?.[key];
if (direct) return direct;
const global = currentSnapshot.global?.[input.action];
if (typeof global === 'boolean') {
return global
? { effect: PERMISSION_EFFECT_ALLOW, policy: PERMISSION_SNAPSHOT_GLOBAL_POLICY }
: {
effect: PERMISSION_EFFECT_DENY,
code: PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
reason: PERMISSION_SNAPSHOT_GLOBAL_POLICY
};
}
return global;
}
function setCached(input: PermissionClientCheckInput, decision: PermissionDecision): void {
const key = decisionKey(input);
const ttl =
decision.effect === PERMISSION_EFFECT_ALLOW && decision.ttl ? decision.ttl : cacheTtlMs;
cache.set(key, { decision, expiresAt: now() + ttl });
currentSnapshot = {
...currentSnapshot,
decisions: {
...(currentSnapshot.decisions ?? {}),
[key]: decision
}
};
emit();
}
async function check(input: PermissionClientCheckInput): Promise<PermissionDecision> {
const key = decisionKey(input);
const cached = cache.get(key);
if (cached && cached.expiresAt > now()) return cached.decision;
const snapshotDecision = readSnapshotDecision(input);
if (snapshotDecision) {
cache.set(key, { decision: snapshotDecision, expiresAt: now() + cacheTtlMs });
return snapshotDecision;
}
const inFlight = pending.get(key);
if (inFlight) return inFlight;
const request = postJson<PermissionDecision>(options, PERMISSION_CLIENT_PATH_CHECK, {
[PERMISSION_REQUEST_FIELD_ACTION]: input.action,
[PERMISSION_REQUEST_FIELD_RESOURCE]: input.resource,
[PERMISSION_REQUEST_FIELD_CONTEXT]: input.context
})
.then((decision) => {
setCached(input, decision);
return decision;
})
.catch((error) => {
options.onError?.(error);
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, {
error,
context: { input }
});
return {
effect: PERMISSION_EFFECT_INDETERMINATE,
reason: PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED,
fallback: PERMISSION_FALLBACK_DENY,
errors: [error]
} satisfies PermissionDecision;
})
.finally(() => {
pending.delete(key);
});
pending.set(key, request);
return request;
}
async function batch(
input: PermissionClientBatchInput
): Promise<Record<string, PermissionDecision>> {
try {
const result = await postJson<{ decisions: Record<string, PermissionDecision> }>(
options,
PERMISSION_CLIENT_PATH_BATCH,
{ [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks }
);
for (const item of input.checks) {
const key = decisionKey(item);
const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][key];
if (decision) setCached(item, decision);
}
return result[PERMISSION_RESPONSE_FIELD_DECISIONS];
} catch (error) {
options.onError?.(error);
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, {
error,
context: { input }
});
const decisions: Record<string, PermissionDecision> = {};
for (const item of input.checks) {
decisions[decisionKey(item)] = {
effect: PERMISSION_EFFECT_INDETERMINATE,
reason: PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED,
fallback: PERMISSION_FALLBACK_DENY,
errors: [error]
};
}
return decisions;
}
}
async function what(input: {
readonly resource?: PermissionClientCheckInput['resource'];
readonly actions?: readonly string[];
readonly context?: PermissionClientCheckInput['context'];
}): Promise<Record<string, PermissionDecision>> {
try {
const result = await postJson<{ actions: Record<string, PermissionDecision> }>(
options,
PERMISSION_CLIENT_PATH_WHAT,
input
);
for (const [action, decision] of Object.entries(result[PERMISSION_RESPONSE_FIELD_ACTIONS])) {
setCached({ action, resource: input.resource, context: input.context }, decision);
}
return result[PERMISSION_RESPONSE_FIELD_ACTIONS];
} catch (error) {
options.onError?.(error);
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, {
error,
context: { input }
});
return {};
}
}
async function explain(input: PermissionClientCheckInput): Promise<ExplainResult | null> {
try {
return await postJson<ExplainResult>(options, PERMISSION_CLIENT_PATH_EXPLAIN, input);
} catch (error) {
options.onError?.(error);
return null;
}
}
function hydrate(snapshot: PermissionSnapshot): void {
currentSnapshot = snapshot;
cache.clear();
for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) {
cache.set(key, { decision, expiresAt: now() + cacheTtlMs });
}
emit();
}
function invalidate(scope?: string): void {
if (!scope) {
cache.clear();
currentSnapshot = { ...currentSnapshot, decisions: {} };
emit();
return;
}
for (const key of [...cache.keys()]) if (key.includes(scope)) cache.delete(key);
const decisions = { ...(currentSnapshot.decisions ?? {}) };
for (const key of Object.keys(decisions)) if (key.includes(scope)) delete decisions[key];
currentSnapshot = { ...currentSnapshot, decisions };
emit();
}
return {
check,
async can(input) {
return (await check(input)).effect === PERMISSION_EFFECT_ALLOW;
},
batch,
what,
explain,
hydrate,
snapshot: () => currentSnapshot,
invalidate,
subscribe(listener) {
listeners.add(listener);
listener(currentSnapshot);
return () => listeners.delete(listener);
},
decisionKey
};
}

Powered by TurnKey Linux.