331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
const OPENFGA_MODEL_NAME = "hwlab-v02-openfga-authorization-v1";
|
|
const OPENFGA_CONTRACT_VERSION = "hwlab-openfga-authorization-v1";
|
|
const DEFAULT_TIMEOUT_MS = 1200;
|
|
const SUPPORTED_MODES = new Set(["off", "enforce"]);
|
|
|
|
export const HWLAB_TOOL_IDS = Object.freeze(["hwpod", "unidesk_ssh", "trans_cmd", "github_pr"]);
|
|
|
|
export function createOpenFgaAuthorizer(options = {}) {
|
|
return new OpenFgaAuthorizer(options);
|
|
}
|
|
|
|
export function openFgaUser(userId) {
|
|
return `user:${sanitizeObjectId(userId)}`;
|
|
}
|
|
|
|
export function openFgaObject(type, id) {
|
|
return `${sanitizeType(type)}:${sanitizeObjectId(id)}`;
|
|
}
|
|
|
|
export class OpenFgaAuthorizer {
|
|
constructor({ env = process.env, fetchImpl = fetch, now = () => new Date().toISOString(), configStore = null } = {}) {
|
|
this.env = env;
|
|
this.fetchImpl = fetchImpl;
|
|
this.now = now;
|
|
this.configStore = configStore;
|
|
this.mode = normalizeMode(env.HWLAB_OPENFGA_MODE);
|
|
this.apiUrl = normalizeBaseUrl(env.HWLAB_OPENFGA_API_URL);
|
|
this.token = textOr(env.HWLAB_OPENFGA_AUTHN_TOKEN ?? env.OPENFGA_API_TOKEN, "");
|
|
this.storeName = textOr(env.HWLAB_OPENFGA_STORE_NAME, "hwlab-v02");
|
|
this.timeoutMs = boundedTimeout(env.HWLAB_OPENFGA_TIMEOUT_MS);
|
|
this.bootstrapEnabled = textOr(env.HWLAB_OPENFGA_BOOTSTRAP, "1") !== "0";
|
|
this.storeId = textOr(env.HWLAB_OPENFGA_STORE_ID ?? env.FGA_STORE_ID, "");
|
|
this.modelId = textOr(env.HWLAB_OPENFGA_MODEL_ID ?? env.FGA_MODEL_ID, "");
|
|
this.readyCache = null;
|
|
}
|
|
|
|
async describe({ refresh = false } = {}) {
|
|
const readiness = await this.ensureReady({ refresh });
|
|
return {
|
|
contractVersion: OPENFGA_CONTRACT_VERSION,
|
|
mode: this.mode,
|
|
configured: Boolean(this.apiUrl),
|
|
ready: readiness.ready,
|
|
status: readiness.ready ? "ready" : this.mode === "off" ? "disabled" : "degraded",
|
|
apiUrlConfigured: Boolean(this.apiUrl),
|
|
storeName: this.storeName,
|
|
storeId: redactedId(readiness.storeId || this.storeId),
|
|
modelId: redactedId(readiness.modelId || this.modelId),
|
|
degradedReason: readiness.degradedReason,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async check({ actor = null, userId = "", relation = "", object = "" } = {}) {
|
|
const actorId = textOr(userId, actor?.id ?? "");
|
|
const rel = textOr(relation, "");
|
|
const obj = textOr(object, "");
|
|
if (!actorId || !rel || !obj) return decision({ mode: this.mode, allowed: false, decisionSource: "invalid-request", relation: rel, object: obj, userId: actorId });
|
|
if (this.mode === "off") return decision({ mode: this.mode, allowed: false, decisionSource: "openfga-disabled", relation: rel, object: obj, userId: actorId, degradedReason: "openfga_disabled" });
|
|
|
|
const readiness = await this.ensureReady();
|
|
if (!readiness.ready) {
|
|
return decision({
|
|
mode: this.mode,
|
|
allowed: false,
|
|
fgaAllowed: null,
|
|
decisionSource: "openfga-degraded",
|
|
relation: rel,
|
|
object: obj,
|
|
userId: actorId,
|
|
degradedReason: readiness.degradedReason
|
|
});
|
|
}
|
|
|
|
const fga = await this.checkOpenFga({ user: openFgaUser(actorId), relation: rel, object: obj, readiness });
|
|
if (fga.allowed) {
|
|
return decision({
|
|
mode: this.mode,
|
|
allowed: true,
|
|
fgaAllowed: true,
|
|
decisionSource: "openfga",
|
|
relation: rel,
|
|
object: obj,
|
|
userId: actorId,
|
|
storeId: readiness.storeId,
|
|
modelId: readiness.modelId,
|
|
degradedReason: null
|
|
});
|
|
}
|
|
const systemAdminObject = openFgaObject("system", "hwlab");
|
|
if (!(rel === "admin" && obj === systemAdminObject)) {
|
|
const admin = await this.checkOpenFga({ user: openFgaUser(actorId), relation: "admin", object: systemAdminObject, readiness });
|
|
if (admin.allowed) {
|
|
return decision({
|
|
mode: this.mode,
|
|
allowed: true,
|
|
fgaAllowed: true,
|
|
decisionSource: "openfga-admin",
|
|
relation: rel,
|
|
object: obj,
|
|
userId: actorId,
|
|
storeId: readiness.storeId,
|
|
modelId: readiness.modelId,
|
|
degradedReason: null
|
|
});
|
|
}
|
|
}
|
|
return decision({
|
|
mode: this.mode,
|
|
allowed: false,
|
|
fgaAllowed: false,
|
|
decisionSource: "openfga",
|
|
relation: rel,
|
|
object: obj,
|
|
userId: actorId,
|
|
storeId: readiness.storeId,
|
|
modelId: readiness.modelId,
|
|
degradedReason: fga.error ?? null
|
|
});
|
|
}
|
|
|
|
async writeTuple({ userId, relation, object } = {}) {
|
|
return this.writeTuples({ writes: [{ user: openFgaUser(userId), relation: textOr(relation, ""), object: textOr(object, "") }] });
|
|
}
|
|
|
|
async deleteTuple({ userId, relation, object } = {}) {
|
|
return this.writeTuples({ deletes: [{ user: openFgaUser(userId), relation: textOr(relation, ""), object: textOr(object, "") }] });
|
|
}
|
|
|
|
async writeTuples({ writes = [], deletes = [] } = {}) {
|
|
const sanitizedWrites = writes.map(sanitizeTuple).filter(Boolean);
|
|
const sanitizedDeletes = deletes.map(sanitizeTuple).filter(Boolean);
|
|
if (this.mode === "off") {
|
|
return { ok: false, mode: this.mode, status: 503, error: { code: "openfga_disabled", message: "OpenFGA is disabled" }, written: 0, deleted: 0 };
|
|
}
|
|
const readiness = await this.ensureReady();
|
|
if (!readiness.ready) {
|
|
return { ok: false, mode: this.mode, status: 503, error: { code: "openfga_degraded", message: readiness.degradedReason }, written: 0, deleted: 0 };
|
|
}
|
|
const body = {
|
|
...(sanitizedWrites.length > 0 ? { writes: { tuple_keys: sanitizedWrites, on_duplicate: "ignore" } } : {}),
|
|
...(sanitizedDeletes.length > 0 ? { deletes: { tuple_keys: sanitizedDeletes } } : {}),
|
|
authorization_model_id: readiness.modelId
|
|
};
|
|
const result = await this.request(`/stores/${encodeURIComponent(readiness.storeId)}/write`, { method: "POST", body });
|
|
return {
|
|
ok: result.ok,
|
|
mode: this.mode,
|
|
status: result.status,
|
|
written: sanitizedWrites.length,
|
|
deleted: sanitizedDeletes.length,
|
|
storeId: redactedId(readiness.storeId),
|
|
modelId: redactedId(readiness.modelId),
|
|
error: result.ok ? null : { code: "openfga_write_failed", message: result.message }
|
|
};
|
|
}
|
|
|
|
async ensureReady({ refresh = false } = {}) {
|
|
if (this.mode === "off") return { ready: false, storeId: this.storeId, modelId: this.modelId, degradedReason: "openfga_disabled" };
|
|
if (!this.apiUrl) return { ready: false, storeId: "", modelId: "", degradedReason: "openfga_api_url_missing" };
|
|
if (this.readyCache && !refresh) return this.readyCache;
|
|
|
|
const health = await this.request("/healthz", { method: "GET", allowNotJson: true });
|
|
if (!health.ok) return this.cacheReady({ ready: false, storeId: this.storeId, modelId: this.modelId, degradedReason: `openfga_health_failed:${health.status}` });
|
|
|
|
let storeId = this.storeId || await this.getConfigValue("openfga.storeId");
|
|
if (!storeId && this.bootstrapEnabled) {
|
|
const created = await this.request("/stores", { method: "POST", body: { name: this.storeName } });
|
|
if (created.ok) {
|
|
storeId = textOr(created.body?.id, "");
|
|
if (storeId) await this.setConfigValue("openfga.storeId", storeId);
|
|
}
|
|
}
|
|
if (!storeId) return this.cacheReady({ ready: false, storeId: "", modelId: "", degradedReason: "openfga_store_id_missing" });
|
|
this.storeId = storeId;
|
|
|
|
let modelId = this.modelId || await this.getConfigValue("openfga.modelId");
|
|
if (!modelId && this.bootstrapEnabled) {
|
|
const written = await this.request(`/stores/${encodeURIComponent(storeId)}/authorization-models`, { method: "POST", body: HWLAB_AUTHORIZATION_MODEL });
|
|
if (written.ok) {
|
|
modelId = textOr(written.body?.authorization_model_id ?? written.body?.id, "");
|
|
if (modelId) await this.setConfigValue("openfga.modelId", modelId);
|
|
} else {
|
|
return this.cacheReady({ ready: false, storeId, modelId: "", degradedReason: `openfga_model_write_failed:${written.status}` });
|
|
}
|
|
}
|
|
if (!modelId) return this.cacheReady({ ready: false, storeId, modelId: "", degradedReason: "openfga_model_id_missing" });
|
|
this.modelId = modelId;
|
|
return this.cacheReady({ ready: true, storeId, modelId, degradedReason: null });
|
|
}
|
|
|
|
cacheReady(value) {
|
|
this.readyCache = value;
|
|
return value;
|
|
}
|
|
|
|
async checkOpenFga({ user, relation, object, readiness }) {
|
|
const result = await this.request(`/stores/${encodeURIComponent(readiness.storeId)}/check`, {
|
|
method: "POST",
|
|
body: { tuple_key: { user, relation, object }, authorization_model_id: readiness.modelId }
|
|
});
|
|
if (!result.ok) return { allowed: false, error: `openfga_check_failed:${result.status}` };
|
|
return { allowed: result.body?.allowed === true, error: null };
|
|
}
|
|
|
|
async request(path, { method = "GET", body = null, allowNotJson = false } = {}) {
|
|
const headers = { accept: "application/json" };
|
|
if (body !== null) headers["content-type"] = "application/json";
|
|
if (this.token) headers.authorization = `Bearer ${this.token}`;
|
|
try {
|
|
const response = await fetchWithTimeout(this.fetchImpl, `${this.apiUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
...(body !== null ? { body: JSON.stringify(body) } : {})
|
|
}, this.timeoutMs);
|
|
const text = await response.text();
|
|
const json = allowNotJson ? parseJson(text, {}) : parseJson(text, {});
|
|
return { ok: response.status < 400, status: response.status, body: json, message: textOr(json?.message ?? json?.error_description ?? json?.error, text) };
|
|
} catch (error) {
|
|
return { ok: false, status: 0, body: {}, message: error?.message ?? String(error) };
|
|
}
|
|
}
|
|
|
|
async getConfigValue(key) {
|
|
return textOr(await this.configStore?.getAccessConfig?.(key), "");
|
|
}
|
|
|
|
async setConfigValue(key, value) {
|
|
await this.configStore?.setAccessConfig?.({ key, value, updatedAt: this.now() });
|
|
}
|
|
}
|
|
|
|
const HWLAB_AUTHORIZATION_MODEL = Object.freeze({
|
|
schema_version: "1.1",
|
|
type_definitions: [
|
|
{ type: "user" },
|
|
directTypeDefinition("system", ["admin", "access_manager", "can_manage_users", "can_manage_tools"]),
|
|
directTypeDefinition("agent_session", ["owner", "viewer", "operator"]),
|
|
directTypeDefinition("tool", ["can_use"])
|
|
]
|
|
});
|
|
|
|
function directTypeDefinition(type, relations) {
|
|
return {
|
|
type,
|
|
relations: Object.fromEntries(relations.map((relation) => [relation, { this: {} }])),
|
|
metadata: {
|
|
relations: Object.fromEntries(relations.map((relation) => [relation, { directly_related_user_types: [{ type: "user" }] }]))
|
|
}
|
|
};
|
|
}
|
|
|
|
function decision(input = {}) {
|
|
return {
|
|
contractVersion: OPENFGA_CONTRACT_VERSION,
|
|
mode: input.mode,
|
|
allowed: input.allowed === true,
|
|
fgaAllowed: input.fgaAllowed ?? null,
|
|
mismatch: input.mismatch === true,
|
|
decisionSource: textOr(input.decisionSource, "unknown"),
|
|
userId: textOr(input.userId, ""),
|
|
relation: textOr(input.relation, ""),
|
|
object: textOr(input.object, ""),
|
|
storeId: redactedId(input.storeId),
|
|
modelId: redactedId(input.modelId),
|
|
degradedReason: input.degradedReason ?? null,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function sanitizeTuple(tuple = {}) {
|
|
const user = textOr(tuple.user, "");
|
|
const relation = textOr(tuple.relation, "");
|
|
const object = textOr(tuple.object, "");
|
|
if (!user || !relation || !object) return null;
|
|
return { user, relation, object };
|
|
}
|
|
|
|
function normalizeMode(value) {
|
|
const mode = textOr(value, "off").toLowerCase();
|
|
if (mode === "shadow") return "enforce";
|
|
return SUPPORTED_MODES.has(mode) ? mode : "off";
|
|
}
|
|
|
|
function sanitizeType(value) {
|
|
return textOr(value, "object").replace(/[^A-Za-z0-9_]/gu, "_");
|
|
}
|
|
|
|
function sanitizeObjectId(value) {
|
|
return textOr(value, "unknown").replace(/[^A-Za-z0-9_.:-]/gu, "_");
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
const text = textOr(value, "").replace(/\/+$/u, "");
|
|
return /^https?:\/\//u.test(text) ? text : "";
|
|
}
|
|
|
|
function boundedTimeout(value) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS, 100), 10000);
|
|
}
|
|
|
|
function redactedId(value) {
|
|
const text = textOr(value, "");
|
|
if (!text) return null;
|
|
if (text.length <= 12) return text;
|
|
return `${text.slice(0, 8)}...${text.slice(-4)}`;
|
|
}
|
|
|
|
function textOr(value, fallback) {
|
|
const text = String(value ?? "").trim();
|
|
return text || fallback;
|
|
}
|
|
|
|
function parseJson(value, fallback) {
|
|
if (!value) return fallback;
|
|
if (typeof value === "object") return value;
|
|
try { return JSON.parse(String(value)); } catch { return fallback; }
|
|
}
|
|
|
|
async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
return await fetchImpl(url, { ...options, signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|