Refine launcher admin access workflows

This commit is contained in:
DCCONSTRUCTIONS
2026-05-27 11:46:29 +03:00
parent 3b006303a2
commit ec021d1a95
6 changed files with 676 additions and 95 deletions
+38
View File
@@ -41,6 +41,7 @@ const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejecte
const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]);
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
const engineServiceRoles = new Set(["viewer", "member"]);
const syncStates = new Set(["synced", "pending", "error", "disabled"]);
const publicPoolClientId = "client_public_pool";
const protectedLauncherUserIds = new Set(["user_root"]);
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
@@ -2043,6 +2044,42 @@ export function createControlPlaneStore({ projectRoot }) {
return { syncStatus, data };
}
async function updateSyncStatus(syncId, patch, identity) {
const data = readData();
const actor = resolveActor(data, identity);
const syncStatus = findById(data.syncStatuses, syncId, "sync status");
const now = isoNow();
if ("state" in (patch ?? {})) {
syncStatus.state = pickEnum(patch.state, syncStates, syncStatus.state);
}
if ("error" in (patch ?? {})) {
syncStatus.error = nullableString(patch.error);
}
if ("objectName" in (patch ?? {})) {
syncStatus.objectName = optionalString(patch.objectName, syncStatus.objectName);
}
if (syncStatus.state === "synced") {
syncStatus.lastSyncAt = nullableString(patch?.lastSyncAt) ?? now;
syncStatus.error = null;
}
syncStatus.updatedAt = now;
addAuditEvent(data, actor, {
action: "Статус sync обновлён",
objectType: syncStatus.objectType,
objectName: syncStatus.objectName,
result: syncStatus.state === "error" ? "error" : "success",
details: `Target: ${syncStatus.target}; State: ${syncStatus.state}`,
});
await writeData(data);
return { syncStatus, data };
}
async function markUserAuthentikProvisioned(userId, provisioning, identity) {
const data = readData();
const actor = resolveActor(data, identity);
@@ -2191,6 +2228,7 @@ export function createControlPlaneStore({ projectRoot }) {
replaceData,
reorderServices,
retrySync,
updateSyncStatus,
markUserAuthentikProvisioned,
recordTaskManagerProjectMembership,
recordTaskManagerWorkspaceMembership,
+152 -3
View File
@@ -2196,11 +2196,50 @@ app.post("/api/admin/access/service-modules", requireLauncherAdmin, asyncRoute(a
}));
app.post("/api/admin/sync/:syncId/retry", requireLauncherAdmin, requireRootLauncherAdmin, asyncRoute(async (req, res) => {
const result = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
publishControlPlaneEvent("admin.sync.retry");
res.json(result);
const retryResult = await controlPlaneStore.retrySync(req.params.syncId, req.nodedcSession.user);
const affectedUserIds = resolveSyncStatusAffectedUserIds(retryResult.data, retryResult.syncStatus);
if (!authentikSyncClient.isConfigured()) {
const result = await controlPlaneStore.updateSyncStatus(
req.params.syncId,
{ state: "error", error: "Authentik API не настроен на сервере launcher." },
req.nodedcSession.user
);
publishControlPlaneEvent("admin.sync.retry.failed");
res.json(scopeAdminMutationResult(req, { ...result, ok: false, message: result.syncStatus.error }));
return;
}
try {
const syncResult = affectedUserIds.length
? await syncUsersToAuthentik(retryResult.data, affectedUserIds, req.nodedcSession.user)
: { data: retryResult.data, userIds: [] };
const result = await controlPlaneStore.updateSyncStatus(
req.params.syncId,
{ state: "synced", error: null },
req.nodedcSession.user
);
publishControlPlaneEvent("admin.sync.retry", syncResult.userIds);
res.json(scopeAdminMutationResult(req, { ...result, ok: true, syncedUserIds: syncResult.userIds }));
} catch (error) {
const message = error instanceof Error ? error.message : "Не удалось выполнить синхронизацию Authentik";
const result = await controlPlaneStore.updateSyncStatus(
req.params.syncId,
{ state: "error", error: message },
req.nodedcSession.user
);
publishControlPlaneEvent("admin.sync.retry.failed");
res.json(scopeAdminMutationResult(req, { ...result, ok: false, message }));
}
}));
app.get("/api/admin/sync/authentik/health", requireLauncherAdmin, requireRootLauncherAdmin, (req, res) => {
const snapshot = controlPlaneStore.getSnapshot(req.nodedcSession.user);
res.json(buildAuthentikSyncHealth(snapshot.data));
});
app.get("/api/admin/sync/authentik/plan", requireLauncherAdmin, requireRootLauncherAdmin, (_req, res) => {
res.json(controlPlaneStore.buildAuthentikSyncPlan());
});
@@ -2664,6 +2703,116 @@ async function syncUsersToAuthentik(data, userIds, identity) {
return { data: latestData, userIds: uniqueUserIds };
}
function buildAuthentikSyncHealth(data) {
const rows = data.syncStatuses.filter((syncStatus) => syncStatus.target === "authentik");
const errors = rows.filter((syncStatus) => syncStatus.state === "error");
const pending = rows.filter((syncStatus) => syncStatus.state === "pending");
const freshPending = pending.filter(isFreshPendingSyncStatus);
const stalePending = pending.filter((syncStatus) => !isFreshPendingSyncStatus(syncStatus));
const state = !authentikSyncClient.isConfigured()
? "not_configured"
: errors.length > 0
? "error"
: freshPending.length > 0
? "pending"
: "ok";
return {
configured: authentikSyncClient.isConfigured(),
state,
checkedAt: new Date().toISOString(),
counts: {
total: rows.length,
synced: rows.filter((syncStatus) => syncStatus.state === "synced").length,
pending: pending.length,
freshPending: freshPending.length,
stalePending: stalePending.length,
errors: errors.length,
disabled: rows.filter((syncStatus) => syncStatus.state === "disabled").length,
},
};
}
function isFreshPendingSyncStatus(syncStatus) {
if (syncStatus.state !== "pending") return false;
const timestamp = Date.parse(syncStatus.updatedAt ?? syncStatus.lastSyncAt ?? "");
if (!Number.isFinite(timestamp)) return false;
return Date.now() - timestamp <= 5 * 60 * 1000;
}
function resolveSyncStatusAffectedUserIds(data, syncStatus) {
if (!syncStatus || syncStatus.target !== "authentik") {
return [];
}
if (syncStatus.objectType === "user") {
return data.users.some((user) => user.id === syncStatus.objectId) ? [syncStatus.objectId] : [];
}
if (syncStatus.objectType === "group") {
return data.groups.find((group) => group.id === syncStatus.objectId)?.memberIds ?? [];
}
if (syncStatus.objectType === "client") {
return data.memberships.filter((membership) => membership.clientId === syncStatus.objectId).map((membership) => membership.userId);
}
if (syncStatus.objectType === "service") {
return resolveServiceSyncUserIds(data, syncStatus.objectId);
}
if (syncStatus.objectType === "grant") {
return resolveGrantSyncUserIds(data, syncStatus);
}
if (syncStatus.objectType === "invite") {
const invite = data.invites.find((candidate) => candidate.id === syncStatus.objectId);
return invite ? resolveUserIdsByEmail(data, invite.email) : resolveUserIdsByEmail(data, syncStatus.objectName);
}
return [];
}
function resolveServiceSyncUserIds(data, serviceId) {
const userIds = new Set();
for (const grant of data.grants.filter((candidate) => candidate.serviceId === serviceId && candidate.status === "active")) {
for (const userId of resolveGrantTargetUserIds(data, grant.targetType, grant.targetId)) {
userIds.add(userId);
}
}
for (const exception of data.exceptions.filter((candidate) => candidate.serviceId === serviceId)) {
userIds.add(exception.userId);
}
return [...userIds];
}
function resolveGrantSyncUserIds(data, syncStatus) {
const existingGrant = data.grants.find((grant) => grant.id === syncStatus.objectId);
if (existingGrant) {
return resolveGrantTargetUserIds(data, existingGrant.targetType, existingGrant.targetId);
}
const [serviceId, userId] = String(syncStatus.objectId).split(":");
if (serviceId && userId && data.users.some((user) => user.id === userId)) {
return [userId];
}
return resolveUserIdsByEmail(data, syncStatus.objectName);
}
function resolveUserIdsByEmail(data, value) {
const normalizedValue = String(value ?? "").toLowerCase();
if (!normalizedValue) return [];
return data.users
.filter((user) => normalizedValue.includes(String(user.email ?? "").toLowerCase()))
.map((user) => user.id);
}
function resolveGrantTargetUserIds(data, targetType, targetId) {
if (targetType === "user") {
return [targetId];