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.

463 lines
14 KiB

import {
PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_FALLBACK_DENY
} from '$libs/perm';
import type { StandardSchemaV1 } from '$libs/standard-schema';
import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http';
import {
PERMISSION_ACTIVE_EVENT_BATCH,
PERMISSION_ACTIVE_EVENT_HYDRATE,
PERMISSION_ACTIVE_EVENT_INVALIDATE,
PERMISSION_CLIENT_DIAGNOSTIC_EVENTS,
PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS,
PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS,
PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS,
PERMISSION_CLIENT_PATH_BATCH,
PERMISSION_CLIENT_PATH_CHECK,
PERMISSION_CLIENT_PATH_EXPLAIN,
PERMISSION_CLIENT_PATH_WHAT,
PERMISSION_CLIENT_KEY_SEPARATOR,
PERMISSION_CLIENT_SCOPE_PREFIX,
PERMISSION_DECISION_REASON_REMOTE_BATCH_FAILED,
PERMISSION_DECISION_REASON_REMOTE_CHECK_FAILED,
PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX,
PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX,
PERMISSION_HTTP_CREDENTIALS_INCLUDE,
PERMISSION_METHOD_DECISION_KEY,
PERMISSION_METHOD_CHECK,
PERMISSION_METHOD_CAN,
PERMISSION_METHOD_EXPLAIN,
PERMISSION_METHOD_SUBSCRIBE,
PERMISSION_METHOD_WHAT,
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 {
createPermissionClientDiagnostics,
emitPermissionClientDiagnostic
} from './diagnostics.ts';
import { PermDisposedError, PermRemoteRequestError } from './errors.ts';
import { disposedPermissionsMessage } from './helpers.ts';
import { permissionDecisionKey, stablePermissionStringify } from '$libs/svrs/perm';
import type {
PermissionClient,
PermissionClientBatchInput,
PermissionClientCheckInput,
PermissionClientOptions,
PermissionSnapshot
} from './types.ts';
import type { ExplainResult, PermissionDecision } from '$libs/perm';
const PERMISSION_JSON_PASSTHROUGH_SCHEMA: StandardSchemaV1<unknown, unknown> = {
'~standard': {
version: 1,
vendor: 'active-perm',
validate(value) {
return { value };
}
}
};
interface CacheEntry {
readonly decision: PermissionDecision;
readonly expiresAt: number;
}
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>,
schema: PERMISSION_JSON_PASSTHROUGH_SCHEMA
});
if (response.ok) return response.value as T;
const status = 'status' in response ? response.status : undefined;
throw new PermRemoteRequestError(
`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${status ?? url}`,
url,
status
);
}
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 PermRemoteRequestError(
`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}`,
url,
response.status
);
}
return readPermissionJson<T>(response, url);
}
async function readPermissionJson<T>(response: Response, url: string): Promise<T> {
const contentType = response.headers.get(HTTP_HEADER_CONTENT_TYPE);
if (contentType && !contentType.toLowerCase().includes(HTTP_CONTENT_TYPE_JSON)) {
throw new PermRemoteRequestError(
`${PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX}${url}`,
url,
response.status
);
}
try {
return (await response.json()) as T;
} catch (_error) {
throw new PermRemoteRequestError(
`${PERMISSION_ERROR_MSG_RESPONSE_NOT_JSON_PREFIX}${url}`,
url,
response.status
);
}
}
export function createPermissionClient(options: PermissionClientOptions): PermissionClient {
const clock = options.clock ?? systemClock;
const diagnostics = createPermissionClientDiagnostics(options.logger);
const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS;
const nonAllowCacheTtlMs =
options.nonAllowCacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_NON_ALLOW_CACHE_TTL_MS;
const remoteFailureBackoffMs =
options.remoteFailureBackoffMs ?? PERMISSION_CLIENT_DEFAULT_REMOTE_FAILURE_BACKOFF_MS;
const cache = new Map<string, CacheEntry>();
const failures = new Map<string, CacheEntry>();
const pending = new Map<string, Promise<PermissionDecision>>();
const listeners = new Set<(snapshot: PermissionSnapshot) => void>();
let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} };
let generation = 0;
let disposed = false;
function now(): number {
return clock.now();
}
function ensureLive(method: string): void {
if (disposed) throw new PermDisposedError(disposedPermissionsMessage(method));
}
function emit(): void {
for (const listener of listeners) listener(currentSnapshot);
}
function remoteDecisionKey(input: PermissionClientCheckInput): string {
return permissionDecisionKey(input);
}
function resolveScopeKey(): string | undefined {
const configured =
typeof options.scopeKey === 'function' ? options.scopeKey() : options.scopeKey;
if (configured !== undefined && configured.length > 0) return configured;
if (currentSnapshot.actor === undefined) return undefined;
return stablePermissionStringify(currentSnapshot.actor);
}
function decisionKeyForScope(
input: PermissionClientCheckInput,
scope: string | undefined
): string {
const base = remoteDecisionKey(input);
if (scope === undefined) return base;
return [PERMISSION_CLIENT_SCOPE_PREFIX, stablePermissionStringify(scope), base].join(
PERMISSION_CLIENT_KEY_SEPARATOR
);
}
function decisionKey(input: PermissionClientCheckInput): string {
return decisionKeyForScope(input, resolveScopeKey());
}
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 remote = currentSnapshot.decisions?.[remoteDecisionKey(input)];
if (remote) return remote;
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(
key: string,
decision: PermissionDecision,
requestGeneration = generation
): void {
if (requestGeneration !== generation) return;
const ttl = resolveDecisionTtl(decision);
failures.delete(key);
if (ttl <= 0) return;
cache.set(key, { decision, expiresAt: now() + ttl });
currentSnapshot = {
...currentSnapshot,
decisions: {
...(currentSnapshot.decisions ?? {}),
[key]: decision
}
};
emit();
}
function resolveDecisionTtl(decision: PermissionDecision): number {
if (decision.effect === PERMISSION_EFFECT_ALLOW) return decision.ttl ?? cacheTtlMs;
if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) return 0;
return Math.min(cacheTtlMs, nonAllowCacheTtlMs);
}
function fallbackDecision(reason: string, error: unknown): PermissionDecision {
return {
effect: PERMISSION_EFFECT_INDETERMINATE,
reason,
fallback: PERMISSION_FALLBACK_DENY,
errors: [error]
};
}
async function check(input: PermissionClientCheckInput): Promise<PermissionDecision> {
ensureLive(PERMISSION_METHOD_CHECK);
const key = decisionKey(input);
const requestGeneration = generation;
const cached = cache.get(key);
if (cached && cached.expiresAt > now()) return cached.decision;
const failed = failures.get(key);
if (failed && failed.expiresAt > now()) return failed.decision;
const snapshotDecision = readSnapshotDecision(input);
if (snapshotDecision) {
const ttl = resolveDecisionTtl(snapshotDecision);
if (ttl > 0) cache.set(key, { decision: snapshotDecision, expiresAt: now() + ttl });
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(key, decision, requestGeneration);
return decision;
})
.catch((error) => {
options.onError?.(error);
emitPermissionClientDiagnostic(
diagnostics,
PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_CHECK_FAILED,
{ error, input }
);
const fallback = fallbackDecision(PERMISSION_DECISION_REASON_REMOTE_CHECK_FAILED, error);
if (requestGeneration === generation && remoteFailureBackoffMs > 0) {
failures.set(key, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs });
}
return fallback;
})
.finally(() => {
pending.delete(key);
});
pending.set(key, request);
return request;
}
async function batch(
input: PermissionClientBatchInput
): Promise<Record<string, PermissionDecision>> {
ensureLive(PERMISSION_ACTIVE_EVENT_BATCH);
const requestGeneration = generation;
const checks = input.checks.map((item) => ({
remoteKey: remoteDecisionKey(item),
localKey: decisionKey(item)
}));
try {
const result = await postJson<{ decisions: Record<string, PermissionDecision> }>(
options,
PERMISSION_CLIENT_PATH_BATCH,
{ [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks }
);
const decisions: Record<string, PermissionDecision> = {};
for (const { remoteKey, localKey } of checks) {
const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][remoteKey];
if (decision) {
setCached(localKey, decision, requestGeneration);
decisions[localKey] = decision;
}
}
return decisions;
} catch (error) {
options.onError?.(error);
emitPermissionClientDiagnostic(
diagnostics,
PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_BATCH_FAILED,
{ error, input }
);
const decisions: Record<string, PermissionDecision> = {};
for (const { localKey } of checks) {
const fallback = fallbackDecision(PERMISSION_DECISION_REASON_REMOTE_BATCH_FAILED, error);
decisions[localKey] = fallback;
if (requestGeneration === generation && remoteFailureBackoffMs > 0) {
failures.set(localKey, { decision: fallback, expiresAt: now() + remoteFailureBackoffMs });
}
}
return decisions;
}
}
async function what(input: {
readonly resource?: PermissionClientCheckInput['resource'];
readonly actions?: readonly string[];
readonly context?: PermissionClientCheckInput['context'];
}): Promise<Record<string, PermissionDecision>> {
ensureLive(PERMISSION_METHOD_WHAT);
const scope = resolveScopeKey();
const requestGeneration = generation;
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(
decisionKeyForScope({ action, resource: input.resource, context: input.context }, scope),
decision,
requestGeneration
);
}
return result[PERMISSION_RESPONSE_FIELD_ACTIONS];
} catch (error) {
options.onError?.(error);
emitPermissionClientDiagnostic(
diagnostics,
PERMISSION_CLIENT_DIAGNOSTIC_EVENTS.REMOTE_WHAT_FAILED,
{ error, input }
);
return {};
}
}
async function explain(input: PermissionClientCheckInput): Promise<ExplainResult | null> {
ensureLive(PERMISSION_METHOD_EXPLAIN);
try {
return await postJson<ExplainResult>(options, PERMISSION_CLIENT_PATH_EXPLAIN, input);
} catch (error) {
options.onError?.(error);
return null;
}
}
function hydrate(snapshot: PermissionSnapshot): void {
ensureLive(PERMISSION_ACTIVE_EVENT_HYDRATE);
generation += 1;
pending.clear();
currentSnapshot = snapshot;
cache.clear();
failures.clear();
for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) {
const ttl = resolveDecisionTtl(decision);
if (ttl > 0) cache.set(key, { decision, expiresAt: now() + ttl });
}
emit();
}
function invalidate(scope?: string): void {
ensureLive(PERMISSION_ACTIVE_EVENT_INVALIDATE);
generation += 1;
if (!scope) {
cache.clear();
failures.clear();
pending.clear();
currentSnapshot = { ...currentSnapshot, decisions: {} };
emit();
return;
}
const prefix = scopedKeyPrefix(scope);
for (const key of [...cache.keys()]) if (key.startsWith(prefix)) cache.delete(key);
for (const key of [...failures.keys()]) if (key.startsWith(prefix)) failures.delete(key);
for (const key of [...pending.keys()]) if (key.startsWith(prefix)) pending.delete(key);
const decisions = { ...(currentSnapshot.decisions ?? {}) };
for (const key of Object.keys(decisions)) if (key.startsWith(prefix)) delete decisions[key];
currentSnapshot = { ...currentSnapshot, decisions };
emit();
}
function scopedKeyPrefix(scope: string): string {
return [PERMISSION_CLIENT_SCOPE_PREFIX, stablePermissionStringify(scope), ''].join(
PERMISSION_CLIENT_KEY_SEPARATOR
);
}
return {
check,
async can(input) {
ensureLive(PERMISSION_METHOD_CAN);
return (await check(input)).effect === PERMISSION_EFFECT_ALLOW;
},
batch,
what,
explain,
hydrate,
snapshot: () => currentSnapshot,
invalidate,
subscribe(listener) {
ensureLive(PERMISSION_METHOD_SUBSCRIBE);
listeners.add(listener);
listener(currentSnapshot);
return () => listeners.delete(listener);
},
decisionKey(input) {
ensureLive(PERMISSION_METHOD_DECISION_KEY);
return decisionKey(input);
},
dispose() {
if (disposed) return;
disposed = true;
cache.clear();
failures.clear();
pending.clear();
listeners.clear();
}
};
}
const systemClock = {
now: () => Date.now()
};

Powered by TurnKey Linux.