feat(device-manager): trust Hub owner scopes

This commit is contained in:
Codex
2026-08-13 11:37:36 +03:00
parent 1c5246afe8
commit 117bfe0c3a
3 changed files with 171 additions and 18 deletions
@@ -8,6 +8,7 @@ export function createDeviceManagerAuth({
env = process.env,
fetchImpl = fetch,
now = Date.now,
internalToken: providedInternalToken,
} = {}) {
const authRequired = booleanValue(
env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED,
@@ -17,7 +18,9 @@ export function createDeviceManagerAuth({
const launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
const internalToken = textValue(
env.NODEDC_INTERNAL_ACCESS_TOKEN || env.NODEDC_PLATFORM_SERVICE_TOKEN,
providedInternalToken
|| env.NODEDC_INTERNAL_ACCESS_TOKEN
|| env.NODEDC_PLATFORM_SERVICE_TOKEN,
"",
);
const sessionCookie = textValue(
@@ -66,6 +69,7 @@ export function createDeviceManagerAuth({
sessions.set(id, {
id,
user: handoff.user,
access: handoff.access,
launcherSessionId: handoff.launcherSessionId,
expiresAt: createdAt + sessionTtlMs,
validatedAt: createdAt,
@@ -116,6 +120,7 @@ export function createDeviceManagerAuth({
}
createSession(response, {
user: result.body.user,
access: result.body.access,
launcherSessionId: result.body.launcherSessionId ?? null,
});
return redirect(response, nextPath);
@@ -156,6 +161,7 @@ export function createDeviceManagerAuth({
const { response: upstream, body } = await session.validationInFlight;
if (upstream.ok && body?.ok === true && body.active === true) {
session.user = body.user || session.user;
session.access = body.access;
session.validatedAt = now();
return attachSession(request, session);
}
@@ -199,7 +205,7 @@ export function createDeviceManagerAuth({
});
return true;
}
const access = resolveAccess(session.user);
const access = resolveAccess(session.user, session.access, { allowLegacy: !authRequired });
if (!access.allowed) {
sendJson(response, 403, {
ok: false,
@@ -222,7 +228,9 @@ export function createDeviceManagerAuth({
function currentContext(request) {
const user = request.nodedcDeviceManagerSession?.user;
const access = request.nodedcDeviceManagerAccess ?? resolveAccess(user);
const trustedAccess = request.nodedcDeviceManagerSession?.access;
const access = request.nodedcDeviceManagerAccess
?? resolveAccess(user, trustedAccess, { allowLegacy: !authRequired });
if (!user || !access.allowed) return null;
const id = cleanOpaque(user.id || user.subject || user.sub);
if (!id) return null;
@@ -232,9 +240,6 @@ export function createDeviceManagerAuth({
.slice(0, 240);
const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
const userRef = `user:${id}`;
const ownerScopes = ["admin", "owner"].includes(access.hubRole)
? [{ scopeKind: "personal", ownerRef: userRef, displayName }]
: [];
return {
user: {
id,
@@ -247,7 +252,7 @@ export function createDeviceManagerAuth({
userRef,
hubRole: access.hubRole,
groupRefs: access.groups.map((group) => `group:${group}`),
ownerScopes,
ownerScopes: access.ownerScopes,
},
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
};
@@ -266,6 +271,7 @@ export function createDeviceManagerAuth({
return {
authRequired,
internalAccessConfigured: Boolean(internalToken),
serviceSlug,
authorize,
currentContext,
@@ -274,16 +280,19 @@ export function createDeviceManagerAuth({
};
}
function resolveAccess(user) {
function resolveAccess(user, trustedAccess, { allowLegacy = false } = {}) {
if (!user || typeof user !== "object") {
return { allowed: false, blocked: false, hubRole: "viewer", groups: [] };
return deniedAccess();
}
const groups = normalizedGroups(user);
if (groups.includes("nodedc:device-core:blocked")) {
return { allowed: false, blocked: true, hubRole: "viewer", groups };
return { ...deniedAccess(groups), blocked: true };
}
const id = cleanOpaque(user.id || user.subject || user.sub);
if (!id) return { allowed: false, blocked: false, hubRole: "viewer", groups };
if (!id) return deniedAccess(groups);
const claims = normalizeTrustedAccess(trustedAccess, id);
if (claims) return { ...claims, blocked: false, groups };
if (!allowLegacy) return deniedAccess(groups);
const hubRole = id === "user_root" || groups.includes("nodedc:superadmin")
? "owner"
: groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin")
@@ -291,7 +300,57 @@ function resolveAccess(user) {
: groups.includes("nodedc:device-core:viewer")
? "viewer"
: "member";
return { allowed: true, blocked: false, hubRole, groups };
const ownerScopes = ["admin", "owner"].includes(hubRole)
? [{
scopeKind: "personal",
ownerRef: `user:${id}`,
displayName: String(user.name || user.displayName || user.email || id).trim().slice(0, 240),
}]
: [];
return { allowed: true, blocked: false, hubRole, groups, ownerScopes };
}
function normalizeTrustedAccess(input, userId) {
if (!input || typeof input !== "object" || input.allowed !== true) return null;
const hubRole = ["viewer", "member", "admin", "owner"].includes(input.hubRole)
? input.hubRole
: null;
if (!hubRole || !Array.isArray(input.ownerScopes)) return null;
const ownerScopes = [];
for (const item of input.ownerScopes) {
if (!item || typeof item !== "object") return null;
const scopeKind = item.scopeKind === "company" || item.scopeKind === "personal"
? item.scopeKind
: null;
const ownerRef = cleanOpaque(item.ownerRef);
const validOwner = scopeKind === "personal"
? ownerRef === `user:${userId}`
: ownerRef?.startsWith("client:") && ownerRef.length > "client:".length;
if (!scopeKind || !validOwner) return null;
ownerScopes.push({
scopeKind,
ownerRef,
displayName: String(item.displayName || ownerRef).trim().slice(0, 240),
});
}
return {
allowed: true,
hubRole,
ownerScopes: [...new Map(ownerScopes.map((scope) => [
`${scope.scopeKind}\0${scope.ownerRef}`,
scope,
])).values()],
};
}
function deniedAccess(groups = []) {
return {
allowed: false,
blocked: false,
hubRole: "viewer",
groups,
ownerScopes: [],
};
}
function normalizedGroups(user) {
@@ -15,6 +15,22 @@ test("Launcher handoff becomes an opaque Device Manager session and trusted acto
return jsonResponse(200, {
ok: true,
launcherSessionId,
access: {
allowed: true,
hubRole: "owner",
ownerScopes: [
{
scopeKind: "company",
ownerRef: "client:client_dctouch",
displayName: "DC Touch",
},
{
scopeKind: "personal",
ownerRef: "user:user_root",
displayName: "DC SUDO",
},
],
},
user: {
id: "user_root",
email: "root@example.test",
@@ -53,11 +69,18 @@ test("Launcher handoff becomes an opaque Device Manager session and trusted acto
const context = auth.currentContext(request);
assert.equal(context.actor.userRef, "user:user_root");
assert.equal(context.actor.hubRole, "owner");
assert.deepEqual(context.actor.ownerScopes, [{
scopeKind: "personal",
ownerRef: "user:user_root",
displayName: "DC SUDO",
}]);
assert.deepEqual(context.actor.ownerScopes, [
{
scopeKind: "company",
ownerRef: "client:client_dctouch",
displayName: "DC Touch",
},
{
scopeKind: "personal",
ownerRef: "user:user_root",
displayName: "DC SUDO",
},
]);
assert.deepEqual(context.actor.groupRefs, [
"group:nodedc:device-core:admin",
"group:nodedc:superadmin",
@@ -76,6 +99,11 @@ test("invalid identity and explicit Device Core block never produce an actor", a
fetchImpl: async () => jsonResponse(200, {
ok: true,
launcherSessionId,
access: {
allowed: true,
hubRole: "admin",
ownerScopes: [],
},
user,
}),
});
@@ -97,6 +125,65 @@ test("invalid identity and explicit Device Core block never produce an actor", a
}
});
test("production auth fails closed when Launcher omits trusted Device Core access", async () => {
const auth = createDeviceManagerAuth({
env: productionEnv(),
fetchImpl: async () => jsonResponse(200, {
ok: true,
launcherSessionId,
user: {
id: "user_root",
email: "root@example.test",
name: "DC SUDO",
groups: ["nodedc:superadmin"],
},
}),
});
const handoff = mockResponse();
await auth.handleHandoff(
{ method: "GET", headers: {} },
handoff,
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
);
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
const response = mockResponse();
assert.equal(await auth.authorize(
{ method: "GET", headers: { cookie, accept: "application/json" } },
response,
new URL("https://device.example.test/api/device-manager/session"),
), true);
assert.equal(response.statusCode, 403);
assert.equal(JSON.parse(response.body).error, "device_manager_access_denied");
});
test("an injected file-backed token takes precedence over broad platform env tokens", async () => {
const calls = [];
const auth = createDeviceManagerAuth({
env: { ...productionEnv(), NODEDC_INTERNAL_ACCESS_TOKEN: "broad-platform-token" },
internalToken: "scoped-file-token",
fetchImpl: async (url, init) => {
calls.push({ url, init });
return jsonResponse(200, {
ok: true,
launcherSessionId,
access: { allowed: true, hubRole: "member", ownerScopes: [] },
user: {
id: "device-member",
email: "member@example.test",
name: "Device Member",
groups: ["nodedc:device-core:access"],
},
});
},
});
await auth.handleHandoff(
{ method: "GET", headers: {} },
mockResponse(),
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
);
assert.equal(calls[0].init.headers.Authorization, "Bearer scoped-file-token");
});
function productionEnv() {
return {
NODE_ENV: "production",
@@ -123,7 +123,14 @@ export function createDeviceManagerServer({
export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) {
const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false);
const auth = createDeviceManagerAuth({ env });
const launcherTokenFile = String(env.NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE || "").trim();
const launcherInternalToken = launcherTokenFile
? (await readFile(launcherTokenFile, "utf8")).trim()
: undefined;
const auth = createDeviceManagerAuth({ env, internalToken: launcherInternalToken });
if (auth.authRequired && !auth.internalAccessConfigured) {
throw new Error("device_manager_auth_token_file_required");
}
let coreClient;
if (localPreview) {
if (String(env.NODE_ENV || "").toLowerCase() === "production") {