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
286 lines
8.6 KiB
|
5 months ago
|
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
|
||
|
|
};
|
||
|
|
}
|