@k2b/cloud 0.43.0 → 0.45.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/package.json +11 -2
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +158 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +7 -5
- package/src/_internal/define-app.ts +57 -6
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +11 -1
- package/src/_internal/registry.ts +8 -1
- package/src/_internal/runtime-context.ts +8 -1
- package/src/access/AppAccessEditor.tsx +2 -0
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +20 -5
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/PrincipalPicker.tsx +14 -4
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-messages.ts +6 -0
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +7 -0
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +10 -6
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +40 -25
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +12 -5
- package/src/ai/inference-calls.ts +5 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api.ts +26 -9
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +58 -47
- package/src/ai/projects.ts +110 -88
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +55 -6
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider.ts +1 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +4 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +9 -3
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +47 -48
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/skill-tool.ts +4 -4
- package/src/ai/skills-routes.ts +27 -26
- package/src/ai/skills.ts +120 -81
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +21 -44
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +27 -22
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +22 -3
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +35 -5
- package/src/api/capabilities.ts +28 -4
- package/src/api/index.ts +7 -1
- package/src/api/mcp.ts +175 -2
- package/src/api/me.ts +6 -4
- package/src/api/pwa-phone.ts +75 -26
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/app-pwa/contracts.ts +4 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +1 -0
- package/src/app-pwa/service.ts +91 -0
- package/src/app-pwa/transport.ts +22 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/browser/session.ts +39 -0
- package/src/browser/shared-text-history-validation.ts +151 -0
- package/src/browser/shared-text-history.ts +464 -0
- package/src/browser/shared-text-offline-authorized.ts +29 -0
- package/src/browser/shared-text-offline.ts +351 -0
- package/src/browser/shared-text-provider.ts +714 -0
- package/src/browser/shared-text-table-contract.ts +150 -0
- package/src/browser/shared-text.ts +19 -0
- package/src/capabilities/server-invoke.ts +2 -11
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +5 -2
- package/src/cli/apps.ts +89 -26
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +26 -2
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +21 -5
- package/src/contracts/capabilities.ts +143 -3
- package/src/contracts/index.ts +2 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/pwa.ts +17 -1
- package/src/contracts/registry.ts +6 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +6 -1
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +1 -1
- package/src/server/api/respond.ts +17 -4
- package/src/server/index.ts +2 -0
- package/src/server/middleware/app-access.ts +1 -1
- package/src/server/middleware/auth.ts +21 -7
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +2 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +31 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +36 -13
- package/src/services/accounts/entities.ts +43 -19
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +19 -14
- package/src/services/accounts/identities.ts +70 -11
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +69 -4
- package/src/services/accounts/users.ts +45 -14
- package/src/services/app-access.ts +25 -7
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/ledger.ts +28 -10
- package/src/services/geo/service.ts +43 -24
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/invocation-authority.ts +4 -0
- package/src/services/identity/invocation-token.ts +238 -56
- package/src/services/identity/issue-invocation.ts +6 -1
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +13 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +20 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/session/index.ts +26 -3
- package/src/services/settings/core-settings.ts +71 -43
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/email-html.ts +1 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/markdown/extensions/katex.ts +8 -1
- package/src/shared/markdown/extensions/tables.ts +14 -1
- package/src/shared/markdown/index.ts +22 -11
- package/src/shared-text/anchors.ts +29 -0
- package/src/shared-text/awareness.ts +87 -0
- package/src/shared-text/changes.ts +27 -0
- package/src/shared-text/documents.ts +433 -65
- package/src/shared-text/events.ts +12 -2
- package/src/shared-text/index.ts +16 -1
- package/src/shared-text/log.ts +24 -5
- package/src/shared-text/snapshots.ts +69 -23
- package/src/shared-text/structural-history.ts +377 -0
- package/src/shared-text/table-actions.ts +432 -0
- package/src/shared-text/text-diff.ts +75 -0
- package/src/shared-text/types.ts +24 -5
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +9 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +2 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +44 -7
- package/src/ssr/Layout.tsx +9 -7
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/PwaLayout.tsx +16 -1
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +26 -31
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/index.ts +8 -0
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +11 -1
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/pwa-messages.ts +2 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +3 -1
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/language/expressions.ts +3 -2
- package/src/workflows/runtime/executor.ts +134 -34
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- package/src/shared/ai-model-request-settings.ts +0 -21
|
@@ -31,8 +31,6 @@ const listCanonical = async (params: {
|
|
|
31
31
|
scope?: GroupListScope;
|
|
32
32
|
search?: string;
|
|
33
33
|
provider?: UserProvider;
|
|
34
|
-
/** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
|
|
35
|
-
includePersonal?: boolean;
|
|
36
34
|
page?: number;
|
|
37
35
|
perPage?: number;
|
|
38
36
|
}): Promise<{
|
|
@@ -48,7 +46,6 @@ const listCanonical = async (params: {
|
|
|
48
46
|
const scope = params.scope ?? (params.userId ? "member" : "all");
|
|
49
47
|
const scopeUserId = params.userId ?? "00000000-0000-0000-0000-000000000000";
|
|
50
48
|
const idsCondition = ids.length === 0 ? sql`TRUE` : sql`g.id = ANY(${toPgUuidArray(ids)}::uuid[])`;
|
|
51
|
-
const includePersonal = params.includePersonal === true || params.ids !== undefined;
|
|
52
49
|
|
|
53
50
|
if (params.ids && params.ids.length === 0) {
|
|
54
51
|
return {
|
|
@@ -69,7 +66,7 @@ const listCanonical = async (params: {
|
|
|
69
66
|
${personalOwnerJoin()}
|
|
70
67
|
WHERE (${params.provider ?? null}::text IS NULL OR g.provider = ${params.provider ?? null})
|
|
71
68
|
AND ${idsCondition}
|
|
72
|
-
AND (
|
|
69
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
73
70
|
AND (
|
|
74
71
|
${scope === "all"} = true
|
|
75
72
|
OR ${params.userId ?? null}::uuid IS NULL
|
|
@@ -86,7 +83,9 @@ const listCanonical = async (params: {
|
|
|
86
83
|
OFFSET ${offset}
|
|
87
84
|
`;
|
|
88
85
|
|
|
89
|
-
|
|
86
|
+
// A page past the end has no rows to carry the window count; the total still counts every match.
|
|
87
|
+
let total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
|
|
88
|
+
if (!rows.length && page > 1) total = (await listCanonical({ ...params, page: 1, perPage: 1 })).total;
|
|
90
89
|
return {
|
|
91
90
|
groups: rows.map(buildBaseGroup),
|
|
92
91
|
total,
|
|
@@ -105,8 +104,6 @@ export const list = async (params: {
|
|
|
105
104
|
scope?: GroupListScope;
|
|
106
105
|
search?: string;
|
|
107
106
|
provider?: UserProvider;
|
|
108
|
-
/** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
|
|
109
|
-
includePersonal?: boolean;
|
|
110
107
|
page?: number;
|
|
111
108
|
perPage?: number;
|
|
112
109
|
}) => {
|
|
@@ -120,13 +117,13 @@ export const get = async (params: { id: string }): Promise<BaseGroup | null> =>
|
|
|
120
117
|
export const getMembers = async (params: {
|
|
121
118
|
id: string;
|
|
122
119
|
provider?: UserProvider;
|
|
123
|
-
type?: "
|
|
120
|
+
type?: GroupMember["type"];
|
|
124
121
|
recursive?: boolean;
|
|
125
122
|
}): Promise<GroupMember[]> => {
|
|
126
123
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
127
124
|
if (provider === "local") return localGroups.getMembers(params);
|
|
128
|
-
if (!provider) return [];
|
|
129
|
-
return providers.ipa.groups.getMembers(params);
|
|
125
|
+
if (!provider || params.type === "service_account") return [];
|
|
126
|
+
return providers.ipa.groups.getMembers({ ...params, type: params.type });
|
|
130
127
|
};
|
|
131
128
|
|
|
132
129
|
export const getManagers = async (params: {
|
|
@@ -223,9 +220,13 @@ export const addMember = async (params: {
|
|
|
223
220
|
provider?: UserProvider;
|
|
224
221
|
user?: string;
|
|
225
222
|
group?: string;
|
|
223
|
+
serviceAccount?: string;
|
|
226
224
|
}): Promise<MutationResult<void>> => {
|
|
227
|
-
const provider =
|
|
228
|
-
if (provider === "local")
|
|
225
|
+
const provider = (await getGroup(params.id))?.provider;
|
|
226
|
+
if (provider === "local")
|
|
227
|
+
return localGroups.addMember({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
|
|
228
|
+
if (params.serviceAccount)
|
|
229
|
+
return { ok: false, error: "Service accounts can only join local Cloud groups, not directory groups", status: 400 };
|
|
229
230
|
const serviceSession = await getServiceIpaSession();
|
|
230
231
|
if (!serviceSession.ok) return serviceSession;
|
|
231
232
|
return providers.ipa.groups.addMember({
|
|
@@ -241,9 +242,13 @@ export const removeMember = async (params: {
|
|
|
241
242
|
provider?: UserProvider;
|
|
242
243
|
user?: string;
|
|
243
244
|
group?: string;
|
|
245
|
+
serviceAccount?: string;
|
|
244
246
|
}): Promise<MutationResult<void>> => {
|
|
245
|
-
const provider =
|
|
246
|
-
if (provider === "local")
|
|
247
|
+
const provider = (await getGroup(params.id))?.provider;
|
|
248
|
+
if (provider === "local")
|
|
249
|
+
return localGroups.removeMember({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
|
|
250
|
+
if (params.serviceAccount)
|
|
251
|
+
return { ok: false, error: "Service accounts can only join local Cloud groups, not directory groups", status: 400 };
|
|
247
252
|
const serviceSession = await getServiceIpaSession();
|
|
248
253
|
if (!serviceSession.ok) return serviceSession;
|
|
249
254
|
return providers.ipa.groups.removeMember({
|
|
@@ -189,40 +189,99 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
|
|
|
189
189
|
eligible: current.profile === "user",
|
|
190
190
|
};
|
|
191
191
|
}
|
|
192
|
+
const [ownership] = await db<{ personal: boolean }[]>`SELECT EXISTS (
|
|
193
|
+
SELECT 1 FROM auth.personal_groups p
|
|
194
|
+
WHERE (${input.identityId ?? null}::uuid IS NOT NULL AND p.group_id = ${input.identityId ?? null}::uuid)
|
|
195
|
+
OR (${input.identityId ?? null}::uuid IS NULL AND p.group_name = ${input.name})
|
|
196
|
+
) AS personal`;
|
|
197
|
+
const personal = ownership?.personal === true;
|
|
192
198
|
const rows = await groupRows(sql`(g.provider = 'local' AND g.name = ${input.name}) OR g.id = ${input.identityId ?? null}::uuid`);
|
|
193
199
|
const expected = rows.find((row) => row.id === input.identityId);
|
|
194
200
|
if (expected && (expected.provider !== "local" || expected.name !== input.name))
|
|
195
201
|
return { state: "unknown", reason: "identity_conflict" };
|
|
196
202
|
const matches = rows.filter((row) => row.provider === "local" && row.name === input.name);
|
|
197
|
-
if (!matches.length) return { state: "absent" };
|
|
203
|
+
if (!matches.length) return personal ? { state: "absent", personal: true } : { state: "absent" };
|
|
198
204
|
if (matches.length !== 1) return { state: "unknown", reason: "invalid_response" };
|
|
199
205
|
const current = matches[0]!;
|
|
200
206
|
return {
|
|
201
207
|
state: "present",
|
|
202
208
|
identity: { id: current.id, name: current.name, uidNumber: null, gidNumber: current.gidNumber },
|
|
203
209
|
eligible: current.gidNumber !== null,
|
|
210
|
+
...(personal ? { personal: true } : {}),
|
|
204
211
|
};
|
|
205
212
|
};
|
|
206
213
|
const validateLookup = (input: { kind: "users" | "groups"; name: string }) => {
|
|
207
214
|
if ((input.kind !== "users" && input.kind !== "groups") || typeof input.name !== "string" || !input.name || input.name.includes("\0"))
|
|
208
215
|
throw new AccountIdentityError("invalid_identity_lookup", 400);
|
|
209
216
|
};
|
|
217
|
+
const reconcileIdentity = async (
|
|
218
|
+
input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
|
|
219
|
+
state: AccountIdentityAvailability,
|
|
220
|
+
): Promise<AccountIdentityReconciliation> => {
|
|
221
|
+
validateLookup(input);
|
|
222
|
+
if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
|
|
223
|
+
if (input.identityId !== undefined) cursor(input.identityId);
|
|
224
|
+
if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
|
|
225
|
+
if (input.provider === "ipa") {
|
|
226
|
+
if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
|
|
227
|
+
return upstreamIdentity(input);
|
|
228
|
+
}
|
|
229
|
+
const result = await readLocalIdentity(input);
|
|
230
|
+
return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
|
|
231
|
+
};
|
|
210
232
|
return {
|
|
233
|
+
/** Trusted application provisioning only; no profile, grants, credentials, or administrator impersonation. */
|
|
234
|
+
async provisioningUsers(input: { provider?: UserProvider; id?: string; after?: string }) {
|
|
235
|
+
if (input.provider !== undefined && input.provider !== "local" && input.provider !== "ipa")
|
|
236
|
+
throw new AccountIdentityError("invalid_provider", 400);
|
|
237
|
+
const id = cursor(input.id);
|
|
238
|
+
const after = cursor(input.after);
|
|
239
|
+
const state = await availability();
|
|
240
|
+
const [localAllowed, ipaAllowed] = await Promise.all([
|
|
241
|
+
isAccountCategoryAllowed({ provider: "local", profile: "user" }, db),
|
|
242
|
+
isAccountCategoryAllowed({ provider: "ipa", profile: "user" }, db),
|
|
243
|
+
]);
|
|
244
|
+
const rows = await db<
|
|
245
|
+
{
|
|
246
|
+
id: string;
|
|
247
|
+
provider: UserProvider;
|
|
248
|
+
uid: string;
|
|
249
|
+
managed_by: UserProvider | null;
|
|
250
|
+
uid_number: number | null;
|
|
251
|
+
primary_gid_number: number | null;
|
|
252
|
+
}[]
|
|
253
|
+
>`SELECT u.id,u.provider,u.uid,p.managed_by,p.uid_number,p.primary_gid_number
|
|
254
|
+
FROM auth.users u LEFT JOIN auth.user_posix p ON p.user_id=u.id
|
|
255
|
+
WHERE u.provider IN ('local','ipa') AND (${input.provider ?? null}::text IS NULL OR u.provider=${input.provider ?? null})
|
|
256
|
+
AND u.profile='user'
|
|
257
|
+
AND (u.account_expires IS NULL OR u.account_expires>now())
|
|
258
|
+
AND (${id}::uuid IS NULL OR u.id=${id}::uuid)
|
|
259
|
+
AND (${after}::uuid IS NULL OR u.id>${after}::uuid)
|
|
260
|
+
ORDER BY u.id LIMIT ${PAGE_SIZE + 1}`;
|
|
261
|
+
const source = page(rows);
|
|
262
|
+
const items = source.items
|
|
263
|
+
.filter((row) => (row.provider === "local" ? localAllowed : ipaAllowed && state.freeipaEnabled))
|
|
264
|
+
.map((row) => ({
|
|
265
|
+
id: row.id,
|
|
266
|
+
provider: row.provider,
|
|
267
|
+
username: row.uid,
|
|
268
|
+
posix:
|
|
269
|
+
row.managed_by === row.provider && positiveId(row.uid_number) && positiveId(row.primary_gid_number)
|
|
270
|
+
? { uidNumber: row.uid_number, primaryGidNumber: row.primary_gid_number }
|
|
271
|
+
: null,
|
|
272
|
+
}));
|
|
273
|
+
return { items, nextCursor: source.nextCursor, availability: state };
|
|
274
|
+
},
|
|
211
275
|
async reconcile(
|
|
212
276
|
actor: RequestActor,
|
|
213
277
|
input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
|
|
214
278
|
): Promise<AccountIdentityReconciliation> {
|
|
215
279
|
const { state } = await requireAdmin(actor);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
|
|
222
|
-
return upstreamIdentity(input);
|
|
223
|
-
}
|
|
224
|
-
const result = await readLocalIdentity(input);
|
|
225
|
-
return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
|
|
280
|
+
return reconcileIdentity(input, state);
|
|
281
|
+
},
|
|
282
|
+
/** Trusted provisioning services use the same live identity check as administrator provisioning. */
|
|
283
|
+
async provisioningIdentity(input: { kind: "users"; provider: UserProvider; name: string; identityId: string; signal?: AbortSignal }) {
|
|
284
|
+
return reconcileIdentity(input, await availability());
|
|
226
285
|
},
|
|
227
286
|
/** Trusted application jobs only; this read does not authorize an HTTP caller or a filesystem mutation. */
|
|
228
287
|
async localLifecycle(input: { kind: "users" | "groups"; identityId: string; name: string }) {
|
|
@@ -9,8 +9,10 @@ export type AccountIdentityReconciliation =
|
|
|
9
9
|
state: "present";
|
|
10
10
|
identity: { id: string | null; name: string; uidNumber: number | null; gidNumber: number | null };
|
|
11
11
|
eligible: boolean;
|
|
12
|
+
/** Proven Cloud personal-group ownership, including retained directory bindings. */
|
|
13
|
+
personal?: boolean;
|
|
12
14
|
}
|
|
13
|
-
| { state: "absent" }
|
|
15
|
+
| { state: "absent"; personal?: boolean }
|
|
14
16
|
| { state: "unknown"; reason: "provider_disabled" | "provider_unavailable" | "invalid_response" | "identity_conflict" };
|
|
15
17
|
export type IdentityLookup = { kind: "users" | "groups"; name: string; signal?: AbortSignal };
|
|
16
18
|
|
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import { escapeLikePattern, isUniqueViolation } from "../postgres";
|
|
4
4
|
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
5
|
+
import { recursiveServiceAccountGroupIdsSubquery } from "./group-sql";
|
|
5
6
|
|
|
6
7
|
type DbRow = Record<string, unknown>;
|
|
7
8
|
|
|
@@ -122,6 +123,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
|
|
|
122
123
|
SELECT COUNT(*)::int AS count
|
|
123
124
|
FROM auth.groups g
|
|
124
125
|
WHERE g.provider = 'local'
|
|
126
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
125
127
|
AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
|
|
126
128
|
`;
|
|
127
129
|
const total = Number(countRow?.count ?? 0);
|
|
@@ -130,6 +132,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
|
|
|
130
132
|
FROM auth.groups g
|
|
131
133
|
${personalOwnerJoin()}
|
|
132
134
|
WHERE g.provider = 'local'
|
|
135
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
133
136
|
AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
|
|
134
137
|
ORDER BY g.name
|
|
135
138
|
LIMIT ${perPage} OFFSET ${offset}
|
|
@@ -177,7 +180,7 @@ export const remove = async (params: { id: string }): Promise<MutationResult<voi
|
|
|
177
180
|
return { ok: true, data: undefined };
|
|
178
181
|
};
|
|
179
182
|
|
|
180
|
-
export const getMembers = async (params: { id: string; type?: "
|
|
183
|
+
export const getMembers = async (params: { id: string; type?: GroupMember["type"]; recursive?: boolean }): Promise<GroupMember[]> => {
|
|
181
184
|
const group = await getLocalGroupById(params.id);
|
|
182
185
|
if (!group) return [];
|
|
183
186
|
const members: GroupMember[] = [];
|
|
@@ -231,6 +234,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
|
|
|
231
234
|
SELECT DISTINCT g.id, g.name
|
|
232
235
|
FROM local_group_tree tree
|
|
233
236
|
JOIN auth.groups g ON g.id = tree.child_group_id
|
|
237
|
+
WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
234
238
|
ORDER BY g.name
|
|
235
239
|
`
|
|
236
240
|
: await sql<DbRow[]>`
|
|
@@ -238,6 +242,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
|
|
|
238
242
|
FROM auth.group_groups_v2 gg
|
|
239
243
|
JOIN auth.groups g ON g.id = gg.child_group_id
|
|
240
244
|
WHERE gg.parent_group_id = ${group.id} AND g.provider = 'local'
|
|
245
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
241
246
|
ORDER BY g.name
|
|
242
247
|
`;
|
|
243
248
|
for (const row of groupRows) {
|
|
@@ -245,6 +250,25 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
|
|
|
245
250
|
}
|
|
246
251
|
}
|
|
247
252
|
|
|
253
|
+
if (!params.type || params.type === "service_account") {
|
|
254
|
+
const rows = await sql<{ id: string; name: string; kind: "standalone" | "agent" }[]>`
|
|
255
|
+
SELECT sa.id, sa.name, sa.kind
|
|
256
|
+
FROM auth.service_accounts sa
|
|
257
|
+
WHERE sa.kind IN ('standalone', 'agent') AND ${
|
|
258
|
+
params.recursive === true
|
|
259
|
+
? sql`${group.id}::uuid IN (${recursiveServiceAccountGroupIdsSubquery(sql`sa.id`)})`
|
|
260
|
+
: sql`EXISTS (
|
|
261
|
+
SELECT 1 FROM auth.service_account_groups sg
|
|
262
|
+
WHERE sg.service_account_id = sa.id AND sg.group_id = ${group.id}::uuid
|
|
263
|
+
)`
|
|
264
|
+
}
|
|
265
|
+
ORDER BY sa.name, sa.id
|
|
266
|
+
`;
|
|
267
|
+
members.push(
|
|
268
|
+
...rows.map((row) => ({ type: "service_account" as const, id: row.id, displayName: row.name, serviceAccountKind: row.kind })),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
248
272
|
return members;
|
|
249
273
|
};
|
|
250
274
|
|
|
@@ -310,6 +334,7 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
|
|
|
310
334
|
FROM auth.group_manager_groups_v2 gmg
|
|
311
335
|
JOIN auth.groups g ON g.id = gmg.manager_group_id
|
|
312
336
|
WHERE gmg.group_id = ${group.id} AND g.provider = 'local'
|
|
337
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
313
338
|
ORDER BY g.name
|
|
314
339
|
`;
|
|
315
340
|
for (const row of groupRows) {
|
|
@@ -340,7 +365,8 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
|
|
|
340
365
|
WHERE g.provider = 'local'
|
|
341
366
|
)
|
|
342
367
|
SELECT DISTINCT parent_group_id
|
|
343
|
-
FROM local_parent_tree
|
|
368
|
+
FROM local_parent_tree tree
|
|
369
|
+
WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = tree.parent_group_id)
|
|
344
370
|
`
|
|
345
371
|
: await sql<DbRow[]>`
|
|
346
372
|
SELECT gg.parent_group_id
|
|
@@ -348,6 +374,7 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
|
|
|
348
374
|
JOIN auth.groups g ON g.id = gg.parent_group_id
|
|
349
375
|
WHERE gg.child_group_id = ${group.id}
|
|
350
376
|
AND g.provider = 'local'
|
|
377
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
351
378
|
ORDER BY g.name
|
|
352
379
|
`;
|
|
353
380
|
|
|
@@ -374,6 +401,7 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
|
|
|
374
401
|
JOIN auth.group_manager_groups_v2 gmg ON gmg.manager_group_id = tree.manager_group_id
|
|
375
402
|
JOIN auth.groups g ON g.id = gmg.group_id
|
|
376
403
|
WHERE g.provider = 'local'
|
|
404
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
377
405
|
`
|
|
378
406
|
: await sql<DbRow[]>`
|
|
379
407
|
SELECT gmg.group_id
|
|
@@ -381,15 +409,38 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
|
|
|
381
409
|
JOIN auth.groups g ON g.id = gmg.group_id
|
|
382
410
|
WHERE gmg.manager_group_id = ${group.id}
|
|
383
411
|
AND g.provider = 'local'
|
|
412
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
384
413
|
ORDER BY g.name
|
|
385
414
|
`;
|
|
386
415
|
|
|
387
416
|
return rows.map((row) => row.group_id as string);
|
|
388
417
|
};
|
|
389
418
|
|
|
390
|
-
export const addMember = async (params: {
|
|
419
|
+
export const addMember = async (params: {
|
|
420
|
+
id: string;
|
|
421
|
+
user?: string;
|
|
422
|
+
group?: string;
|
|
423
|
+
serviceAccount?: string;
|
|
424
|
+
}): Promise<MutationResult<void>> => {
|
|
391
425
|
const group = await getLocalGroupById(params.id);
|
|
392
426
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
427
|
+
const [personal] = await sql`SELECT 1 FROM auth.personal_groups
|
|
428
|
+
WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
|
|
429
|
+
if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
|
|
430
|
+
|
|
431
|
+
if (params.serviceAccount) {
|
|
432
|
+
const [account] = await sql<{ id: string }[]>`
|
|
433
|
+
SELECT id FROM auth.service_accounts
|
|
434
|
+
WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')
|
|
435
|
+
`;
|
|
436
|
+
if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
|
|
437
|
+
const inserted = await sql`
|
|
438
|
+
INSERT INTO auth.service_account_groups (service_account_id, group_id)
|
|
439
|
+
VALUES (${account.id}::uuid, ${group.id}::uuid) ON CONFLICT DO NOTHING
|
|
440
|
+
`;
|
|
441
|
+
if (inserted.count === 0) return { ok: false, error: "Service account is already a direct member of this group", status: 409 };
|
|
442
|
+
return { ok: true, data: undefined };
|
|
443
|
+
}
|
|
393
444
|
|
|
394
445
|
if (params.user) {
|
|
395
446
|
const provider = await getUserProvider(params.user);
|
|
@@ -427,10 +478,21 @@ export const addMember = async (params: { id: string; user?: string; group?: str
|
|
|
427
478
|
return { ok: false, error: "Missing member", status: 400 };
|
|
428
479
|
};
|
|
429
480
|
|
|
430
|
-
export const removeMember = async (params: {
|
|
481
|
+
export const removeMember = async (params: {
|
|
482
|
+
id: string;
|
|
483
|
+
user?: string;
|
|
484
|
+
group?: string;
|
|
485
|
+
serviceAccount?: string;
|
|
486
|
+
}): Promise<MutationResult<void>> => {
|
|
431
487
|
const group = await getLocalGroupById(params.id);
|
|
432
488
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
433
489
|
|
|
490
|
+
if (params.serviceAccount) {
|
|
491
|
+
await sql`DELETE FROM auth.service_account_groups
|
|
492
|
+
WHERE service_account_id = ${params.serviceAccount}::uuid AND group_id = ${group.id}::uuid`;
|
|
493
|
+
return { ok: true, data: undefined };
|
|
494
|
+
}
|
|
495
|
+
|
|
434
496
|
if (params.user) {
|
|
435
497
|
await sql`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
|
|
436
498
|
return { ok: true, data: undefined };
|
|
@@ -447,6 +509,9 @@ export const removeMember = async (params: { id: string; user?: string; group?:
|
|
|
447
509
|
export const addManager = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
|
|
448
510
|
const group = await getLocalGroupById(params.id);
|
|
449
511
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
512
|
+
const [personal] = await sql`SELECT 1 FROM auth.personal_groups
|
|
513
|
+
WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
|
|
514
|
+
if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
|
|
450
515
|
|
|
451
516
|
if (params.user) {
|
|
452
517
|
const provider = await getUserProvider(params.user);
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BaseUser,
|
|
4
|
+
MutationResult,
|
|
5
|
+
PaginationResponse,
|
|
6
|
+
RequestActor,
|
|
7
|
+
Role,
|
|
8
|
+
User,
|
|
9
|
+
UserProfile,
|
|
10
|
+
UserProvider,
|
|
11
|
+
} from "../../contracts/shared";
|
|
3
12
|
import { freeipa } from "../../server/services";
|
|
4
13
|
import { createAuthLoginUrl } from "../../shared/redirect";
|
|
5
14
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
6
15
|
import type { AuditActor } from "../audit";
|
|
7
|
-
import { getFreeIpaConfig } from "../freeipa-config";
|
|
16
|
+
import { getFreeIpaAdminGroups, getFreeIpaConfig } from "../freeipa-config";
|
|
8
17
|
import { getServiceIpaSession } from "../ipa/service-account";
|
|
9
18
|
import { toPgTextArray, toPgUuidArray } from "../postgres";
|
|
10
19
|
import { providers } from "../providers";
|
|
@@ -168,11 +177,12 @@ const buildUser = (row: DbRow, groupsAdmin: string[]): User => {
|
|
|
168
177
|
};
|
|
169
178
|
};
|
|
170
179
|
|
|
171
|
-
export const get = async (params: { id: string } | { uid: string }): Promise<User | null> => {
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const
|
|
180
|
+
export const get = async (params: { id: string } | { uid: string }, options: { db?: SQL } = {}): Promise<User | null> => {
|
|
181
|
+
const db = options.db ?? sql;
|
|
182
|
+
const whereClause = "id" in params ? db`u.id = ${params.id}` : db`u.uid = ${params.uid}`;
|
|
183
|
+
const userIdExpr = "id" in params ? db`${params.id}` : db`u.id`;
|
|
184
|
+
const groupsAdmin = options.db ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
|
|
185
|
+
const rows = await db<DbRow[]>`
|
|
176
186
|
SELECT u.*,
|
|
177
187
|
${userIpaDataColumns()},
|
|
178
188
|
CASE
|
|
@@ -224,9 +234,10 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
|
|
|
224
234
|
};
|
|
225
235
|
|
|
226
236
|
/** Audience roles for a resolved access subject, including delegated users and group managers. */
|
|
227
|
-
export const getRoles = async (userId: string): Promise<Role[]> => {
|
|
228
|
-
const
|
|
229
|
-
const
|
|
237
|
+
export const getRoles = async (userId: string, options?: { db: typeof sql }): Promise<Role[]> => {
|
|
238
|
+
const db = options?.db ?? sql;
|
|
239
|
+
const groupsAdmin = options ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
|
|
240
|
+
const [row] = await db<DbRow[]>`SELECT u.provider, u.profile,
|
|
230
241
|
CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
|
|
231
242
|
SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
|
|
232
243
|
AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
@@ -238,6 +249,26 @@ export const getRoles = async (userId: string): Promise<Role[]> => {
|
|
|
238
249
|
return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
|
|
239
250
|
};
|
|
240
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Revalidate installation authority and retain it until the caller commits.
|
|
254
|
+
* Lock users before grant/membership or mandate/profile locks, matching user
|
|
255
|
+
* deletion and provider sync. Projection and settings locks also cover rows
|
|
256
|
+
* inserted or replaced while lock acquisition was waiting.
|
|
257
|
+
*/
|
|
258
|
+
export const hasInstallationAdminInTransaction = async (actor: RequestActor, db: typeof sql): Promise<boolean> => {
|
|
259
|
+
if (actor.kind !== "user" || actor.sessionKind === "app" || !actor.user.roles.includes("admin")) return false;
|
|
260
|
+
const [user] = await db<{ provider: UserProvider; profile: UserProfile; expired: boolean }[]>`
|
|
261
|
+
SELECT provider, profile, (account_expires IS NOT NULL AND account_expires <= statement_timestamp()) AS expired
|
|
262
|
+
FROM auth.users WHERE id=${actor.user.id}::uuid FOR SHARE`;
|
|
263
|
+
if (!user || user.expired || user.profile !== "user") return false;
|
|
264
|
+
await db`LOCK TABLE settings.entries IN SHARE MODE`;
|
|
265
|
+
if (!(await isAccountCategoryAllowed(user, db))) return false;
|
|
266
|
+
if (user.provider === "ipa") {
|
|
267
|
+
await db`LOCK TABLE auth.ipa_user_effective_groups IN SHARE MODE`;
|
|
268
|
+
}
|
|
269
|
+
return (await getRoles(actor.user.id, { db })).includes("admin");
|
|
270
|
+
};
|
|
271
|
+
|
|
241
272
|
export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
|
|
242
273
|
const { groupsAdmin } = await getFreeIpaConfig();
|
|
243
274
|
const rows = await sql<DbRow[]>`
|
|
@@ -346,15 +377,15 @@ export const list = async (params: {
|
|
|
346
377
|
};
|
|
347
378
|
};
|
|
348
379
|
|
|
349
|
-
/**
|
|
350
|
-
export const getGroups = async (params: { id: string; recursive?: boolean
|
|
380
|
+
/** Exported group names exclude personal groups; authorization uses the complete group ID projection. */
|
|
381
|
+
export const getGroups = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
|
|
351
382
|
if (params.recursive) {
|
|
352
383
|
const rows = await sql<DbRow[]>`${recursiveUserGroupsSubquery({
|
|
353
384
|
userId: params.id,
|
|
354
385
|
select: sql`
|
|
355
386
|
SELECT DISTINCT g.name FROM user_all_groups ag
|
|
356
387
|
JOIN auth.groups g ON g.id = ag.group_id
|
|
357
|
-
WHERE
|
|
388
|
+
WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
358
389
|
ORDER BY g.name
|
|
359
390
|
`,
|
|
360
391
|
})}`;
|
|
@@ -363,7 +394,7 @@ export const getGroups = async (params: { id: string; recursive?: boolean; inclu
|
|
|
363
394
|
const rows = await sql<DbRow[]>`
|
|
364
395
|
SELECT g.name FROM auth.groups g
|
|
365
396
|
WHERE g.id IN (SELECT group_id FROM auth.user_groups_v2 WHERE user_id = ${params.id}::uuid)
|
|
366
|
-
AND
|
|
397
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
367
398
|
ORDER BY g.name
|
|
368
399
|
`;
|
|
369
400
|
return rows.map((row) => row.name as string);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
|
+
import { RESOURCE_ACCESS_TIMEOUT_MS, resourceAppAccess } from "../_internal/app-access-resources";
|
|
3
4
|
import type { AppMeta } from "../contracts/app";
|
|
4
5
|
import { AppAccessRuleSchema, type FixedAppAccessRule } from "../contracts/app-access";
|
|
5
6
|
import { userFromActor } from "../server/actor";
|
|
6
|
-
import type
|
|
7
|
+
import { type AuthContext, auth, type RequestAuthority } from "../server/middleware/auth";
|
|
7
8
|
import { buildAccessPrincipalCondition } from "../server/services/access";
|
|
8
9
|
import { logger } from "./logging";
|
|
9
10
|
import { toPgTextArray } from "./postgres";
|
|
@@ -17,7 +18,7 @@ export const appAccessAuthority = <E extends AuthContext>(c: Context<E>): Reques
|
|
|
17
18
|
const accessSubject = c.get("accessSubject");
|
|
18
19
|
const credentialKind = c.get("credentialKind");
|
|
19
20
|
if (!actor || !accessSubject || !credentialKind) return null;
|
|
20
|
-
return
|
|
21
|
+
return auth.getAuthority(c);
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
const credentialAllows = (authority: RequestAuthority, appId: string): boolean => {
|
|
@@ -65,6 +66,7 @@ export const evaluateAppAccess = (authority: RequestAuthority | null, app: Acces
|
|
|
65
66
|
const parsed = AppAccessRuleSchema.safeParse(app.access);
|
|
66
67
|
if (!parsed.success) return false;
|
|
67
68
|
const rule = parsed.data;
|
|
69
|
+
if (rule.kind === "resources") return false; // Asynchronous adapter decision only.
|
|
68
70
|
if (rule.kind !== "grants") return fixedAllows(authority, rule);
|
|
69
71
|
if (!state?.configured) return !!rule.until && fixedAllows(authority, rule.until);
|
|
70
72
|
return state.permission === "read" || state.permission === "admin";
|
|
@@ -74,21 +76,34 @@ let lastFailureLog = -Infinity;
|
|
|
74
76
|
const reportFailure = () => {
|
|
75
77
|
if (Date.now() - lastFailureLog < 60_000) return;
|
|
76
78
|
lastFailureLog = Date.now();
|
|
77
|
-
logger("app-access").error("Application access could not be resolved;
|
|
79
|
+
logger("app-access").error("Application access could not be resolved; affected applications are unavailable");
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
|
-
* One authoritative SQL read for all configurable
|
|
83
|
+
* One authoritative SQL read for all configurable lists; resource rules query the owning adapters.
|
|
84
|
+
* No cross-request grant cache: revocation applies on the next request.
|
|
82
85
|
* Pass the reserved transaction as `db` when checking access inside a guarded operation.
|
|
86
|
+
* A supplied `signal` replaces the default resource budget; the caller must bound it.
|
|
83
87
|
*/
|
|
84
88
|
export const usableAppIds = async (
|
|
85
89
|
authority: RequestAuthority | null,
|
|
86
90
|
apps: readonly AccessApp[],
|
|
87
|
-
options: { db?: SQL } = {},
|
|
91
|
+
options: { db?: SQL; signal?: AbortSignal } = {},
|
|
88
92
|
): Promise<Set<string>> => {
|
|
89
93
|
const db = options.db ?? sql;
|
|
90
94
|
const allowed = new Set(apps.filter((app) => app.access?.kind !== "grants" && evaluateAppAccess(authority, app)).map((app) => app.id));
|
|
91
95
|
if (!authority) return allowed;
|
|
96
|
+
let resourceSignal = options.signal;
|
|
97
|
+
// Sequential: one existence probe in flight per decision, no resource enumeration.
|
|
98
|
+
for (const app of apps) {
|
|
99
|
+
if (app.access?.kind !== "resources" || !credentialAllows(authority, app.id)) continue;
|
|
100
|
+
resourceSignal ??= AbortSignal.timeout(RESOURCE_ACCESS_TIMEOUT_MS);
|
|
101
|
+
try {
|
|
102
|
+
if (await resourceAppAccess(authority, app, { ...options, signal: resourceSignal })) allowed.add(app.id);
|
|
103
|
+
} catch {
|
|
104
|
+
if (!options.signal?.aborted) reportFailure();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
92
107
|
const configurable = apps.filter((app) => app.access?.kind === "grants" && credentialAllows(authority, app.id));
|
|
93
108
|
if (configurable.length === 0) return allowed;
|
|
94
109
|
try {
|
|
@@ -119,5 +134,8 @@ export const usableAppIds = async (
|
|
|
119
134
|
return allowed;
|
|
120
135
|
};
|
|
121
136
|
|
|
122
|
-
export const canUseApp = async (
|
|
123
|
-
|
|
137
|
+
export const canUseApp = async (
|
|
138
|
+
authority: RequestAuthority | null,
|
|
139
|
+
app: AccessApp,
|
|
140
|
+
options: { db?: SQL; signal?: AbortSignal } = {},
|
|
141
|
+
): Promise<boolean> => (await usableAppIds(authority, [app], options)).has(app.id);
|