feat(device-manager): trust Hub owner scopes
This commit is contained in:
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user