@k2b/cloud 0.6.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/package.json +128 -0
- package/public/logo.svg +1 -0
- package/scripts/app-favicon.test.ts +49 -0
- package/scripts/app-favicon.ts +51 -0
- package/scripts/build.ts +205 -0
- package/scripts/preload.ts +164 -0
- package/scripts/sync-recovery-smoke.ts +200 -0
- package/src/_internal/app-favicon.ts +2 -0
- package/src/_internal/app-presentation.ts +61 -0
- package/src/_internal/app-workspace-state.ts +24 -0
- package/src/_internal/bounded-json.ts +47 -0
- package/src/_internal/build-metadata.ts +9 -0
- package/src/_internal/capabilities.ts +1118 -0
- package/src/_internal/define-app.ts +801 -0
- package/src/_internal/heartbeat.ts +167 -0
- package/src/_internal/help-catalog.ts +241 -0
- package/src/_internal/help.ts +95 -0
- package/src/_internal/nats-connection.ts +34 -0
- package/src/_internal/page-responses.ts +40 -0
- package/src/_internal/process-sync.ts +117 -0
- package/src/_internal/readiness.ts +3 -0
- package/src/_internal/registry-validation.ts +136 -0
- package/src/_internal/registry-watch.ts +52 -0
- package/src/_internal/registry.ts +338 -0
- package/src/_internal/runtime-compatibility.ts +64 -0
- package/src/_internal/runtime-context.ts +84 -0
- package/src/_internal/runtime-watcher.ts +78 -0
- package/src/_internal/static-assets.ts +86 -0
- package/src/_internal/status-preserving-ssr.ts +49 -0
- package/src/access/PermissionEditor.tsx +486 -0
- package/src/access/ResourceApiKeys.tsx +271 -0
- package/src/access/messages.ts +121 -0
- package/src/access/ui.ts +16 -0
- package/src/account/Avatar.tsx +28 -0
- package/src/account/EntitySearch.tsx +363 -0
- package/src/account/avatar-upload-dialog.tsx +197 -0
- package/src/account/avatar-upload.ts +149 -0
- package/src/account/ui.ts +3 -0
- package/src/ai/admin.ts +47 -0
- package/src/ai/approval-routes.ts +130 -0
- package/src/ai/approvals.ts +139 -0
- package/src/ai/assistant-models.ts +61 -0
- package/src/ai/attachments.ts +40 -0
- package/src/ai/browser.ts +92 -0
- package/src/ai/capabilities.ts +714 -0
- package/src/ai/capability-execution.ts +151 -0
- package/src/ai/chat/blocks.render.test.tsx +1108 -0
- package/src/ai/chat/blocks.tsx +730 -0
- package/src/ai/chat/builtin-tools.tsx +428 -0
- package/src/ai/chat/capability-table.tsx +87 -0
- package/src/ai/chat/composer-adapter.ts +169 -0
- package/src/ai/chat/file-tools.tsx +81 -0
- package/src/ai/chat/message-actions.tsx +370 -0
- package/src/ai/chat/message-utils.ts +479 -0
- package/src/ai/chat/messages.ts +43 -0
- package/src/ai/chat/presentation.tsx +395 -0
- package/src/ai/chat/primitives.tsx +3 -0
- package/src/ai/chat/tool-disclosure.tsx +41 -0
- package/src/ai/chat/turn-navigator-utils.ts +28 -0
- package/src/ai/chat/turn-navigator.tsx +164 -0
- package/src/ai/chat/user-message.tsx +149 -0
- package/src/ai/chat/visual-tools.tsx +435 -0
- package/src/ai/chat/web-tools.tsx +202 -0
- package/src/ai/chat-task-contracts.ts +113 -0
- package/src/ai/chat-tasks.ts +952 -0
- package/src/ai/client/controller.ts +1211 -0
- package/src/ai/client/file-source.ts +95 -0
- package/src/ai/client/live-connection.ts +140 -0
- package/src/ai/client/projection.ts +178 -0
- package/src/ai/client/transport.ts +111 -0
- package/src/ai/compaction.ts +182 -0
- package/src/ai/credentials.ts +124 -0
- package/src/ai/default-tool-contracts.ts +108 -0
- package/src/ai/default-tools.ts +127 -0
- package/src/ai/enrich.ts +280 -0
- package/src/ai/executor.ts +1624 -0
- package/src/ai/fetch-file-tool.ts +313 -0
- package/src/ai/file-context.ts +92 -0
- package/src/ai/file-media-type.ts +28 -0
- package/src/ai/file-mount.ts +16 -0
- package/src/ai/file-tools.ts +566 -0
- package/src/ai/files-store.ts +390 -0
- package/src/ai/firecrawl-tools.ts +305 -0
- package/src/ai/grids-skill.ts +126 -0
- package/src/ai/http.ts +265 -0
- package/src/ai/index.ts +337 -0
- package/src/ai/kit-skill.ts +22 -0
- package/src/ai/limits.ts +5 -0
- package/src/ai/live-events.ts +289 -0
- package/src/ai/live-messages.ts +45 -0
- package/src/ai/live-outbox.ts +102 -0
- package/src/ai/live-routes.ts +412 -0
- package/src/ai/live.ts +2 -0
- package/src/ai/maintenance.ts +302 -0
- package/src/ai/markdown-pdf-tool.ts +95 -0
- package/src/ai/memories.ts +605 -0
- package/src/ai/memory-learning-runs.ts +222 -0
- package/src/ai/memory-learning.ts +619 -0
- package/src/ai/memory-tool.ts +120 -0
- package/src/ai/memory-workflow-evidence.ts +158 -0
- package/src/ai/migrate.ts +2082 -0
- package/src/ai/model-access-migrate.ts +30 -0
- package/src/ai/model-access.ts +191 -0
- package/src/ai/personal-agent.ts +6 -0
- package/src/ai/prefs.ts +72 -0
- package/src/ai/project-tool.ts +94 -0
- package/src/ai/projects-routes.ts +277 -0
- package/src/ai/projects.ts +902 -0
- package/src/ai/protocol.ts +279 -0
- package/src/ai/provider.ts +73 -0
- package/src/ai/public-projection.ts +25 -0
- package/src/ai/resource-markers.ts +31 -0
- package/src/ai/resource-refs.ts +70 -0
- package/src/ai/routes.ts +1131 -0
- package/src/ai/runtime-tools.ts +39 -0
- package/src/ai/runtime.ts +544 -0
- package/src/ai/settings.ts +529 -0
- package/src/ai/short-id.ts +45 -0
- package/src/ai/skill-catalog.ts +73 -0
- package/src/ai/skill-format.ts +145 -0
- package/src/ai/skill-seeds.ts +430 -0
- package/src/ai/skill-tool.ts +81 -0
- package/src/ai/skills-routes.ts +143 -0
- package/src/ai/skills.ts +934 -0
- package/src/ai/solid.ts +27 -0
- package/src/ai/store.ts +3113 -0
- package/src/ai/stream.ts +308 -0
- package/src/ai/structured-runs.ts +66 -0
- package/src/ai/structured.ts +186 -0
- package/src/ai/system-prompt.ts +170 -0
- package/src/ai/task-prompt.ts +22 -0
- package/src/ai/timeline.ts +148 -0
- package/src/ai/tool-audit.ts +177 -0
- package/src/ai/tool-result-budget.ts +16 -0
- package/src/ai/tools.ts +155 -0
- package/src/ai/types.ts +967 -0
- package/src/ai/ui.tsx +39 -0
- package/src/ai/usage-migrate.ts +81 -0
- package/src/ai/usage.ts +315 -0
- package/src/ai/validate.ts +54 -0
- package/src/ai/vision-tool.ts +79 -0
- package/src/api/accounts-entities.ts +174 -0
- package/src/api/admin-ai-projects.ts +74 -0
- package/src/api/admin-ai-skills.ts +76 -0
- package/src/api/admin-ai-usage.ts +42 -0
- package/src/api/admin-core-settings.ts +508 -0
- package/src/api/admin-identity.ts +148 -0
- package/src/api/admin-lifecycle.ts +206 -0
- package/src/api/admin-linux-identities.ts +66 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/app-approval.ts +129 -0
- package/src/api/apps.ts +51 -0
- package/src/api/auth/schemas.ts +54 -0
- package/src/api/auth.ts +370 -0
- package/src/api/capabilities.ts +658 -0
- package/src/api/help-messages.ts +19 -0
- package/src/api/help.ts +78 -0
- package/src/api/index.ts +108 -0
- package/src/api/mcp.ts +744 -0
- package/src/api/me-rail.ts +45 -0
- package/src/api/me.ts +753 -0
- package/src/api/search/schemas.ts +79 -0
- package/src/api/search.bench.ts +185 -0
- package/src/api/search.ts +407 -0
- package/src/api/sync-ops.ts +79 -0
- package/src/api/widgets.ts +143 -0
- package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
- package/src/browser/CloudResourceSearch.tsx +791 -0
- package/src/browser/app-approval-vault.ts +305 -0
- package/src/browser/app-approval.ts +234 -0
- package/src/browser/live-websocket.ts +268 -0
- package/src/browser/locale-preference.ts +25 -0
- package/src/browser/notification-target.ts +27 -0
- package/src/browser/notification-timeout.ts +14 -0
- package/src/browser/notifications.ts +181 -0
- package/src/browser/resource-clipboard.ts +70 -0
- package/src/browser/resource-picker.tsx +39 -0
- package/src/browser/resource-search-messages.ts +55 -0
- package/src/browser/resource-search.ts +27 -0
- package/src/capabilities/client.ts +149 -0
- package/src/capabilities/response.ts +39 -0
- package/src/capabilities/server.ts +215 -0
- package/src/capabilities/signals.ts +4 -0
- package/src/capabilities/testing.ts +12 -0
- package/src/capabilities/types.ts +32 -0
- package/src/cli/access.ts +499 -0
- package/src/cli/account.ts +382 -0
- package/src/cli/admin/account-administration.ts +45 -0
- package/src/cli/admin/account-categories.ts +49 -0
- package/src/cli/admin/ai-usage.ts +118 -0
- package/src/cli/admin/app-sign-in.ts +49 -0
- package/src/cli/admin/data.ts +343 -0
- package/src/cli/admin/documentation.ts +34 -0
- package/src/cli/admin/gateway.ts +155 -0
- package/src/cli/admin/index.ts +61 -0
- package/src/cli/admin/instance.ts +250 -0
- package/src/cli/admin/jobs.ts +223 -0
- package/src/cli/admin/legal.ts +122 -0
- package/src/cli/admin/linux.ts +53 -0
- package/src/cli/admin/logs.ts +324 -0
- package/src/cli/admin/metrics.ts +142 -0
- package/src/cli/admin/nats.ts +167 -0
- package/src/cli/admin/notifications.ts +516 -0
- package/src/cli/admin/shared.ts +113 -0
- package/src/cli/admin/sync.ts +188 -0
- package/src/cli/admin/telemetry.ts +314 -0
- package/src/cli/admin/webhooks.ts +222 -0
- package/src/cli/admin/workflows.ts +367 -0
- package/src/cli/apps.ts +40 -0
- package/src/cli/capabilities.ts +239 -0
- package/src/cli/commands.ts +604 -0
- package/src/cli/index.ts +61 -0
- package/src/cli/locale.ts +31 -0
- package/src/cli/output.ts +58 -0
- package/src/clients/core.ts +20 -0
- package/src/config/env.ts +54 -0
- package/src/config/index.ts +6 -0
- package/src/contracts/account-categories.ts +62 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app-approval.ts +197 -0
- package/src/contracts/app.ts +162 -0
- package/src/contracts/capabilities.ts +484 -0
- package/src/contracts/index.ts +14 -0
- package/src/contracts/notification-live.ts +66 -0
- package/src/contracts/notification-types.ts +235 -0
- package/src/contracts/posix.ts +69 -0
- package/src/contracts/profile.ts +85 -0
- package/src/contracts/rail-preferences.ts +53 -0
- package/src/contracts/registry.ts +110 -0
- package/src/contracts/resource-clipboard.ts +33 -0
- package/src/contracts/settings-types.ts +98 -0
- package/src/contracts/shared.ts +408 -0
- package/src/contracts/user-notifications.ts +101 -0
- package/src/contracts/widgets.ts +183 -0
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/index.ts +58 -0
- package/src/server/actor.ts +41 -0
- package/src/server/api/index.ts +2 -0
- package/src/server/api/respond.ts +96 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/help.ts +187 -0
- package/src/server/index.ts +89 -0
- package/src/server/locale.ts +55 -0
- package/src/server/middleware/auth.ts +386 -0
- package/src/server/middleware/index.ts +28 -0
- package/src/server/middleware/invocation.ts +55 -0
- package/src/server/middleware/middleware.ts +65 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +125 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/route-template.ts +46 -0
- package/src/server/middleware/runtime.ts +22 -0
- package/src/server/middleware/settings.ts +55 -0
- package/src/server/middleware/validator.ts +49 -0
- package/src/server/middleware/workload.ts +22 -0
- package/src/server/ratelimit.ts +151 -0
- package/src/server/request-cookies.ts +18 -0
- package/src/server/services/access.ts +644 -0
- package/src/server/services/freeipa/client.ts +137 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +123 -0
- package/src/server/services/freeipa/test-certificates.ts +44 -0
- package/src/server/services/freeipa/tls.ts +44 -0
- package/src/server/services/freeipa/transport.ts +116 -0
- package/src/server/services/freeipa/util.ts +59 -0
- package/src/server/services/geo.ts +198 -0
- package/src/server/services/index.ts +48 -0
- package/src/server/services/pagination.ts +22 -0
- package/src/server/services/services.ts +13 -0
- package/src/server/time.ts +33 -0
- package/src/services/account-category-policy.ts +51 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +940 -0
- package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
- package/src/services/account-lifecycle/notification-sender.ts +13 -0
- package/src/services/account-lifecycle/scheduler.ts +506 -0
- package/src/services/account-model.ts +22 -0
- package/src/services/accounts/app.ts +1671 -0
- package/src/services/accounts/authz.ts +44 -0
- package/src/services/accounts/avatar.ts +120 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +46 -0
- package/src/services/accounts/duplicate-emails.ts +82 -0
- package/src/services/accounts/entities.ts +622 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +272 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +66 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +499 -0
- package/src/services/accounts/model.ts +129 -0
- package/src/services/accounts/notification-sender.ts +31 -0
- package/src/services/accounts/posix.ts +386 -0
- package/src/services/accounts/request-policy.ts +11 -0
- package/src/services/accounts/switching.ts +126 -0
- package/src/services/accounts/users.ts +745 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/app-approval.ts +498 -0
- package/src/services/audit/index.ts +438 -0
- package/src/services/auth-flows/index.ts +15 -0
- package/src/services/auth-flows/ipa.ts +197 -0
- package/src/services/auth-flows/magic-link.ts +185 -0
- package/src/services/auth-flows/notification-sender.ts +10 -0
- package/src/services/auth-flows/password-reset.ts +268 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/document-extraction.ts +148 -0
- package/src/services/freeipa-config.ts +179 -0
- package/src/services/gateway.ts +186 -0
- package/src/services/identity/constants.ts +18 -0
- package/src/services/identity/index.ts +52 -0
- package/src/services/identity/invocation-actor.ts +120 -0
- package/src/services/identity/invocation-authority.ts +53 -0
- package/src/services/identity/invocation-operations.ts +15 -0
- package/src/services/identity/invocation-token.ts +276 -0
- package/src/services/identity/key-config.ts +41 -0
- package/src/services/identity/key-ring.ts +530 -0
- package/src/services/identity/metrics.ts +29 -0
- package/src/services/identity/routes.ts +18 -0
- package/src/services/identity/runtime-config.ts +88 -0
- package/src/services/identity/session-token.ts +152 -0
- package/src/services/identity/signer-refresh.ts +44 -0
- package/src/services/identity/workload-auth.ts +64 -0
- package/src/services/index.ts +187 -0
- package/src/services/ipa/auth.ts +216 -0
- package/src/services/ipa/connection.ts +57 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/groups.ts +722 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/posix.ts +19 -0
- package/src/services/ipa/profile.ts +132 -0
- package/src/services/ipa/runtime.ts +28 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.ts +100 -0
- package/src/services/ipa/sync.ts +862 -0
- package/src/services/ipa/users.ts +806 -0
- package/src/services/legal-consent.ts +76 -0
- package/src/services/logging/index.ts +477 -0
- package/src/services/logging/redaction.ts +20 -0
- package/src/services/logging/trace.ts +1231 -0
- package/src/services/mandates/index.ts +855 -0
- package/src/services/mandates/policy.ts +85 -0
- package/src/services/network-security.ts +31 -0
- package/src/services/notifications/batches.ts +722 -0
- package/src/services/notifications/browser.ts +252 -0
- package/src/services/notifications/catalog.ts +113 -0
- package/src/services/notifications/channels.ts +104 -0
- package/src/services/notifications/dispatcher.ts +139 -0
- package/src/services/notifications/email.ts +118 -0
- package/src/services/notifications/index.ts +633 -0
- package/src/services/notifications/live.ts +50 -0
- package/src/services/notifications/observability.ts +407 -0
- package/src/services/notifications/platform.ts +421 -0
- package/src/services/notifications/runtime.ts +120 -0
- package/src/services/notifications/user.ts +248 -0
- package/src/services/notifications/web-push-transport.ts +135 -0
- package/src/services/oauth-tokens.ts +209 -0
- package/src/services/outbox.ts +187 -0
- package/src/services/pdf/gotenberg.ts +290 -0
- package/src/services/pdf/index.ts +43 -0
- package/src/services/pdf/markdown.ts +209 -0
- package/src/services/pdf/template-preview.ts +122 -0
- package/src/services/postgres.ts +76 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +51 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +263 -0
- package/src/services/rail-preferences.ts +27 -0
- package/src/services/runtime-lifecycle.ts +191 -0
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.ts +891 -0
- package/src/services/service-accounts.ts +184 -0
- package/src/services/session/index.ts +236 -0
- package/src/services/session/test-fixture.ts +18 -0
- package/src/services/session/user.ts +144 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +86 -0
- package/src/services/settings/core-settings.ts +796 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +374 -0
- package/src/services/settings/index.ts +215 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +241 -0
- package/src/services/settings/templates.ts +4 -0
- package/src/services/sync-ops.ts +403 -0
- package/src/services/topic-cursor.ts +22 -0
- package/src/services/weather/forecast.ts +323 -0
- package/src/services/weather/geo.ts +119 -0
- package/src/services/weather/index.ts +67 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +133 -0
- package/src/services/weather/migrate.ts +45 -0
- package/src/services/weather/settings.ts +66 -0
- package/src/services/weather/short-id.ts +57 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/services/webauthn.ts +385 -0
- package/src/shared/account-action-notice.ts +70 -0
- package/src/shared/account-display.ts +15 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/ai-platform-prompt.ts +134 -0
- package/src/shared/ai-usage.ts +49 -0
- package/src/shared/app-presentation.ts +48 -0
- package/src/shared/app-url.ts +12 -0
- package/src/shared/branding.ts +14 -0
- package/src/shared/capability-messages.ts +70 -0
- package/src/shared/email-html.ts +54 -0
- package/src/shared/format.ts +91 -0
- package/src/shared/help.ts +68 -0
- package/src/shared/icons.ts +400 -0
- package/src/shared/index.ts +54 -0
- package/src/shared/locale.ts +48 -0
- package/src/shared/login-method.ts +44 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +105 -0
- package/src/shared/markdown/extensions/guided-help.ts +100 -0
- package/src/shared/markdown/extensions/images.ts +79 -0
- package/src/shared/markdown/extensions/info-blocks.ts +97 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +43 -0
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +109 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +193 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/network-address.ts +106 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/template-rendering.ts +140 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +53 -0
- package/src/ssr/AdminSidebar.tsx +50 -0
- package/src/ssr/AppLaunchpad.island.tsx +236 -0
- package/src/ssr/AppWorkspaceController.island.tsx +21 -0
- package/src/ssr/BrowserNotifications.island.tsx +135 -0
- package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
- package/src/ssr/GlobalSearchDialog.tsx +35 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
- package/src/ssr/Layout.render.test.tsx +148 -0
- package/src/ssr/Layout.tsx +292 -0
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutFooter.tsx +22 -0
- package/src/ssr/LayoutHeader.tsx +81 -0
- package/src/ssr/LayoutHelp.tsx +869 -0
- package/src/ssr/LayoutPreferences.island.tsx +36 -0
- package/src/ssr/LayoutRail.tsx +58 -0
- package/src/ssr/MinimalLayout.tsx +46 -0
- package/src/ssr/PageError.tsx +29 -0
- package/src/ssr/ProfilePreferences.island.tsx +92 -0
- package/src/ssr/RailApps.island.tsx +61 -0
- package/src/ssr/RailEditor.tsx +293 -0
- package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/admin-active-link.ts +47 -0
- package/src/ssr/admin-navigation.ts +78 -0
- package/src/ssr/app-appearance.ts +23 -0
- package/src/ssr/app-navigation.ts +41 -0
- package/src/ssr/help-messages.ts +104 -0
- package/src/ssr/index.ts +14 -0
- package/src/ssr/islands/SearchBar.island.tsx +80 -0
- package/src/ssr/islands/index.ts +16 -0
- package/src/ssr/layout-context.ts +17 -0
- package/src/ssr/layout-help-markdown.ts +9 -0
- package/src/ssr/layout-help-navigation.ts +24 -0
- package/src/ssr/layout-help-url.ts +5 -0
- package/src/ssr/layout-runtime.ts +22 -0
- package/src/ssr/page-error-messages.ts +25 -0
- package/src/ssr/platform-messages.ts +135 -0
- package/src/ssr/preference-controller.ts +44 -0
- package/src/ssr/profile-preferences-messages.ts +29 -0
- package/src/ssr/rail-context.ts +21 -0
- package/src/ssr/rail-messages.ts +66 -0
- package/src/ssr/rail-navigation.ts +60 -0
- package/src/ssr/runtime.ts +31 -0
- package/src/ssr/url-filter.ts +127 -0
- package/src/styles/base-popover.css +36 -0
- package/src/styles/effects.css +851 -0
- package/src/styles/global.css +268 -0
- package/src/styles/input.css +58 -0
- package/src/styles/tokens.css +230 -0
- package/src/styles/utilities-buttons.css +405 -0
- package/src/styles/utilities-data.css +33 -0
- package/src/styles/utilities-feedback.css +232 -0
- package/src/styles/utilities-layout.css +603 -0
- package/src/styles/utilities-markdown-editor.css +525 -0
- package/src/styles/utilities-markdown-table.css +234 -0
- package/src/styles/utilities-navigation.css +1054 -0
- package/src/types/ambient.d.ts +24 -0
- package/src/workflows/ai/index.ts +17 -0
- package/src/workflows/ai/runtime.ts +286 -0
- package/src/workflows/ai/store.ts +314 -0
- package/src/workflows/ai/types.ts +111 -0
- package/src/workflows/ai-actions.ts +269 -0
- package/src/workflows/authoring.ts +88 -0
- package/src/workflows/builtins.ts +193 -0
- package/src/workflows/contracts.ts +280 -0
- package/src/workflows/definition.ts +334 -0
- package/src/workflows/editor.ts +113 -0
- package/src/workflows/index.ts +71 -0
- package/src/workflows/language/binder.ts +45 -0
- package/src/workflows/language/canonical.ts +29 -0
- package/src/workflows/language/compiler.ts +396 -0
- package/src/workflows/language/expressions.ts +34 -0
- package/src/workflows/language/index.ts +20 -0
- package/src/workflows/language/references.ts +55 -0
- package/src/workflows/language/schema.ts +134 -0
- package/src/workflows/language/strict-yaml.ts +131 -0
- package/src/workflows/manifest.ts +24 -0
- package/src/workflows/module.ts +12 -0
- package/src/workflows/runtime/coordinator.ts +115 -0
- package/src/workflows/runtime/executor.ts +1003 -0
- package/src/workflows/runtime/index.ts +60 -0
- package/src/workflows/runtime/instant.ts +8 -0
- package/src/workflows/runtime/ports.ts +213 -0
- package/src/workflows/runtime/schedule.ts +203 -0
- package/src/workflows/runtime/trigger.ts +37 -0
- package/src/workflows/runtime/wake.ts +13 -0
- package/src/workflows/store/actions.ts +438 -0
- package/src/workflows/store/budget.ts +145 -0
- package/src/workflows/store/definitions.ts +345 -0
- package/src/workflows/store/events.ts +383 -0
- package/src/workflows/store/index.ts +107 -0
- package/src/workflows/store/observability.ts +594 -0
- package/src/workflows/store/runs.ts +975 -0
- package/src/workflows/store/transaction.ts +23 -0
- package/src/workflows/store/worker.ts +439 -0
- package/src/workflows/testing/conformance.ts +72 -0
- package/src/workflows/testing/index.ts +11 -0
- package/src/workflows/testing/process-fixtures.ts +275 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { freeipa } from "../../server/services";
|
|
3
|
+
import { writeDeletedAccountAudit } from "../account-lifecycle/audit";
|
|
4
|
+
import { calculateIpaProfileFromGroupNames, parseIpaAccountTransitionPolicy, parseIpaMatchMode } from "../account-model";
|
|
5
|
+
import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
|
|
6
|
+
import { getFreeIpaConfig } from "../freeipa-config";
|
|
7
|
+
import { logger } from "../logging";
|
|
8
|
+
import { session } from "../session";
|
|
9
|
+
import { mirrorIpaPosix } from "./posix";
|
|
10
|
+
import * as settings from "../settings";
|
|
11
|
+
import { buildEffectiveIpaGroupsByUid } from "./effective-groups";
|
|
12
|
+
import { calculateIpaProfileFromEffectiveProjection, getEffectiveUserGroups } from "./profile";
|
|
13
|
+
import { createIpaRuntimeCheckpoint, type IpaSyncRuntime } from "./runtime";
|
|
14
|
+
import { assessIpaDestructionGuard, formatIpaDestructionGuardFailure, readCompleteIpaList, selectStaleLocalIpaRows } from "./sync-planning";
|
|
15
|
+
|
|
16
|
+
type DbRow = Record<string, unknown>;
|
|
17
|
+
type LocalIpaRow = DbRow & { uid: string; mail: string | null };
|
|
18
|
+
|
|
19
|
+
const log = logger("auth:ipa:sync");
|
|
20
|
+
|
|
21
|
+
const upsertUserIpaData = async (
|
|
22
|
+
db: typeof sql,
|
|
23
|
+
params: {
|
|
24
|
+
userId: string;
|
|
25
|
+
uidNumber: number | null;
|
|
26
|
+
primaryGidNumber: number | null;
|
|
27
|
+
homeDirectory: string | null;
|
|
28
|
+
loginShell: string | null;
|
|
29
|
+
phone: string | null;
|
|
30
|
+
ipaPasswordExpires: Date | null;
|
|
31
|
+
lastLoginIpa: Date | null;
|
|
32
|
+
employeeType: string | null;
|
|
33
|
+
addrStreet: string | null;
|
|
34
|
+
addrPostalCode: string | null;
|
|
35
|
+
addrCity: string | null;
|
|
36
|
+
addrState: string | null;
|
|
37
|
+
mobile: string | null;
|
|
38
|
+
sshPublicKeys: string[];
|
|
39
|
+
sshFingerprints: string[];
|
|
40
|
+
},
|
|
41
|
+
) => {
|
|
42
|
+
await mirrorIpaPosix(db, params);
|
|
43
|
+
await db`
|
|
44
|
+
INSERT INTO auth.user_ipa_data (
|
|
45
|
+
user_id, uid_number, phone, employee_type, mobile, addr_street, addr_postal_code,
|
|
46
|
+
addr_city, addr_state, ipa_password_expires, last_login_ipa, synced_at, ssh_public_keys, ssh_fingerprints
|
|
47
|
+
)
|
|
48
|
+
VALUES (
|
|
49
|
+
${params.userId},
|
|
50
|
+
${params.uidNumber},
|
|
51
|
+
${params.phone},
|
|
52
|
+
${params.employeeType},
|
|
53
|
+
${params.mobile},
|
|
54
|
+
${params.addrStreet},
|
|
55
|
+
${params.addrPostalCode},
|
|
56
|
+
${params.addrCity},
|
|
57
|
+
${params.addrState},
|
|
58
|
+
${params.ipaPasswordExpires},
|
|
59
|
+
${params.lastLoginIpa},
|
|
60
|
+
now(),
|
|
61
|
+
${freeipa.util.toPgTextArray(params.sshPublicKeys)}::text[],
|
|
62
|
+
${freeipa.util.toPgTextArray(params.sshFingerprints)}::text[]
|
|
63
|
+
)
|
|
64
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
65
|
+
uid_number = EXCLUDED.uid_number,
|
|
66
|
+
phone = EXCLUDED.phone,
|
|
67
|
+
employee_type = EXCLUDED.employee_type,
|
|
68
|
+
mobile = EXCLUDED.mobile,
|
|
69
|
+
addr_street = EXCLUDED.addr_street,
|
|
70
|
+
addr_postal_code = EXCLUDED.addr_postal_code,
|
|
71
|
+
addr_city = EXCLUDED.addr_city,
|
|
72
|
+
addr_state = EXCLUDED.addr_state,
|
|
73
|
+
ipa_password_expires = EXCLUDED.ipa_password_expires,
|
|
74
|
+
last_login_ipa = EXCLUDED.last_login_ipa,
|
|
75
|
+
synced_at = EXCLUDED.synced_at,
|
|
76
|
+
ssh_public_keys = EXCLUDED.ssh_public_keys,
|
|
77
|
+
ssh_fingerprints = EXCLUDED.ssh_fingerprints
|
|
78
|
+
`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ==========================
|
|
82
|
+
// Sync Types
|
|
83
|
+
// ==========================
|
|
84
|
+
|
|
85
|
+
type SyncUser = {
|
|
86
|
+
uid: string;
|
|
87
|
+
uidNumber: number | null;
|
|
88
|
+
primaryGidNumber: number | null;
|
|
89
|
+
homeDirectory: string | null;
|
|
90
|
+
loginShell: string | null;
|
|
91
|
+
givenname: string;
|
|
92
|
+
sn: string;
|
|
93
|
+
displayName: string;
|
|
94
|
+
mail: string | null;
|
|
95
|
+
phone: string | null;
|
|
96
|
+
ipaAccountExpires: Date | null;
|
|
97
|
+
ipaPasswordExpires: Date | null;
|
|
98
|
+
lastLoginIpa: Date | null;
|
|
99
|
+
memberofGroup: string[];
|
|
100
|
+
employeeType: string | null;
|
|
101
|
+
addrStreet: string | null;
|
|
102
|
+
addrPostalCode: string | null;
|
|
103
|
+
addrCity: string | null;
|
|
104
|
+
addrState: string | null;
|
|
105
|
+
mobile: string | null;
|
|
106
|
+
sshPublicKeys: string[];
|
|
107
|
+
sshFingerprints: string[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type SyncGroup = {
|
|
111
|
+
cn: string;
|
|
112
|
+
description: string | null;
|
|
113
|
+
gidnumber: number | null;
|
|
114
|
+
users: string[];
|
|
115
|
+
groups: string[];
|
|
116
|
+
parentGroups: string[];
|
|
117
|
+
managerUsers: string[];
|
|
118
|
+
managerGroups: string[];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ==========================
|
|
122
|
+
// Transform helpers
|
|
123
|
+
// ==========================
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Normalizes one raw IPA user record into the sync user model.
|
|
127
|
+
*/
|
|
128
|
+
const transformSyncUser = (raw: Record<string, unknown>): SyncUser => {
|
|
129
|
+
const directGroups = (raw.memberof_group as string[]) ?? [];
|
|
130
|
+
const indirectGroups = (raw.memberofindirect_group as string[]) ?? [];
|
|
131
|
+
return {
|
|
132
|
+
uid: freeipa.util.str(raw.uid),
|
|
133
|
+
uidNumber: freeipa.util.num(raw.uidnumber),
|
|
134
|
+
primaryGidNumber: freeipa.util.num(raw.gidnumber),
|
|
135
|
+
homeDirectory: freeipa.util.str(raw.homedirectory) || null,
|
|
136
|
+
loginShell: freeipa.util.str(raw.loginshell) || null,
|
|
137
|
+
givenname: freeipa.util.str(raw.givenname),
|
|
138
|
+
sn: freeipa.util.str(raw.sn),
|
|
139
|
+
displayName:
|
|
140
|
+
freeipa.util.str(raw.displayname) ||
|
|
141
|
+
[freeipa.util.str(raw.givenname), freeipa.util.str(raw.sn)].filter(Boolean).join(" ") ||
|
|
142
|
+
freeipa.util.str(raw.uid),
|
|
143
|
+
mail: freeipa.util.str(raw.mail) || null,
|
|
144
|
+
phone: freeipa.util.str(raw.telephonenumber) || null,
|
|
145
|
+
ipaAccountExpires: freeipa.util.parseGeneralizedTime(raw.krbprincipalexpiration),
|
|
146
|
+
ipaPasswordExpires: freeipa.util.parseGeneralizedTime(raw.krbpasswordexpiration),
|
|
147
|
+
lastLoginIpa: freeipa.util.parseGeneralizedTime(raw.krblastsuccessfulauth),
|
|
148
|
+
memberofGroup: [...directGroups, ...indirectGroups],
|
|
149
|
+
employeeType: freeipa.util.str(raw.employeetype) || null,
|
|
150
|
+
addrStreet: freeipa.util.str(raw.street) || null,
|
|
151
|
+
addrPostalCode: freeipa.util.str(raw.postalcode) || null,
|
|
152
|
+
addrCity: freeipa.util.str(raw.l) || null,
|
|
153
|
+
addrState: freeipa.util.str(raw.st) || null,
|
|
154
|
+
mobile: freeipa.util.str(raw.mobile) || null,
|
|
155
|
+
sshPublicKeys: Array.isArray(raw.ipasshpubkey) ? raw.ipasshpubkey : [],
|
|
156
|
+
sshFingerprints: Array.isArray(raw.sshpubkeyfp) ? raw.sshpubkeyfp : [],
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Normalizes one raw IPA group record into the sync group model.
|
|
162
|
+
* `excludedGroupsSet` is hoisted to the caller so we don't re-read settings
|
|
163
|
+
* once per group.
|
|
164
|
+
*/
|
|
165
|
+
const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string> = new Set()): SyncGroup => ({
|
|
166
|
+
cn: freeipa.util.str(raw.cn),
|
|
167
|
+
description: freeipa.util.str(raw.description) || null,
|
|
168
|
+
gidnumber: freeipa.util.num(raw.gidnumber),
|
|
169
|
+
users: (raw.member_user as string[]) ?? [],
|
|
170
|
+
groups: ((raw.member_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
|
|
171
|
+
parentGroups: ((raw.memberof_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
|
|
172
|
+
managerUsers: (raw.membermanager_user as string[]) ?? [],
|
|
173
|
+
managerGroups: ((raw.membermanager_group as string[]) ?? []).filter((g) => !excludedGroupsSet.has(g)),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Evaluates whether an IPA account is already expired based on account metadata.
|
|
178
|
+
*/
|
|
179
|
+
const isExpired = (u: SyncUser): boolean => {
|
|
180
|
+
if (!u.ipaAccountExpires) return false;
|
|
181
|
+
return u.ipaAccountExpires < new Date();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Reads one IPA list response and throws for transport/protocol failures.
|
|
186
|
+
* Sync must fail hard on invalid payloads to avoid destructive partial updates.
|
|
187
|
+
*/
|
|
188
|
+
// ==========================
|
|
189
|
+
// Sync
|
|
190
|
+
// ==========================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Runs a full IPA-to-local sync pass for users, groups, and memberships.
|
|
194
|
+
*/
|
|
195
|
+
export const syncFromIpa = async (runtime: IpaSyncRuntime = {}): Promise<void> => {
|
|
196
|
+
const startedAt = Date.now();
|
|
197
|
+
let leaseHeartbeats = 0;
|
|
198
|
+
const runCheckpoint = createIpaRuntimeCheckpoint(runtime, {
|
|
199
|
+
onHeartbeat: () => {
|
|
200
|
+
leaseHeartbeats += 1;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const config = await getFreeIpaConfig();
|
|
204
|
+
if (!config.enabled) {
|
|
205
|
+
log.info("Sync skipped", { reason: "freeipa_disabled" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!config.configured) {
|
|
209
|
+
throw new Error(`FreeIPA is enabled but not fully configured. Missing: ${config.missingSettings.join(", ")}.`);
|
|
210
|
+
}
|
|
211
|
+
const excludedGroupsSet = freeipa.util.toExcludedGroupsSet(config.groupsExcluded);
|
|
212
|
+
const ipaSession = await freeipa.session.getServiceSession({
|
|
213
|
+
url: config.url,
|
|
214
|
+
serviceUser: config.serviceUser,
|
|
215
|
+
servicePassword: config.servicePassword,
|
|
216
|
+
signal: runtime.signal,
|
|
217
|
+
});
|
|
218
|
+
await runCheckpoint(true);
|
|
219
|
+
|
|
220
|
+
const snapshotFetchStartedAt = Date.now();
|
|
221
|
+
const [usersRes, groupsRes] = await Promise.all([
|
|
222
|
+
freeipa.client.call({
|
|
223
|
+
url: config.url,
|
|
224
|
+
ipaSession,
|
|
225
|
+
method: "user_find",
|
|
226
|
+
args: [],
|
|
227
|
+
options: { sizelimit: 0, all: true },
|
|
228
|
+
signal: runtime.signal,
|
|
229
|
+
}),
|
|
230
|
+
freeipa.client.call({
|
|
231
|
+
url: config.url,
|
|
232
|
+
ipaSession,
|
|
233
|
+
method: "group_find",
|
|
234
|
+
args: [],
|
|
235
|
+
options: {
|
|
236
|
+
sizelimit: 0,
|
|
237
|
+
no_members: false,
|
|
238
|
+
all: true,
|
|
239
|
+
},
|
|
240
|
+
signal: runtime.signal,
|
|
241
|
+
}),
|
|
242
|
+
]);
|
|
243
|
+
const snapshotFetchDurationMs = Date.now() - snapshotFetchStartedAt;
|
|
244
|
+
await runCheckpoint(true);
|
|
245
|
+
|
|
246
|
+
const allRawUsers = readCompleteIpaList({ response: usersRes, entity: "users" });
|
|
247
|
+
const allUsers = allRawUsers.map(transformSyncUser);
|
|
248
|
+
const allRawGroups = readCompleteIpaList({ response: groupsRes, entity: "groups" });
|
|
249
|
+
const effectiveGroupsByUid = buildEffectiveIpaGroupsByUid(allRawGroups.map((raw) => transformSyncGroup(raw)));
|
|
250
|
+
const users = allUsers.filter((user) => {
|
|
251
|
+
const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
|
|
252
|
+
return config.groupsBaseSync.some((group) => effectiveGroups.includes(group));
|
|
253
|
+
});
|
|
254
|
+
const groups = allRawGroups.map((raw) => transformSyncGroup(raw, excludedGroupsSet)).filter((g) => !excludedGroupsSet.has(g.cn));
|
|
255
|
+
|
|
256
|
+
const activeUsers = users.filter((u) => !isExpired(u));
|
|
257
|
+
const expiredUsers = users.length - activeUsers.length;
|
|
258
|
+
// Only ACTIVE remote users are considered in scope. Expired ones fall through to the
|
|
259
|
+
// stale/transition branch so their local mirror is either demoted or deleted per policy,
|
|
260
|
+
// and their sessions are revoked. Treating expired users as in-scope would leave a stale
|
|
261
|
+
// unexpired local row plus live sessions.
|
|
262
|
+
const groupCns = new Set(groups.map((g) => g.cn));
|
|
263
|
+
const matchMode = parseIpaMatchMode(await settings.get<string | null>("freeipa.user_match_mode"));
|
|
264
|
+
const transitionPolicy = parseIpaAccountTransitionPolicy(await settings.get<string | null>("freeipa.account_transition_policy"));
|
|
265
|
+
|
|
266
|
+
let matchedExistingUsersByMail = 0;
|
|
267
|
+
let migratedLocalUsers = 0;
|
|
268
|
+
let skippedLocalMailConflicts = 0;
|
|
269
|
+
let skippedLocalUidConflicts = 0;
|
|
270
|
+
let upsertedUsersByUid = 0;
|
|
271
|
+
let insertedUsersByUid = 0;
|
|
272
|
+
let updatedUsersByUid = 0;
|
|
273
|
+
let deletedGroups = 0;
|
|
274
|
+
|
|
275
|
+
const localIpaRows = await sql<DbRow[]>`
|
|
276
|
+
SELECT id, uid, mail, display_name, profile
|
|
277
|
+
FROM auth.users
|
|
278
|
+
WHERE provider = 'ipa'
|
|
279
|
+
ORDER BY uid
|
|
280
|
+
`;
|
|
281
|
+
const localIpaGroupRows = await sql<DbRow[]>`
|
|
282
|
+
SELECT name
|
|
283
|
+
FROM auth.groups
|
|
284
|
+
WHERE provider = 'ipa'
|
|
285
|
+
ORDER BY name
|
|
286
|
+
`;
|
|
287
|
+
const localIpaUsers = localIpaRows.length;
|
|
288
|
+
const localGroups = localIpaGroupRows.length;
|
|
289
|
+
const currentRowByUid = new Map(localIpaRows.map((row) => [row.uid as string, row]));
|
|
290
|
+
const currentRowsByMail = new Map<string, DbRow[]>();
|
|
291
|
+
for (const row of localIpaRows) {
|
|
292
|
+
const mail = row.mail as string | null;
|
|
293
|
+
if (!mail) continue;
|
|
294
|
+
const matches = currentRowsByMail.get(mail);
|
|
295
|
+
if (matches) matches.push(row);
|
|
296
|
+
else currentRowsByMail.set(mail, [row]);
|
|
297
|
+
}
|
|
298
|
+
const currentRowForRemoteUser = (user: SyncUser): DbRow | undefined => {
|
|
299
|
+
const byUid = currentRowByUid.get(user.uid);
|
|
300
|
+
if (byUid) return byUid;
|
|
301
|
+
if (!user.mail) return undefined;
|
|
302
|
+
const byMail = currentRowsByMail.get(user.mail) ?? [];
|
|
303
|
+
return byMail.length === 1 ? byMail[0] : undefined;
|
|
304
|
+
};
|
|
305
|
+
let profileDriftCount = 0;
|
|
306
|
+
const profileDriftSamples: string[] = [];
|
|
307
|
+
let profilesPromoted = 0;
|
|
308
|
+
let profilesDemoted = 0;
|
|
309
|
+
for (const user of activeUsers) {
|
|
310
|
+
const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
|
|
311
|
+
const graphProfile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
|
|
312
|
+
const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
|
|
313
|
+
if (graphProfile !== userSideProfile) {
|
|
314
|
+
profileDriftCount += 1;
|
|
315
|
+
if (profileDriftSamples.length < 10) profileDriftSamples.push(user.uid);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const previousProfile = currentRowForRemoteUser(user)?.profile as "user" | "guest" | undefined;
|
|
319
|
+
if (previousProfile === "guest" && graphProfile === "user") profilesPromoted += 1;
|
|
320
|
+
if (previousProfile === "user" && graphProfile === "guest") profilesDemoted += 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const localIpaIdentityRows: LocalIpaRow[] = localIpaRows.map((row) => ({
|
|
324
|
+
...row,
|
|
325
|
+
uid: row.uid as string,
|
|
326
|
+
mail: (row.mail as string | null) ?? null,
|
|
327
|
+
}));
|
|
328
|
+
const staleLocalUsers = selectStaleLocalIpaRows({
|
|
329
|
+
localRows: localIpaIdentityRows,
|
|
330
|
+
activeRemoteUsers: activeUsers.map((user) => ({ uid: user.uid, mail: user.mail })),
|
|
331
|
+
});
|
|
332
|
+
const affectedUserIds = new Set(staleLocalUsers.map((row) => row.id as string));
|
|
333
|
+
for (const user of activeUsers) {
|
|
334
|
+
const previousRow = currentRowForRemoteUser(user);
|
|
335
|
+
const previousProfile = previousRow?.profile as "user" | "guest" | undefined;
|
|
336
|
+
const nextProfile = calculateIpaProfileFromGroupNames(effectiveGroupsByUid.get(user.uid) ?? [], config.groupsBaseIpaRealm);
|
|
337
|
+
if (previousRow && previousProfile === "user" && nextProfile === "guest") {
|
|
338
|
+
affectedUserIds.add(previousRow.id as string);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const deletedGroupNames = localIpaGroupRows.map((row) => row.name as string).filter((name) => !groupCns.has(name));
|
|
342
|
+
const guard = assessIpaDestructionGuard({
|
|
343
|
+
affectedUserIds,
|
|
344
|
+
localUsers: localIpaUsers,
|
|
345
|
+
deletedGroupNames,
|
|
346
|
+
localGroups,
|
|
347
|
+
limits: config.syncGuard,
|
|
348
|
+
});
|
|
349
|
+
log.info("IPA sync destructive plan assessed", guard);
|
|
350
|
+
if (guard.violations.length > 0) throw new Error(formatIpaDestructionGuardFailure(guard));
|
|
351
|
+
if (profilesDemoted > 0) {
|
|
352
|
+
log.warn("IPA sync will downgrade user profiles", { profilesDemoted });
|
|
353
|
+
}
|
|
354
|
+
if (profileDriftCount > 0) {
|
|
355
|
+
log.warn("IPA user memberOf drift detected; using group graph projection", {
|
|
356
|
+
profileDriftCount,
|
|
357
|
+
sampleUids: profileDriftSamples,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Revoke before the local transition so a crash after the transaction
|
|
362
|
+
// cannot leave a session valid for a user that no longer appears in the next
|
|
363
|
+
// IPA sync plan. A failed transaction may require a relogin, which is the
|
|
364
|
+
// safe side of this cross-store boundary.
|
|
365
|
+
for (const staleUser of staleLocalUsers) {
|
|
366
|
+
await runCheckpoint();
|
|
367
|
+
await session.revokeAllForUser(staleUser.id as string);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let scopeTransitions = 0;
|
|
371
|
+
let effectiveGroupsRebuilt = 0;
|
|
372
|
+
await runCheckpoint(true);
|
|
373
|
+
const transactionStartedAt = Date.now();
|
|
374
|
+
await sql.begin(async (tx) => {
|
|
375
|
+
// 1. Upsert active IPA users
|
|
376
|
+
// Match order: mail (existing IPA user, handles UID renames) → mail (guest promotion) → uid (new or unchanged)
|
|
377
|
+
for (let userIndex = 0; userIndex < activeUsers.length; userIndex += 1) {
|
|
378
|
+
await runCheckpoint();
|
|
379
|
+
const u = activeUsers[userIndex]!;
|
|
380
|
+
const effectiveGroups = effectiveGroupsByUid.get(u.uid) ?? [];
|
|
381
|
+
const profile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
|
|
382
|
+
const provider = "ipa";
|
|
383
|
+
|
|
384
|
+
if (u.mail) {
|
|
385
|
+
// First: match existing IPA user by mail (handles UID renames)
|
|
386
|
+
const updated = await tx`
|
|
387
|
+
UPDATE auth.users SET
|
|
388
|
+
uid = ${u.uid}, provider = ${provider}, profile = ${profile}, admin = false,
|
|
389
|
+
given_name = ${u.givenname}, sn = ${u.sn},
|
|
390
|
+
display_name = ${u.displayName}, mail = ${u.mail},
|
|
391
|
+
account_expires = ${u.ipaAccountExpires}
|
|
392
|
+
WHERE mail = ${u.mail} AND provider = 'ipa'
|
|
393
|
+
RETURNING id`;
|
|
394
|
+
if (updated.length > 0) {
|
|
395
|
+
await upsertUserIpaData(tx, { userId: updated[0]!.id as string, ...u });
|
|
396
|
+
matchedExistingUsersByMail += 1;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Second: optionally migrate a unique local account to IPA.
|
|
401
|
+
if (matchMode === "migrate") {
|
|
402
|
+
const localMatches = await tx<DbRow[]>`
|
|
403
|
+
SELECT id
|
|
404
|
+
FROM auth.users
|
|
405
|
+
WHERE mail = ${u.mail} AND provider = 'local'
|
|
406
|
+
ORDER BY profile = 'user' DESC, created_at ASC
|
|
407
|
+
LIMIT 2
|
|
408
|
+
`;
|
|
409
|
+
|
|
410
|
+
if (localMatches.length === 1) {
|
|
411
|
+
const migrated = await tx`
|
|
412
|
+
UPDATE auth.users SET
|
|
413
|
+
uid = ${u.uid}, provider = ${provider}, profile = ${profile}, admin = false,
|
|
414
|
+
given_name = ${u.givenname}, sn = ${u.sn},
|
|
415
|
+
display_name = ${u.displayName}, mail = ${u.mail},
|
|
416
|
+
account_expires = ${u.ipaAccountExpires}
|
|
417
|
+
WHERE id = ${localMatches[0]!.id as string}::uuid
|
|
418
|
+
RETURNING id`;
|
|
419
|
+
if (migrated.length > 0) {
|
|
420
|
+
await upsertUserIpaData(tx, { userId: migrated[0]!.id as string, ...u });
|
|
421
|
+
migratedLocalUsers += 1;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
} else if (localMatches.length > 1) {
|
|
425
|
+
skippedLocalMailConflicts += 1;
|
|
426
|
+
log.warn("Skipping IPA provider migration because multiple local accounts matched by mail", {
|
|
427
|
+
mail: u.mail,
|
|
428
|
+
uid: u.uid,
|
|
429
|
+
});
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const uidConflictRows = await tx<DbRow[]>`
|
|
436
|
+
SELECT id
|
|
437
|
+
FROM auth.users
|
|
438
|
+
WHERE uid = ${u.uid} AND provider = 'local'
|
|
439
|
+
LIMIT 1
|
|
440
|
+
`;
|
|
441
|
+
if (uidConflictRows.length > 0) {
|
|
442
|
+
skippedLocalUidConflicts += 1;
|
|
443
|
+
log.warn("Skipping IPA sync user upsert because a local account already uses the UID", {
|
|
444
|
+
uid: u.uid,
|
|
445
|
+
mail: u.mail,
|
|
446
|
+
});
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Third: insert new or update existing by uid
|
|
451
|
+
const upserted = await tx<DbRow[]>`
|
|
452
|
+
INSERT INTO auth.users (uid, provider, profile, admin, given_name, sn, display_name, mail, account_expires)
|
|
453
|
+
VALUES (${u.uid}, ${provider}, ${profile}, false, ${u.givenname}, ${u.sn}, ${u.displayName}, ${u.mail}, ${u.ipaAccountExpires})
|
|
454
|
+
ON CONFLICT (uid) DO UPDATE SET
|
|
455
|
+
provider = ${provider},
|
|
456
|
+
profile = ${profile},
|
|
457
|
+
admin = false,
|
|
458
|
+
given_name = EXCLUDED.given_name, sn = EXCLUDED.sn,
|
|
459
|
+
display_name = EXCLUDED.display_name, mail = EXCLUDED.mail,
|
|
460
|
+
account_expires = EXCLUDED.account_expires
|
|
461
|
+
RETURNING id, (xmax = 0) AS inserted`;
|
|
462
|
+
await upsertUserIpaData(tx, { userId: upserted[0]!.id as string, ...u });
|
|
463
|
+
upsertedUsersByUid += 1;
|
|
464
|
+
if (Boolean(upserted[0]?.inserted)) insertedUsersByUid += 1;
|
|
465
|
+
else updatedUsersByUid += 1;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (let staleIndex = 0; staleIndex < staleLocalUsers.length; staleIndex += 1) {
|
|
469
|
+
await runCheckpoint();
|
|
470
|
+
const stale = staleLocalUsers[staleIndex]!;
|
|
471
|
+
const userId = stale.id as string;
|
|
472
|
+
const uid = stale.uid as string;
|
|
473
|
+
const previousProfile = (stale.profile as "user" | "guest" | null) ?? "guest";
|
|
474
|
+
|
|
475
|
+
if (transitionPolicy === "delete") {
|
|
476
|
+
await writeDeletedAccountAudit({
|
|
477
|
+
db: tx,
|
|
478
|
+
userId,
|
|
479
|
+
uid,
|
|
480
|
+
mail: (stale.mail as string) ?? null,
|
|
481
|
+
displayName: (stale.display_name as string) ?? null,
|
|
482
|
+
previousProvider: "ipa",
|
|
483
|
+
previousProfile,
|
|
484
|
+
reason: "sync_out_of_scope_deleted",
|
|
485
|
+
meta: {
|
|
486
|
+
reason: "missing_from_ipa_sync_scope",
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
|
|
490
|
+
scopeTransitions += 1;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const target = await applyIpaAccountTransitionPolicy({
|
|
495
|
+
userId,
|
|
496
|
+
currentProfile: previousProfile,
|
|
497
|
+
policy: transitionPolicy,
|
|
498
|
+
db: tx,
|
|
499
|
+
});
|
|
500
|
+
await writeDeletedAccountAudit({
|
|
501
|
+
db: tx,
|
|
502
|
+
userId,
|
|
503
|
+
uid,
|
|
504
|
+
mail: (stale.mail as string) ?? null,
|
|
505
|
+
displayName: (stale.display_name as string) ?? null,
|
|
506
|
+
previousProvider: "ipa",
|
|
507
|
+
previousProfile,
|
|
508
|
+
reason: "sync_out_of_scope_demoted",
|
|
509
|
+
meta: {
|
|
510
|
+
accountExpiresAt: target.accountExpires?.toISOString() ?? null,
|
|
511
|
+
targetProfile: target.targetProfile,
|
|
512
|
+
reason: "missing_from_ipa_sync_scope",
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
scopeTransitions += 1;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 2. Upsert groups + delete stale
|
|
519
|
+
for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
|
|
520
|
+
await runCheckpoint();
|
|
521
|
+
const g = groups[groupIndex]!;
|
|
522
|
+
await tx`
|
|
523
|
+
INSERT INTO auth.groups (id, cn, name, provider, description, gid_number, synced_at)
|
|
524
|
+
VALUES (gen_random_uuid(), ${g.cn}, ${g.cn}, 'ipa', ${g.description}, ${g.gidnumber}, now())
|
|
525
|
+
ON CONFLICT (provider, name) DO UPDATE SET
|
|
526
|
+
cn = EXCLUDED.cn,
|
|
527
|
+
description = EXCLUDED.description,
|
|
528
|
+
gid_number = EXCLUDED.gid_number,
|
|
529
|
+
synced_at = now()`;
|
|
530
|
+
}
|
|
531
|
+
const groupCnArray = [...groupCns];
|
|
532
|
+
if (groupCnArray.length > 0) {
|
|
533
|
+
const deleted = await tx<DbRow[]>`
|
|
534
|
+
DELETE FROM auth.groups
|
|
535
|
+
WHERE provider = 'ipa'
|
|
536
|
+
AND name <> ALL(${freeipa.util.toPgTextArray(groupCnArray)}::text[])
|
|
537
|
+
RETURNING name
|
|
538
|
+
`;
|
|
539
|
+
deletedGroups = deleted.length;
|
|
540
|
+
} else {
|
|
541
|
+
const deleted = await tx<DbRow[]>`
|
|
542
|
+
DELETE FROM auth.groups
|
|
543
|
+
WHERE provider = 'ipa'
|
|
544
|
+
RETURNING name
|
|
545
|
+
`;
|
|
546
|
+
deletedGroups = deleted.length;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const groupRows = await tx<DbRow[]>`SELECT id, name FROM auth.groups WHERE provider = 'ipa'`;
|
|
550
|
+
const groupNameToId = new Map<string, string>(groupRows.map((row) => [row.name as string, row.id as string]));
|
|
551
|
+
|
|
552
|
+
// 3. Rebuild junction tables
|
|
553
|
+
await tx`DELETE FROM auth.user_groups_v2 WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')`;
|
|
554
|
+
await tx`
|
|
555
|
+
DELETE FROM auth.group_groups_v2
|
|
556
|
+
WHERE parent_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
557
|
+
OR child_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
558
|
+
`;
|
|
559
|
+
await tx`DELETE FROM auth.group_manager_users_v2 WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')`;
|
|
560
|
+
await tx`
|
|
561
|
+
DELETE FROM auth.group_manager_groups_v2
|
|
562
|
+
WHERE group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
563
|
+
OR manager_group_id IN (SELECT id FROM auth.groups WHERE provider = 'ipa')
|
|
564
|
+
`;
|
|
565
|
+
|
|
566
|
+
const userIdRows: DbRow[] = await tx`SELECT id, uid FROM auth.users WHERE provider = 'ipa'`;
|
|
567
|
+
const uidToId = new Map<string, string>(userIdRows.map((r) => [r.uid as string, r.id as string]));
|
|
568
|
+
|
|
569
|
+
await tx`
|
|
570
|
+
DELETE FROM auth.ipa_user_effective_groups
|
|
571
|
+
WHERE user_id IN (SELECT id FROM auth.users WHERE provider = 'ipa')
|
|
572
|
+
`;
|
|
573
|
+
const effectiveGroupRows: { user_id: string; group_name: string }[] = [];
|
|
574
|
+
for (const [uid, groupNames] of effectiveGroupsByUid) {
|
|
575
|
+
const userId = uidToId.get(uid);
|
|
576
|
+
if (!userId) continue;
|
|
577
|
+
for (const groupName of groupNames) {
|
|
578
|
+
effectiveGroupRows.push({ user_id: userId, group_name: groupName });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (effectiveGroupRows.length > 0) {
|
|
582
|
+
await runCheckpoint();
|
|
583
|
+
await tx`INSERT INTO auth.ipa_user_effective_groups ${sql(effectiveGroupRows, "user_id", "group_name")} ON CONFLICT DO NOTHING`;
|
|
584
|
+
}
|
|
585
|
+
effectiveGroupsRebuilt = effectiveGroupRows.length;
|
|
586
|
+
|
|
587
|
+
// Bulk INSERT helper. Bun's `sql(rows)` only generates valid VALUES
|
|
588
|
+
// syntax when fed an array of OBJECTS (it derives the column list from
|
|
589
|
+
// object keys). Passing array-of-arrays produces broken SQL like
|
|
590
|
+
// `VALUES ("0", "1") VALUES($1, $2),...` — see Bun postgres bug. So we
|
|
591
|
+
// build typed objects and use the column-form `sql(rows, "col_a", "col_b")`
|
|
592
|
+
// to control column ordering explicitly.
|
|
593
|
+
|
|
594
|
+
// user_groups — built from group's member_user (authoritative source)
|
|
595
|
+
const userGroupRows: { user_id: string; group_id: string }[] = [];
|
|
596
|
+
for (const g of groups) {
|
|
597
|
+
const groupId = groupNameToId.get(g.cn);
|
|
598
|
+
if (!groupId) continue;
|
|
599
|
+
for (const uid of g.users) {
|
|
600
|
+
const userId = uidToId.get(uid);
|
|
601
|
+
if (userId) userGroupRows.push({ user_id: userId, group_id: groupId });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (userGroupRows.length > 0) {
|
|
605
|
+
await runCheckpoint();
|
|
606
|
+
await tx`INSERT INTO auth.user_groups_v2 ${sql(userGroupRows, "user_id", "group_id")} ON CONFLICT DO NOTHING`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// group_groups + manager junctions
|
|
610
|
+
const groupGroupRows: { parent_group_id: string; child_group_id: string }[] = [];
|
|
611
|
+
const managerUserRows: { group_id: string; user_id: string }[] = [];
|
|
612
|
+
const managerGroupRows: { group_id: string; manager_group_id: string }[] = [];
|
|
613
|
+
for (const g of groups) {
|
|
614
|
+
const groupId = groupNameToId.get(g.cn);
|
|
615
|
+
if (!groupId) continue;
|
|
616
|
+
for (const child of g.groups) {
|
|
617
|
+
const childGroupId = groupNameToId.get(child);
|
|
618
|
+
if (childGroupId) groupGroupRows.push({ parent_group_id: groupId, child_group_id: childGroupId });
|
|
619
|
+
}
|
|
620
|
+
for (const uid of g.managerUsers) {
|
|
621
|
+
const userId = uidToId.get(uid);
|
|
622
|
+
if (userId) managerUserRows.push({ group_id: groupId, user_id: userId });
|
|
623
|
+
}
|
|
624
|
+
for (const mgr of g.managerGroups) {
|
|
625
|
+
const managerGroupId = groupNameToId.get(mgr);
|
|
626
|
+
if (managerGroupId) managerGroupRows.push({ group_id: groupId, manager_group_id: managerGroupId });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (groupGroupRows.length > 0) {
|
|
630
|
+
await runCheckpoint();
|
|
631
|
+
await tx`INSERT INTO auth.group_groups_v2 ${sql(groupGroupRows, "parent_group_id", "child_group_id")} ON CONFLICT DO NOTHING`;
|
|
632
|
+
}
|
|
633
|
+
if (managerUserRows.length > 0) {
|
|
634
|
+
await tx`INSERT INTO auth.group_manager_users_v2 ${sql(managerUserRows, "group_id", "user_id")} ON CONFLICT DO NOTHING`;
|
|
635
|
+
}
|
|
636
|
+
if (managerGroupRows.length > 0) {
|
|
637
|
+
await tx`INSERT INTO auth.group_manager_groups_v2 ${sql(managerGroupRows, "group_id", "manager_group_id")} ON CONFLICT DO NOTHING`;
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
const transactionDurationMs = Date.now() - transactionStartedAt;
|
|
641
|
+
|
|
642
|
+
log.info("Sync complete", {
|
|
643
|
+
durationMs: Date.now() - startedAt,
|
|
644
|
+
snapshotFetchDurationMs,
|
|
645
|
+
transactionDurationMs,
|
|
646
|
+
leaseHeartbeats,
|
|
647
|
+
remoteUsersFetched: allRawUsers.length,
|
|
648
|
+
remoteUsersInScope: users.length,
|
|
649
|
+
remoteExpiredUsers: expiredUsers,
|
|
650
|
+
activeUsersSynced: activeUsers.length,
|
|
651
|
+
matchedExistingUsersByMail,
|
|
652
|
+
migratedLocalUsers,
|
|
653
|
+
skippedLocalMailConflicts,
|
|
654
|
+
skippedLocalUidConflicts,
|
|
655
|
+
staleUsersDemoted: scopeTransitions,
|
|
656
|
+
scopeTransitions,
|
|
657
|
+
profileDriftCount,
|
|
658
|
+
profilesPromoted,
|
|
659
|
+
profilesDemoted,
|
|
660
|
+
effectiveGroupsRebuilt,
|
|
661
|
+
upsertedUsersByUid,
|
|
662
|
+
insertedUsersByUid,
|
|
663
|
+
updatedUsersByUid,
|
|
664
|
+
groupsSynced: groups.length,
|
|
665
|
+
deletedGroups,
|
|
666
|
+
localIpaUsersBefore: localIpaUsers,
|
|
667
|
+
localGroupsBefore: localGroups,
|
|
668
|
+
destructiveUserChanges: guard.userChanges,
|
|
669
|
+
destructiveUserChangePercent: guard.userChangePercent,
|
|
670
|
+
destructiveGroupDeletions: guard.groupDeletions,
|
|
671
|
+
destructiveGroupDeletionPercent: guard.groupDeletionPercent,
|
|
672
|
+
destructionGuardLimits: guard.limits,
|
|
673
|
+
});
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Outcome of a single-user sync attempt. The login flow must reject any outcome
|
|
678
|
+
* other than `synced`; stale mirror state must never grant a fresh session when
|
|
679
|
+
* FreeIPA says the user is expired, missing, or out of sync scope.
|
|
680
|
+
*/
|
|
681
|
+
export type SyncUserOutcome =
|
|
682
|
+
| { status: "synced"; userId: string }
|
|
683
|
+
| { status: "skipped_disabled" }
|
|
684
|
+
| { status: "fetch_failed"; error: string }
|
|
685
|
+
| { status: "expired"; userId: string | null }
|
|
686
|
+
| { status: "out_of_scope"; userId: string | null }
|
|
687
|
+
| { status: "not_found_local" };
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Reconcile a local IPA mirror row after discovering the remote user is no
|
|
691
|
+
* longer valid because the IPA account is expired. Full sync also uses this
|
|
692
|
+
* transition policy for graph-derived out-of-scope users; single-user sync does
|
|
693
|
+
* not demote based on FreeIPA user-side memberOf data.
|
|
694
|
+
*/
|
|
695
|
+
const reconcileOutOfScopeUser = async (params: {
|
|
696
|
+
userId: string;
|
|
697
|
+
uid: string;
|
|
698
|
+
mail: string | null;
|
|
699
|
+
displayName: string | null;
|
|
700
|
+
previousProfile: "user" | "guest";
|
|
701
|
+
reason: "ipa_expired_demoted" | "ipa_expired_deleted" | "sync_out_of_scope_demoted" | "sync_out_of_scope_deleted";
|
|
702
|
+
meta?: Record<string, unknown>;
|
|
703
|
+
}): Promise<void> => {
|
|
704
|
+
const transitionPolicy = parseIpaAccountTransitionPolicy(await settings.get<string | null>("freeipa.account_transition_policy"));
|
|
705
|
+
|
|
706
|
+
await session.revokeAllForUser(params.userId);
|
|
707
|
+
await sql.begin(async (tx) => {
|
|
708
|
+
if (transitionPolicy === "delete") {
|
|
709
|
+
await writeDeletedAccountAudit({
|
|
710
|
+
db: tx,
|
|
711
|
+
userId: params.userId,
|
|
712
|
+
uid: params.uid,
|
|
713
|
+
mail: params.mail,
|
|
714
|
+
displayName: params.displayName,
|
|
715
|
+
previousProvider: "ipa",
|
|
716
|
+
previousProfile: params.previousProfile,
|
|
717
|
+
reason: params.reason.endsWith("_demoted")
|
|
718
|
+
? (params.reason.replace("_demoted", "_deleted") as typeof params.reason)
|
|
719
|
+
: params.reason,
|
|
720
|
+
meta: params.meta ?? {},
|
|
721
|
+
});
|
|
722
|
+
await tx`DELETE FROM auth.users WHERE id = ${params.userId}::uuid`;
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const target = await applyIpaAccountTransitionPolicy({
|
|
727
|
+
userId: params.userId,
|
|
728
|
+
currentProfile: params.previousProfile,
|
|
729
|
+
policy: transitionPolicy,
|
|
730
|
+
db: tx,
|
|
731
|
+
});
|
|
732
|
+
await writeDeletedAccountAudit({
|
|
733
|
+
db: tx,
|
|
734
|
+
userId: params.userId,
|
|
735
|
+
uid: params.uid,
|
|
736
|
+
mail: params.mail,
|
|
737
|
+
displayName: params.displayName,
|
|
738
|
+
previousProvider: "ipa",
|
|
739
|
+
previousProfile: params.previousProfile,
|
|
740
|
+
reason: params.reason,
|
|
741
|
+
meta: {
|
|
742
|
+
...(params.meta ?? {}),
|
|
743
|
+
accountExpiresAt: target.accountExpires?.toISOString() ?? null,
|
|
744
|
+
targetProfile: target.targetProfile,
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Sync a single user's attributes from FreeIPA.
|
|
752
|
+
* Called on login to ensure time-sensitive data is up-to-date.
|
|
753
|
+
*
|
|
754
|
+
* IMPORTANT: Only syncs user attributes (name, mail, expiry, aliases).
|
|
755
|
+
* Does NOT sync group memberships. Scope/profile come from the last full-sync
|
|
756
|
+
* effective group projection; user-side memberOf drift is logged but never used
|
|
757
|
+
* for destructive transitions here.
|
|
758
|
+
*
|
|
759
|
+
* Returns a typed outcome so callers (notably `authFlows.ipa.login`) can decide
|
|
760
|
+
* whether to grant a session. Expired users are reconciled immediately; group
|
|
761
|
+
* scope changes are handled by full sync.
|
|
762
|
+
*/
|
|
763
|
+
export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
|
|
764
|
+
const config = await getFreeIpaConfig();
|
|
765
|
+
if (!config.enabled) {
|
|
766
|
+
log.info("Single-user sync skipped", { reason: "freeipa_disabled", username });
|
|
767
|
+
return { status: "skipped_disabled" };
|
|
768
|
+
}
|
|
769
|
+
if (!config.configured) {
|
|
770
|
+
throw new Error(`FreeIPA is enabled but not fully configured. Missing: ${config.missingSettings.join(", ")}.`);
|
|
771
|
+
}
|
|
772
|
+
const ipaSession = await freeipa.session.getServiceSession({
|
|
773
|
+
url: config.url,
|
|
774
|
+
serviceUser: config.serviceUser,
|
|
775
|
+
servicePassword: config.servicePassword,
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// Fetch user from FreeIPA
|
|
779
|
+
const userRes = await freeipa.client.call({
|
|
780
|
+
url: config.url,
|
|
781
|
+
ipaSession,
|
|
782
|
+
method: "user_show",
|
|
783
|
+
args: [username],
|
|
784
|
+
options: { all: true },
|
|
785
|
+
});
|
|
786
|
+
if (userRes.error || !userRes.result?.result) {
|
|
787
|
+
const error = userRes.error?.message ?? "user_show returned empty result";
|
|
788
|
+
log.warn("Could not fetch user", { username, error });
|
|
789
|
+
return { status: "fetch_failed", error };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const raw = userRes.result.result as Record<string, unknown>;
|
|
793
|
+
const user = transformSyncUser(raw);
|
|
794
|
+
|
|
795
|
+
const existingRow: DbRow | undefined = (
|
|
796
|
+
await sql<DbRow[]>`
|
|
797
|
+
SELECT id, mail, display_name, profile
|
|
798
|
+
FROM auth.users
|
|
799
|
+
WHERE uid = ${user.uid} AND provider = 'ipa'
|
|
800
|
+
`
|
|
801
|
+
)[0];
|
|
802
|
+
const existingUserId = (existingRow?.id as string | undefined) ?? null;
|
|
803
|
+
const previousProfile: "user" | "guest" = (existingRow?.profile as "user" | "guest" | undefined) ?? "guest";
|
|
804
|
+
|
|
805
|
+
if (isExpired(user)) {
|
|
806
|
+
log.warn("User expired during single-user sync", { username });
|
|
807
|
+
if (existingUserId) {
|
|
808
|
+
await reconcileOutOfScopeUser({
|
|
809
|
+
userId: existingUserId,
|
|
810
|
+
uid: user.uid,
|
|
811
|
+
mail: (existingRow?.mail as string | null) ?? user.mail,
|
|
812
|
+
displayName: (existingRow?.display_name as string | null) ?? user.displayName,
|
|
813
|
+
previousProfile,
|
|
814
|
+
reason: "ipa_expired_demoted",
|
|
815
|
+
meta: { accountExpiresAt: user.ipaAccountExpires?.toISOString() ?? null },
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
return { status: "expired", userId: existingUserId };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (!existingUserId) {
|
|
822
|
+
log.warn("User not found in local DB during single-user sync", { username });
|
|
823
|
+
return { status: "not_found_local" };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const effectiveGroups = await getEffectiveUserGroups(existingUserId);
|
|
827
|
+
const inSyncGroups = config.groupsBaseSync.some((g) => effectiveGroups.includes(g));
|
|
828
|
+
if (!inSyncGroups) {
|
|
829
|
+
log.warn("User not in projected sync groups during single-user sync", {
|
|
830
|
+
username,
|
|
831
|
+
reason: "missing_from_last_full_sync_projection",
|
|
832
|
+
});
|
|
833
|
+
return { status: "out_of_scope", userId: existingUserId };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const profile = await calculateIpaProfileFromEffectiveProjection(existingUserId);
|
|
837
|
+
const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
|
|
838
|
+
if (profile !== userSideProfile) {
|
|
839
|
+
log.warn("IPA user memberOf drift detected during single-user sync", {
|
|
840
|
+
username,
|
|
841
|
+
projectedProfile: profile,
|
|
842
|
+
userSideProfile,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
const provider = "ipa";
|
|
846
|
+
|
|
847
|
+
// Update user attributes only (no group sync!)
|
|
848
|
+
await sql`
|
|
849
|
+
UPDATE auth.users SET
|
|
850
|
+
provider = ${provider},
|
|
851
|
+
profile = ${profile},
|
|
852
|
+
admin = false,
|
|
853
|
+
given_name = ${user.givenname},
|
|
854
|
+
sn = ${user.sn},
|
|
855
|
+
display_name = ${user.displayName},
|
|
856
|
+
mail = ${user.mail},
|
|
857
|
+
account_expires = ${user.ipaAccountExpires}
|
|
858
|
+
WHERE uid = ${user.uid}
|
|
859
|
+
`;
|
|
860
|
+
await sql.begin((tx) => upsertUserIpaData(tx, { userId: existingUserId, ...user }));
|
|
861
|
+
return { status: "synced", userId: existingUserId };
|
|
862
|
+
};
|