feat: expose AI Workspace entitlements
This commit is contained in:
parent
5b23410549
commit
332e097116
|
|
@ -674,6 +674,62 @@ app.post("/api/internal/access/check", (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/api/ai-workspace/internal/v1/entitlements", (req, res) => {
|
||||||
|
if (!isInternalRequestAuthorized(req)) {
|
||||||
|
res.status(config.internalAccessToken ? 401 : 503).json({
|
||||||
|
ok: false,
|
||||||
|
error: config.internalAccessToken ? "ai_workspace_entitlement_unauthorized" : "internal_access_not_configured",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = controlPlaneStore.getSnapshot({ name: "AI Workspace entitlement adapter", source: "ai-workspace" });
|
||||||
|
const ownerLookup = aiWorkspaceEntitlementOwnerLookup(req.body);
|
||||||
|
const user = findInternalAccessUser(snapshot.data, ownerLookup);
|
||||||
|
const requestedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
schemaVersion: "ai-workspace.entitlement-response.v1",
|
||||||
|
source: "launcher-control-plane",
|
||||||
|
requestedAt,
|
||||||
|
appGrants: buildAiWorkspaceDeniedAppGrants("launcher_user_not_found", requestedAt),
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = resolveRequiredGroups(snapshot.data, user);
|
||||||
|
const apps = getAppsForUser(groups);
|
||||||
|
const adminScope = resolveAdminScope(buildAssistantGatewaySessionUser(user, groups), groups);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
schemaVersion: "ai-workspace.entitlement-response.v1",
|
||||||
|
source: "launcher-control-plane",
|
||||||
|
requestedAt,
|
||||||
|
appGrants: buildAiWorkspaceAppGrants({
|
||||||
|
data: snapshot.data,
|
||||||
|
user,
|
||||||
|
groups,
|
||||||
|
apps,
|
||||||
|
adminScope,
|
||||||
|
payload: req.body,
|
||||||
|
requestedAt,
|
||||||
|
}),
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
authentikUserId: user.authentikUserId ?? null,
|
||||||
|
globalStatus: user.globalStatus,
|
||||||
|
groups,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) => {
|
app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) => {
|
||||||
if (!isInternalRequestAuthorized(req)) {
|
if (!isInternalRequestAuthorized(req)) {
|
||||||
res.status(config.internalAccessToken ? 401 : 503).json({
|
res.status(config.internalAccessToken ? 401 : 503).json({
|
||||||
|
|
@ -3052,6 +3108,207 @@ function getSessionAccessState(session) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aiWorkspaceEntitlementOwnerLookup(payload) {
|
||||||
|
const source = isPlainRecord(payload) ? payload : {};
|
||||||
|
const owner = isPlainRecord(source.owner) ? source.owner : {};
|
||||||
|
const ownerKey = normalizeOptionalText(owner.ownerKey ?? owner.owner_key ?? source.ownerKey ?? source.owner_key);
|
||||||
|
const ownerKeyParts = parseAiWorkspaceOwnerKey(ownerKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: normalizeOptionalText(owner.userId ?? owner.user_id ?? source.userId ?? source.user_id) ?? ownerKeyParts.userId,
|
||||||
|
email: normalizeOptionalText(owner.email ?? source.email) ?? ownerKeyParts.email,
|
||||||
|
subject: normalizeOptionalText(owner.subject ?? owner.sub ?? source.subject ?? source.sub) ?? ownerKeyParts.subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAiWorkspaceOwnerKey(ownerKey) {
|
||||||
|
const text = normalizeOptionalText(ownerKey) ?? "";
|
||||||
|
if (text.startsWith("email:")) return { email: text.slice("email:".length), userId: null, subject: null };
|
||||||
|
if (text.startsWith("user:")) return { email: null, userId: text.slice("user:".length), subject: null };
|
||||||
|
return { email: null, userId: null, subject: text || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAiWorkspaceDeniedAppGrants(reason, requestedAt) {
|
||||||
|
return {
|
||||||
|
launcher: aiWorkspaceGrant({
|
||||||
|
appId: "launcher",
|
||||||
|
appTitle: "NODE.DC Launcher",
|
||||||
|
allowed: false,
|
||||||
|
reason,
|
||||||
|
requestedAt,
|
||||||
|
}),
|
||||||
|
engine: aiWorkspaceGrant({
|
||||||
|
appId: "engine",
|
||||||
|
appTitle: "NODE.DC Engine / InJoin",
|
||||||
|
allowed: false,
|
||||||
|
reason,
|
||||||
|
requestedAt,
|
||||||
|
}),
|
||||||
|
ops: aiWorkspaceGrant({
|
||||||
|
appId: "ops",
|
||||||
|
appTitle: "NODE.DC Ops / Tasker",
|
||||||
|
allowed: false,
|
||||||
|
reason,
|
||||||
|
requestedAt,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAiWorkspaceAppGrants({ data, user, groups, apps, adminScope, payload, requestedAt }) {
|
||||||
|
const launcherApp = apps.find((candidate) => candidate.slug === "launcher");
|
||||||
|
const opsApp = apps.find((candidate) => candidate.slug === "task-manager");
|
||||||
|
const engineApp = apps.find((candidate) => isEngineServiceSlug(candidate.slug));
|
||||||
|
const membership = resolveAiWorkspaceBestMembership(data, user);
|
||||||
|
const opsWorkspaceSlug = resolveAiWorkspaceOpsWorkspaceSlug(payload);
|
||||||
|
const opsServiceModules = aiWorkspaceAppAllowed(user, opsApp)
|
||||||
|
? resolveTaskManagerWorkspaceServiceModules(data, user, "task-manager", opsWorkspaceSlug)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const baseContext = {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
launcherUserStatus: user.globalStatus ?? "active",
|
||||||
|
groups,
|
||||||
|
membershipRole: membership?.role ?? null,
|
||||||
|
membershipStatus: membership?.status ?? null,
|
||||||
|
coreAssistantRole: membership?.coreAssistantRole ?? null,
|
||||||
|
};
|
||||||
|
const adminContext = {
|
||||||
|
...baseContext,
|
||||||
|
adminScope: adminScope?.isRoot
|
||||||
|
? "root"
|
||||||
|
: adminScope?.clientIds?.size
|
||||||
|
? "client"
|
||||||
|
: "none",
|
||||||
|
adminClientIds: adminScope?.clientIds ? Array.from(adminScope.clientIds).sort() : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
launcher: aiWorkspaceGrant({
|
||||||
|
appId: "launcher",
|
||||||
|
appTitle: "NODE.DC Launcher",
|
||||||
|
surface: "launcher",
|
||||||
|
allowed: aiWorkspaceAppAllowed(user, launcherApp),
|
||||||
|
reason: aiWorkspaceAccessReason(user, launcherApp),
|
||||||
|
scopes: aiWorkspaceLauncherScopes(adminScope),
|
||||||
|
context: adminContext,
|
||||||
|
requestedAt,
|
||||||
|
}),
|
||||||
|
engine: aiWorkspaceGrant({
|
||||||
|
appId: "engine",
|
||||||
|
appTitle: "NODE.DC Engine / InJoin",
|
||||||
|
surface: "engine",
|
||||||
|
allowed: aiWorkspaceAppAllowed(user, engineApp),
|
||||||
|
reason: aiWorkspaceAccessReason(user, engineApp),
|
||||||
|
scopes: ["engine:workspace:read", "engine:workflow:read"],
|
||||||
|
context: {
|
||||||
|
...baseContext,
|
||||||
|
serviceSlug: engineApp?.slug ?? null,
|
||||||
|
matchedGroups: engineApp?.matchedGroups ?? [],
|
||||||
|
},
|
||||||
|
requestedAt,
|
||||||
|
}),
|
||||||
|
ops: aiWorkspaceGrant({
|
||||||
|
appId: "ops",
|
||||||
|
appTitle: "NODE.DC Ops / Tasker",
|
||||||
|
surface: "ops",
|
||||||
|
allowed: aiWorkspaceAppAllowed(user, opsApp),
|
||||||
|
reason: aiWorkspaceAccessReason(user, opsApp),
|
||||||
|
scopes: [
|
||||||
|
"ops:project:read",
|
||||||
|
"ops:card:read",
|
||||||
|
"ops:card:create",
|
||||||
|
...(opsServiceModules.codex_agents ? ["ops:codex-agent:use"] : []),
|
||||||
|
],
|
||||||
|
context: {
|
||||||
|
...baseContext,
|
||||||
|
serviceSlug: opsApp?.slug ?? null,
|
||||||
|
opsWorkspaceSlug,
|
||||||
|
matchedGroups: opsApp?.matchedGroups ?? [],
|
||||||
|
serviceModules: opsServiceModules,
|
||||||
|
},
|
||||||
|
requestedAt,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiWorkspaceGrant({ appId, appTitle, surface, allowed, reason, scopes = [], context = {}, requestedAt }) {
|
||||||
|
return {
|
||||||
|
appId,
|
||||||
|
appTitle,
|
||||||
|
surface: surface ?? appId,
|
||||||
|
status: allowed ? "granted" : "denied",
|
||||||
|
granted: Boolean(allowed),
|
||||||
|
allowed: Boolean(allowed),
|
||||||
|
enabled: Boolean(allowed),
|
||||||
|
denied: !allowed,
|
||||||
|
reason: allowed ? "launcher_access_confirmed" : reason,
|
||||||
|
deniedReason: allowed ? null : reason,
|
||||||
|
deniedText: allowed ? null : "Доступ к модулю ограничен, обратитесь к администратору системы.",
|
||||||
|
scopes: allowed ? scopes : [],
|
||||||
|
context,
|
||||||
|
updatedAt: requestedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiWorkspaceAppAllowed(user, app) {
|
||||||
|
return user?.globalStatus === "active" && Boolean(app?.hasAccess) && (app.status ?? "disabled") === "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiWorkspaceAccessReason(user, app) {
|
||||||
|
if (!user) return "launcher_user_not_found";
|
||||||
|
if (user.globalStatus !== "active") return "launcher_user_blocked";
|
||||||
|
if (!app) return "launcher_service_not_found";
|
||||||
|
if (!app.hasAccess) return "launcher_service_access_denied";
|
||||||
|
if ((app.status ?? "disabled") !== "active") return "launcher_service_inactive";
|
||||||
|
return "launcher_access_confirmed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiWorkspaceLauncherScopes(adminScope) {
|
||||||
|
const scopes = ["launcher:access:read", "assistant:use"];
|
||||||
|
if (adminScope?.isRoot || adminScope?.clientIds?.size) {
|
||||||
|
scopes.push(
|
||||||
|
"launcher_admin_scope.any",
|
||||||
|
"launcher_admin_scope.can_manage_user",
|
||||||
|
"launcher_admin_scope.can_manage_membership",
|
||||||
|
"launcher:users:read",
|
||||||
|
"launcher:roles:read"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (adminScope?.isRoot) {
|
||||||
|
scopes.push("launcher_admin_scope.root", "launcher:service-catalog:manage");
|
||||||
|
}
|
||||||
|
return [...new Set(scopes)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAiWorkspaceBestMembership(data, user) {
|
||||||
|
if (!user?.id) return null;
|
||||||
|
return data.memberships
|
||||||
|
.filter((membership) => membership.userId === user.id && membership.status === "active")
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => assistantMembershipWeight(right) - assistantMembershipWeight(left))[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAiWorkspaceOpsWorkspaceSlug(payload) {
|
||||||
|
const source = isPlainRecord(payload) ? payload : {};
|
||||||
|
const activeContext = isPlainRecord(source.activeContext) ? source.activeContext : {};
|
||||||
|
const runContext = isPlainRecord(source.runContext) ? source.runContext : {};
|
||||||
|
const activeOps = isPlainRecord(activeContext.ops) ? activeContext.ops : {};
|
||||||
|
const runOps = isPlainRecord(runContext.contexts?.ops)
|
||||||
|
? runContext.contexts.ops
|
||||||
|
: isPlainRecord(runContext.ops)
|
||||||
|
? runContext.ops
|
||||||
|
: {};
|
||||||
|
return normalizeOptionalText(
|
||||||
|
runOps.opsWorkspaceSlug ??
|
||||||
|
runOps.workspaceSlug ??
|
||||||
|
activeOps.opsWorkspaceSlug ??
|
||||||
|
activeOps.workspaceSlug ??
|
||||||
|
source.opsWorkspaceSlug ??
|
||||||
|
source.workspaceSlug
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getAppsForUser(userGroups) {
|
function getAppsForUser(userGroups) {
|
||||||
const groupSet = new Set(userGroups);
|
const groupSet = new Set(userGroups);
|
||||||
const catalog = getAppCatalog();
|
const catalog = getAppCatalog();
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,16 @@ describe("assistant-ready Launcher admin routes", () => {
|
||||||
expect(builderBlock).toContain("hasAlternativeAssistantAdminPath");
|
expect(builderBlock).toContain("hasAlternativeAssistantAdminPath");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes AI Workspace entitlements only through internal auth", () => {
|
||||||
|
const block = sourceBlock('app.post("/api/ai-workspace/internal/v1/entitlements"', 'app.post("/api/internal/tasker/invite-requests"');
|
||||||
|
|
||||||
|
expect(block).toContain("isInternalRequestAuthorized(req)");
|
||||||
|
expect(block).toContain("aiWorkspaceEntitlementOwnerLookup(req.body)");
|
||||||
|
expect(block).toContain("buildAiWorkspaceAppGrants");
|
||||||
|
expect(block).toContain('schemaVersion: "ai-workspace.entitlement-response.v1"');
|
||||||
|
expect(block).toContain("appGrants");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps user profile mutation guarded, idempotent, audited, and scoped", () => {
|
it("keeps user profile mutation guarded, idempotent, audited, and scoped", () => {
|
||||||
const block = sourceBlock('app.patch("/api/admin/users/:userId/profile"', 'app.delete("/api/admin/users/:userId"');
|
const block = sourceBlock('app.patch("/api/admin/users/:userId/profile"', 'app.delete("/api/admin/users/:userId"');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue