Files
pikasTech-HWLAB/internal/cloud/openfga-authorization.ts
T
2026-06-05 17:23:56 +08:00

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);
}
}