diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 10116af..222979e 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -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) => { if (!isInternalRequestAuthorized(req)) { 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) { const groupSet = new Set(userGroups); const catalog = getAppCatalog(); diff --git a/src/entities/user/assistantAdminRoutes.contract.test.ts b/src/entities/user/assistantAdminRoutes.contract.test.ts index 913fd53..3ab4204 100644 --- a/src/entities/user/assistantAdminRoutes.contract.test.ts +++ b/src/entities/user/assistantAdminRoutes.contract.test.ts @@ -49,6 +49,16 @@ describe("assistant-ready Launcher admin routes", () => { 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", () => { const block = sourceBlock('app.patch("/api/admin/users/:userId/profile"', 'app.delete("/api/admin/users/:userId"');