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, env = process.env,
fetchImpl = fetch, fetchImpl = fetch,
now = Date.now, now = Date.now,
internalToken: providedInternalToken,
} = {}) { } = {}) {
const authRequired = booleanValue( const authRequired = booleanValue(
env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED, 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 launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl); const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
const internalToken = textValue( 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( const sessionCookie = textValue(
@@ -66,6 +69,7 @@ export function createDeviceManagerAuth({
sessions.set(id, { sessions.set(id, {
id, id,
user: handoff.user, user: handoff.user,
access: handoff.access,
launcherSessionId: handoff.launcherSessionId, launcherSessionId: handoff.launcherSessionId,
expiresAt: createdAt + sessionTtlMs, expiresAt: createdAt + sessionTtlMs,
validatedAt: createdAt, validatedAt: createdAt,
@@ -116,6 +120,7 @@ export function createDeviceManagerAuth({
} }
createSession(response, { createSession(response, {
user: result.body.user, user: result.body.user,
access: result.body.access,
launcherSessionId: result.body.launcherSessionId ?? null, launcherSessionId: result.body.launcherSessionId ?? null,
}); });
return redirect(response, nextPath); return redirect(response, nextPath);
@@ -156,6 +161,7 @@ export function createDeviceManagerAuth({
const { response: upstream, body } = await session.validationInFlight; const { response: upstream, body } = await session.validationInFlight;
if (upstream.ok && body?.ok === true && body.active === true) { if (upstream.ok && body?.ok === true && body.active === true) {
session.user = body.user || session.user; session.user = body.user || session.user;
session.access = body.access;
session.validatedAt = now(); session.validatedAt = now();
return attachSession(request, session); return attachSession(request, session);
} }
@@ -199,7 +205,7 @@ export function createDeviceManagerAuth({
}); });
return true; return true;
} }
const access = resolveAccess(session.user); const access = resolveAccess(session.user, session.access, { allowLegacy: !authRequired });
if (!access.allowed) { if (!access.allowed) {
sendJson(response, 403, { sendJson(response, 403, {
ok: false, ok: false,
@@ -222,7 +228,9 @@ export function createDeviceManagerAuth({
function currentContext(request) { function currentContext(request) {
const user = request.nodedcDeviceManagerSession?.user; 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; if (!user || !access.allowed) return null;
const id = cleanOpaque(user.id || user.subject || user.sub); const id = cleanOpaque(user.id || user.subject || user.sub);
if (!id) return null; if (!id) return null;
@@ -232,9 +240,6 @@ export function createDeviceManagerAuth({
.slice(0, 240); .slice(0, 240);
const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim(); const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
const userRef = `user:${id}`; const userRef = `user:${id}`;
const ownerScopes = ["admin", "owner"].includes(access.hubRole)
? [{ scopeKind: "personal", ownerRef: userRef, displayName }]
: [];
return { return {
user: { user: {
id, id,
@@ -247,7 +252,7 @@ export function createDeviceManagerAuth({
userRef, userRef,
hubRole: access.hubRole, hubRole: access.hubRole,
groupRefs: access.groups.map((group) => `group:${group}`), groupRefs: access.groups.map((group) => `group:${group}`),
ownerScopes, ownerScopes: access.ownerScopes,
}, },
profileUrl: new URL("/profile", launcherBaseUrl).toString(), profileUrl: new URL("/profile", launcherBaseUrl).toString(),
}; };
@@ -266,6 +271,7 @@ export function createDeviceManagerAuth({
return { return {
authRequired, authRequired,
internalAccessConfigured: Boolean(internalToken),
serviceSlug, serviceSlug,
authorize, authorize,
currentContext, currentContext,
@@ -274,16 +280,19 @@ export function createDeviceManagerAuth({
}; };
} }
function resolveAccess(user) { function resolveAccess(user, trustedAccess, { allowLegacy = false } = {}) {
if (!user || typeof user !== "object") { if (!user || typeof user !== "object") {
return { allowed: false, blocked: false, hubRole: "viewer", groups: [] }; return deniedAccess();
} }
const groups = normalizedGroups(user); const groups = normalizedGroups(user);
if (groups.includes("nodedc:device-core:blocked")) { 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); 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") const hubRole = id === "user_root" || groups.includes("nodedc:superadmin")
? "owner" ? "owner"
: groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin") : groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin")
@@ -291,7 +300,57 @@ function resolveAccess(user) {
: groups.includes("nodedc:device-core:viewer") : groups.includes("nodedc:device-core:viewer")
? "viewer" ? "viewer"
: "member"; : "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) { function normalizedGroups(user) {
@@ -15,6 +15,22 @@ test("Launcher handoff becomes an opaque Device Manager session and trusted acto
return jsonResponse(200, { return jsonResponse(200, {
ok: true, ok: true,
launcherSessionId, 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: { user: {
id: "user_root", id: "user_root",
email: "root@example.test", 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); const context = auth.currentContext(request);
assert.equal(context.actor.userRef, "user:user_root"); assert.equal(context.actor.userRef, "user:user_root");
assert.equal(context.actor.hubRole, "owner"); assert.equal(context.actor.hubRole, "owner");
assert.deepEqual(context.actor.ownerScopes, [{ assert.deepEqual(context.actor.ownerScopes, [
scopeKind: "personal", {
ownerRef: "user:user_root", scopeKind: "company",
displayName: "DC SUDO", ownerRef: "client:client_dctouch",
}]); displayName: "DC Touch",
},
{
scopeKind: "personal",
ownerRef: "user:user_root",
displayName: "DC SUDO",
},
]);
assert.deepEqual(context.actor.groupRefs, [ assert.deepEqual(context.actor.groupRefs, [
"group:nodedc:device-core:admin", "group:nodedc:device-core:admin",
"group:nodedc:superadmin", "group:nodedc:superadmin",
@@ -76,6 +99,11 @@ test("invalid identity and explicit Device Core block never produce an actor", a
fetchImpl: async () => jsonResponse(200, { fetchImpl: async () => jsonResponse(200, {
ok: true, ok: true,
launcherSessionId, launcherSessionId,
access: {
allowed: true,
hubRole: "admin",
ownerScopes: [],
},
user, 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() { function productionEnv() {
return { return {
NODE_ENV: "production", NODE_ENV: "production",
@@ -123,7 +123,14 @@ export function createDeviceManagerServer({
export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) { export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) {
const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false); 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; let coreClient;
if (localPreview) { if (localPreview) {
if (String(env.NODE_ENV || "").toLowerCase() === "production") { if (String(env.NODE_ENV || "").toLowerCase() === "production") {