Refine launcher admin access workflows
This commit is contained in:
@@ -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
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user