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