@k2b/cloud 0.11.1 → 0.13.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 +7 -6
- package/src/access/PermissionEditor.tsx +5 -4
- package/src/access/PrincipalPicker.tsx +2 -1
- package/src/account/EntitySearch.tsx +5 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/store.ts +4 -4
- package/src/api/accounts-entities.ts +3 -1
- package/src/api/app-approval.ts +4 -1
- package/src/browser/live-websocket.ts +75 -10
- package/src/browser/mermaid.ts +202 -0
- package/src/browser/reload.ts +34 -0
- package/src/contracts/shared.ts +10 -0
- package/src/services/account-lifecycle/index.ts +2 -2
- package/src/services/accounts/app.ts +5 -2
- package/src/services/accounts/base-group.ts +29 -0
- package/src/services/accounts/entities.ts +22 -1
- package/src/services/accounts/groups.ts +13 -5
- package/src/services/accounts/identities.ts +7 -3
- package/src/services/accounts/local-groups.ts +12 -12
- package/src/services/app-approval.ts +62 -5
- package/src/services/ipa/groups.ts +3 -0
- package/src/services/ipa/search.ts +1 -0
- package/src/services/logging/index.ts +3 -3
- package/src/services/logging/trace.ts +1 -1
- package/src/services/notifications/batches.ts +1 -1
- package/src/services/notifications/index.ts +4 -4
- package/src/services/service-account-credentials.ts +1 -1
- package/src/services/webauthn.ts +1 -1
- package/src/shared/account-display.ts +14 -0
- package/src/shared/markdown/client.ts +3 -33
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default window in which a second automatic reload for the same key is
|
|
3
|
+
* suppressed. A page load, hydration, and the first live subscription finish
|
|
4
|
+
* within a few seconds, so a condition that fails again right after a reload
|
|
5
|
+
* lands well inside it; a genuinely new change minutes later still reloads.
|
|
6
|
+
*/
|
|
7
|
+
export const AUTOMATIC_RELOAD_WINDOW_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
const storageKey = (key: string) => `cloud.reload.${key}`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reloads the page for an automatic reason (a live event, a failed live
|
|
13
|
+
* subscription, a changed view) at most once per `key` and window in this tab.
|
|
14
|
+
*
|
|
15
|
+
* Returns `false` without reloading when the same key already reloaded within
|
|
16
|
+
* the window, or when `sessionStorage` is unavailable and a repeat therefore
|
|
17
|
+
* cannot be ruled out. The caller then keeps the page usable and offers a
|
|
18
|
+
* user-initiated reload instead. Reloads after an explicit user action do not
|
|
19
|
+
* need this guard.
|
|
20
|
+
*/
|
|
21
|
+
export const reloadOnce = (key: string, options: { windowMs?: number } = {}): boolean => {
|
|
22
|
+
const windowMs = options.windowMs ?? AUTOMATIC_RELOAD_WINDOW_MS;
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
try {
|
|
25
|
+
const storage = window.sessionStorage;
|
|
26
|
+
const last = Number(storage.getItem(storageKey(key)));
|
|
27
|
+
if (last > 0 && now >= last && now - last < windowMs) return false;
|
|
28
|
+
storage.setItem(storageKey(key), String(now));
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
window.location.reload();
|
|
33
|
+
return true;
|
|
34
|
+
};
|
package/src/contracts/shared.ts
CHANGED
|
@@ -85,12 +85,22 @@ export type LocalUser = z.infer<typeof LocalUserSchema>;
|
|
|
85
85
|
export const UserSchema = z.discriminatedUnion("provider", [IpaUserSchema, LocalUserSchema]);
|
|
86
86
|
export type User = z.infer<typeof UserSchema>;
|
|
87
87
|
|
|
88
|
+
/** The user whose personal Linux group (user private group) this is. */
|
|
89
|
+
export const PersonalGroupOwnerSchema = z.object({
|
|
90
|
+
id: z.uuid(),
|
|
91
|
+
uid: z.string(),
|
|
92
|
+
displayName: z.string(),
|
|
93
|
+
});
|
|
94
|
+
export type PersonalGroupOwner = z.infer<typeof PersonalGroupOwnerSchema>;
|
|
95
|
+
|
|
88
96
|
export const BaseGroupSchema = z.object({
|
|
89
97
|
id: z.uuid(),
|
|
90
98
|
provider: UserProviderSchema,
|
|
91
99
|
name: z.string(),
|
|
92
100
|
description: z.string().nullable(),
|
|
93
101
|
gidnumber: z.number().nullable(),
|
|
102
|
+
/** Set when the group is a user's personal Linux group; `null` for ordinary groups. */
|
|
103
|
+
personalOwner: PersonalGroupOwnerSchema.nullable(),
|
|
94
104
|
});
|
|
95
105
|
export type BaseGroup = z.infer<typeof BaseGroupSchema>;
|
|
96
106
|
|
|
@@ -831,7 +831,7 @@ export const accountLifecycle = {
|
|
|
831
831
|
OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
|
|
832
832
|
OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
|
|
833
833
|
)
|
|
834
|
-
ORDER BY deleted_at DESC
|
|
834
|
+
ORDER BY deleted_at DESC, id DESC
|
|
835
835
|
LIMIT ${config.perPage}
|
|
836
836
|
OFFSET ${offset}
|
|
837
837
|
`;
|
|
@@ -905,7 +905,7 @@ export const accountLifecycle = {
|
|
|
905
905
|
OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
|
|
906
906
|
OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
|
|
907
907
|
)
|
|
908
|
-
ORDER BY r.created_at DESC
|
|
908
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
909
909
|
LIMIT ${config.perPage}
|
|
910
910
|
OFFSET ${offset}
|
|
911
911
|
`;
|
|
@@ -909,7 +909,7 @@ export const accountsAppService = {
|
|
|
909
909
|
group: {
|
|
910
910
|
list: async (config: {
|
|
911
911
|
pagination?: PageParams;
|
|
912
|
-
filter?: { search?: string };
|
|
912
|
+
filter?: { search?: string; includePersonal?: boolean };
|
|
913
913
|
scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
|
|
914
914
|
}): Promise<Paginated<BaseGroup>> => {
|
|
915
915
|
const { page, perPage } = paginate(config.pagination);
|
|
@@ -921,6 +921,7 @@ export const accountsAppService = {
|
|
|
921
921
|
scope: config.scope?.mode,
|
|
922
922
|
ids: config.scope?.ids,
|
|
923
923
|
provider: config.scope?.provider,
|
|
924
|
+
includePersonal: config.filter?.includePersonal,
|
|
924
925
|
});
|
|
925
926
|
|
|
926
927
|
return {
|
|
@@ -1202,6 +1203,7 @@ export const accountsAppService = {
|
|
|
1202
1203
|
parentGroupId?: string;
|
|
1203
1204
|
managedByUserId?: string;
|
|
1204
1205
|
recursive?: boolean;
|
|
1206
|
+
includePersonal?: boolean;
|
|
1205
1207
|
}): Promise<Paginated<EntityListItem>> => {
|
|
1206
1208
|
const canSearchDirectory = config.actor.roles.includes("user");
|
|
1207
1209
|
const usesRelationFilter = Boolean(
|
|
@@ -1232,6 +1234,7 @@ export const accountsAppService = {
|
|
|
1232
1234
|
parentGroupId: config.parentGroupId,
|
|
1233
1235
|
managedByUserId: config.managedByUserId,
|
|
1234
1236
|
recursive: config.recursive,
|
|
1237
|
+
includePersonal: config.includePersonal,
|
|
1235
1238
|
page,
|
|
1236
1239
|
perPage,
|
|
1237
1240
|
});
|
|
@@ -1261,7 +1264,7 @@ export const accountsAppService = {
|
|
|
1261
1264
|
FROM auth.account_requests r
|
|
1262
1265
|
JOIN auth.users u ON u.id = r.user_id
|
|
1263
1266
|
WHERE ${where}
|
|
1264
|
-
ORDER BY r.created_at DESC
|
|
1267
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
1265
1268
|
LIMIT ${perPage}
|
|
1266
1269
|
OFFSET ${offset}
|
|
1267
1270
|
`;
|
|
@@ -1,11 +1,40 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
1
2
|
import type { BaseGroup, UserProvider } from "../../contracts/shared";
|
|
2
3
|
|
|
3
4
|
type DbRow = Record<string, unknown>;
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Joins the owner of a personal Linux group (the group stored as a user's
|
|
8
|
+
* `auth.user_posix.primary_group_id`) onto `auth.groups g` as
|
|
9
|
+
* `personal_owner_id`, `personal_owner_uid`, and `personal_owner_display_name`.
|
|
10
|
+
* A function, so importing this module never binds Bun's default `sql` handle.
|
|
11
|
+
*/
|
|
12
|
+
export const personalOwnerJoin = () => sql`
|
|
13
|
+
LEFT JOIN LATERAL (
|
|
14
|
+
SELECT
|
|
15
|
+
owner.id AS personal_owner_id,
|
|
16
|
+
owner.uid AS personal_owner_uid,
|
|
17
|
+
COALESCE(NULLIF(owner.display_name, ''), NULLIF(owner.mail, ''), owner.uid) AS personal_owner_display_name
|
|
18
|
+
FROM auth.user_posix owner_posix
|
|
19
|
+
JOIN auth.users owner ON owner.id = owner_posix.user_id
|
|
20
|
+
WHERE owner_posix.primary_group_id = g.id
|
|
21
|
+
ORDER BY owner.id
|
|
22
|
+
LIMIT 1
|
|
23
|
+
) personal_owner ON TRUE
|
|
24
|
+
`;
|
|
25
|
+
|
|
5
26
|
export const buildBaseGroup = (row: DbRow): BaseGroup => ({
|
|
6
27
|
id: row.id as string,
|
|
7
28
|
provider: row.provider as UserProvider,
|
|
8
29
|
name: row.name as string,
|
|
9
30
|
description: (row.description as string | null | undefined) ?? null,
|
|
10
31
|
gidnumber: (row.gid_number as number | null | undefined) ?? null,
|
|
32
|
+
personalOwner:
|
|
33
|
+
typeof row.personal_owner_id === "string"
|
|
34
|
+
? {
|
|
35
|
+
id: row.personal_owner_id,
|
|
36
|
+
uid: String(row.personal_owner_uid),
|
|
37
|
+
displayName: String(row.personal_owner_display_name),
|
|
38
|
+
}
|
|
39
|
+
: null,
|
|
11
40
|
});
|
|
@@ -2,7 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import type { EntityKind, EntityListItem, UserProfile, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import { getFreeIpaConfig } from "../freeipa-config";
|
|
4
4
|
import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
|
|
5
|
-
import { buildBaseGroup } from "./base-group";
|
|
5
|
+
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
6
6
|
import { buildBaseUser } from "./base-user";
|
|
7
7
|
import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
|
|
8
8
|
|
|
@@ -26,6 +26,11 @@ export type EntityListParams = {
|
|
|
26
26
|
parentGroupId?: string;
|
|
27
27
|
managedByUserId?: string;
|
|
28
28
|
recursive?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Personal Linux groups are left out of directory browsing and search by
|
|
31
|
+
* default. Exact `groupIds` lookups and relation filters always return them.
|
|
32
|
+
*/
|
|
33
|
+
includePersonal?: boolean;
|
|
29
34
|
page?: number;
|
|
30
35
|
perPage?: number;
|
|
31
36
|
};
|
|
@@ -415,6 +420,11 @@ export const list = async (
|
|
|
415
420
|
const groupsAdmin = (await getFreeIpaConfig()).groupsAdmin;
|
|
416
421
|
const groupsAdminLiteral = toPgTextArray(groupsAdmin);
|
|
417
422
|
const spec = buildQuerySpec(params);
|
|
423
|
+
const includePersonal =
|
|
424
|
+
params.includePersonal === true ||
|
|
425
|
+
params.groupIds !== undefined ||
|
|
426
|
+
Boolean(params.memberOfGroupId || params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
|
|
427
|
+
const personalCondition = includePersonal ? sql`TRUE` : sql`(kind <> 'group' OR personal_owner_id IS NULL)`;
|
|
418
428
|
const visibilityCondition =
|
|
419
429
|
params.visibility.type === "directory"
|
|
420
430
|
? sql`TRUE`
|
|
@@ -465,6 +475,7 @@ export const list = async (
|
|
|
465
475
|
AND ${excludeGroupCondition}
|
|
466
476
|
AND ${excludeServiceAccountCondition}
|
|
467
477
|
AND ${userMemberOfGroupCondition}
|
|
478
|
+
AND ${personalCondition}
|
|
468
479
|
AND (
|
|
469
480
|
${pattern}::text IS NULL
|
|
470
481
|
OR (
|
|
@@ -529,6 +540,9 @@ export const list = async (
|
|
|
529
540
|
NULL::text AS resource_id,
|
|
530
541
|
NULL::uuid AS created_by,
|
|
531
542
|
NULL::timestamptz AS created_at,
|
|
543
|
+
NULL::uuid AS personal_owner_id,
|
|
544
|
+
NULL::text AS personal_owner_uid,
|
|
545
|
+
NULL::text AS personal_owner_display_name,
|
|
532
546
|
LOWER(COALESCE(NULLIF(u.display_name, ''), NULLIF(u.mail, ''), u.uid)) AS sort_label
|
|
533
547
|
${spec.userFrom}
|
|
534
548
|
WHERE ${spec.userWhere}
|
|
@@ -558,8 +572,12 @@ export const list = async (
|
|
|
558
572
|
NULL::text AS resource_id,
|
|
559
573
|
NULL::uuid AS created_by,
|
|
560
574
|
NULL::timestamptz AS created_at,
|
|
575
|
+
personal_owner.personal_owner_id,
|
|
576
|
+
personal_owner.personal_owner_uid,
|
|
577
|
+
personal_owner.personal_owner_display_name,
|
|
561
578
|
LOWER(g.name) AS sort_label
|
|
562
579
|
${spec.groupFrom}
|
|
580
|
+
${personalOwnerJoin()}
|
|
563
581
|
WHERE ${spec.groupWhere}
|
|
564
582
|
),
|
|
565
583
|
service_account_rows AS (
|
|
@@ -590,6 +608,9 @@ export const list = async (
|
|
|
590
608
|
sa.resource_id,
|
|
591
609
|
sa.created_by,
|
|
592
610
|
sa.created_at,
|
|
611
|
+
NULL::uuid AS personal_owner_id,
|
|
612
|
+
NULL::text AS personal_owner_uid,
|
|
613
|
+
NULL::text AS personal_owner_display_name,
|
|
593
614
|
LOWER(sa.name) AS sort_label
|
|
594
615
|
FROM auth.service_accounts sa
|
|
595
616
|
),
|
|
@@ -5,7 +5,7 @@ import { getServiceIpaSession } from "../ipa/service-account";
|
|
|
5
5
|
import { toPgUuidArray } from "../postgres";
|
|
6
6
|
import { providers } from "../providers";
|
|
7
7
|
import type { AccountsActor } from "./authz";
|
|
8
|
-
import { buildBaseGroup } from "./base-group";
|
|
8
|
+
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
9
9
|
import { buildManagedGroupScopeCondition, buildMemberGroupScopeCondition } from "./group-sql";
|
|
10
10
|
import * as localGroups from "./local-groups";
|
|
11
11
|
import { posix } from "./posix";
|
|
@@ -16,9 +16,10 @@ type GroupListScope = "all" | "member" | "managed";
|
|
|
16
16
|
|
|
17
17
|
const getGroup = async (id: string): Promise<BaseGroup | null> => {
|
|
18
18
|
const [row] = await sql<DbRow[]>`
|
|
19
|
-
SELECT id, provider, name, description, gid_number
|
|
20
|
-
FROM auth.groups
|
|
21
|
-
|
|
19
|
+
SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
|
|
20
|
+
FROM auth.groups g
|
|
21
|
+
${personalOwnerJoin()}
|
|
22
|
+
WHERE g.id = ${id}::uuid
|
|
22
23
|
`;
|
|
23
24
|
if (!row) return null;
|
|
24
25
|
return buildBaseGroup(row);
|
|
@@ -30,6 +31,8 @@ const listCanonical = async (params: {
|
|
|
30
31
|
scope?: GroupListScope;
|
|
31
32
|
search?: string;
|
|
32
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;
|
|
33
36
|
page?: number;
|
|
34
37
|
perPage?: number;
|
|
35
38
|
}): Promise<{
|
|
@@ -45,6 +48,7 @@ const listCanonical = async (params: {
|
|
|
45
48
|
const scope = params.scope ?? (params.userId ? "member" : "all");
|
|
46
49
|
const scopeUserId = params.userId ?? "00000000-0000-0000-0000-000000000000";
|
|
47
50
|
const idsCondition = ids.length === 0 ? sql`TRUE` : sql`g.id = ANY(${toPgUuidArray(ids)}::uuid[])`;
|
|
51
|
+
const includePersonal = params.includePersonal === true || params.ids !== undefined;
|
|
48
52
|
|
|
49
53
|
if (params.ids && params.ids.length === 0) {
|
|
50
54
|
return {
|
|
@@ -60,10 +64,12 @@ const listCanonical = async (params: {
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
const rows = await sql<DbRow[]>`
|
|
63
|
-
SELECT g.id, g.provider, g.name, g.description, g.gid_number, COUNT(*) OVER() AS total
|
|
67
|
+
SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*, COUNT(*) OVER() AS total
|
|
64
68
|
FROM auth.groups g
|
|
69
|
+
${personalOwnerJoin()}
|
|
65
70
|
WHERE (${params.provider ?? null}::text IS NULL OR g.provider = ${params.provider ?? null})
|
|
66
71
|
AND ${idsCondition}
|
|
72
|
+
AND (${includePersonal} = true OR personal_owner.personal_owner_id IS NULL)
|
|
67
73
|
AND (
|
|
68
74
|
${scope === "all"} = true
|
|
69
75
|
OR ${params.userId ?? null}::uuid IS NULL
|
|
@@ -99,6 +105,8 @@ export const list = async (params: {
|
|
|
99
105
|
scope?: GroupListScope;
|
|
100
106
|
search?: string;
|
|
101
107
|
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;
|
|
102
110
|
page?: number;
|
|
103
111
|
perPage?: number;
|
|
104
112
|
}) => {
|
|
@@ -16,7 +16,8 @@ export type AccountIdentityUser = {
|
|
|
16
16
|
profile: UserProfile;
|
|
17
17
|
posix: { uidNumber: number; primaryGidNumber: number } | null;
|
|
18
18
|
};
|
|
19
|
-
|
|
19
|
+
/** `personal` marks a user's personal Linux group (their stored primary group). */
|
|
20
|
+
export type AccountIdentityGroup = { id: string; provider: UserProvider; name: string; gidNumber: number | null; personal: boolean };
|
|
20
21
|
export type AccountIdentityPage<T> = { items: T[]; nextCursor: string | null };
|
|
21
22
|
export type AccountIdentityAvailability = { localLinuxEnabled: boolean; freeipaEnabled: boolean };
|
|
22
23
|
type InventoryFilter = { provider: UserProvider; after?: string; id?: string; name?: string };
|
|
@@ -119,8 +120,10 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
|
|
|
119
120
|
return current;
|
|
120
121
|
};
|
|
121
122
|
const groupRows = async (filter: SQLQuery) => {
|
|
122
|
-
const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null }[]>`
|
|
123
|
-
SELECT g.id, g.provider, g.name, g.gid_number
|
|
123
|
+
const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null; personal: boolean }[]>`
|
|
124
|
+
SELECT g.id, g.provider, g.name, g.gid_number,
|
|
125
|
+
EXISTS(SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id) AS personal
|
|
126
|
+
FROM auth.groups g
|
|
124
127
|
WHERE ${filter} ORDER BY g.id LIMIT ${PAGE_SIZE + 1}
|
|
125
128
|
`;
|
|
126
129
|
return rows.map(
|
|
@@ -129,6 +132,7 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
|
|
|
129
132
|
provider: row.provider,
|
|
130
133
|
name: row.name,
|
|
131
134
|
gidNumber: positiveId(row.gid_number) ? row.gid_number : null,
|
|
135
|
+
personal: row.personal,
|
|
132
136
|
}),
|
|
133
137
|
);
|
|
134
138
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import { escapeLikePattern, isUniqueViolation } from "../postgres";
|
|
4
|
+
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
4
5
|
|
|
5
6
|
type DbRow = Record<string, unknown>;
|
|
6
7
|
|
|
@@ -18,6 +19,7 @@ const toBaseGroup = (row: LocalGroupRow): BaseGroup => ({
|
|
|
18
19
|
name: row.name,
|
|
19
20
|
description: row.description,
|
|
20
21
|
gidnumber: row.gidNumber,
|
|
22
|
+
personalOwner: null,
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
|
|
@@ -73,8 +75,13 @@ const wouldCreateLocalGroupCycle = async (params: { parentGroupId: string; child
|
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
export const get = async (params: { id: string }): Promise<BaseGroup | null> => {
|
|
76
|
-
const row = await
|
|
77
|
-
|
|
78
|
+
const [row] = await sql<DbRow[]>`
|
|
79
|
+
SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
|
|
80
|
+
FROM auth.groups g
|
|
81
|
+
${personalOwnerJoin()}
|
|
82
|
+
WHERE g.id = ${params.id}::uuid AND g.provider = 'local'
|
|
83
|
+
`;
|
|
84
|
+
return row ? buildBaseGroup(row) : null;
|
|
78
85
|
};
|
|
79
86
|
|
|
80
87
|
export const create = async (params: { name: string; description?: string }, db: typeof sql = sql): Promise<MutationResult<BaseGroup>> => {
|
|
@@ -119,8 +126,9 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
|
|
|
119
126
|
`;
|
|
120
127
|
const total = Number(countRow?.count ?? 0);
|
|
121
128
|
const rows = await sql<DbRow[]>`
|
|
122
|
-
SELECT id, provider, name, description, gid_number
|
|
129
|
+
SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
|
|
123
130
|
FROM auth.groups g
|
|
131
|
+
${personalOwnerJoin()}
|
|
124
132
|
WHERE g.provider = 'local'
|
|
125
133
|
AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
|
|
126
134
|
ORDER BY g.name
|
|
@@ -128,15 +136,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
|
|
|
128
136
|
`;
|
|
129
137
|
|
|
130
138
|
return {
|
|
131
|
-
groups: rows.map(
|
|
132
|
-
toBaseGroup({
|
|
133
|
-
id: row.id as string,
|
|
134
|
-
provider: row.provider as "local",
|
|
135
|
-
name: row.name as string,
|
|
136
|
-
description: row.description as string | null,
|
|
137
|
-
gidNumber: row.gid_number as number | null,
|
|
138
|
-
}),
|
|
139
|
-
),
|
|
139
|
+
groups: rows.map(buildBaseGroup),
|
|
140
140
|
total,
|
|
141
141
|
pagination: {
|
|
142
142
|
page,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { type SQL, sql } from "bun";
|
|
3
|
+
import { lazySync } from "../_internal/process-sync";
|
|
3
4
|
import { env } from "../config/env";
|
|
4
5
|
import { type AccountCategory, accountCategory } from "../contracts/account-categories";
|
|
5
6
|
import {
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
import { publicCloudOrigin } from "../shared/app-url";
|
|
18
19
|
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
19
20
|
import { audit } from "./audit";
|
|
21
|
+
import { logger } from "./logging";
|
|
20
22
|
import { CORE_SETTINGS } from "./settings/core-settings";
|
|
21
23
|
import { decryptValue } from "./settings/crypto";
|
|
22
24
|
|
|
@@ -136,6 +138,33 @@ type LoginRow = {
|
|
|
136
138
|
};
|
|
137
139
|
export type AppApprovalActor = { userId: string; sid: string; admin: boolean };
|
|
138
140
|
export type AppDeviceEnrollmentNotice = { deviceId: string; userId: string; name: string; assisted: boolean };
|
|
141
|
+
/** Best-effort wake-ups for browsers waiting on a login decision. Postgres stays authoritative. */
|
|
142
|
+
export type AppLoginDecisionHints = {
|
|
143
|
+
/** Call after the decision commits. Must not throw or delay the caller. */
|
|
144
|
+
publish(requestId: string): void;
|
|
145
|
+
/** Settles on the first hint for this request, or when `signal` aborts. */
|
|
146
|
+
wait(requestId: string, signal: AbortSignal): Promise<void>;
|
|
147
|
+
};
|
|
148
|
+
const log = logger("app-approval");
|
|
149
|
+
const decisionTopic = lazySync((sync) =>
|
|
150
|
+
sync.topic<null>({
|
|
151
|
+
id: "cloud:app-login-decisions",
|
|
152
|
+
owner: "cloud",
|
|
153
|
+
retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
|
|
154
|
+
maxPayloadBytes: 2000,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
/** Core NATS broadcast reaches the waiting browser on any replica. */
|
|
158
|
+
export const syncLoginDecisionHints: AppLoginDecisionHints = {
|
|
159
|
+
publish: (requestId) => {
|
|
160
|
+
void Promise.resolve()
|
|
161
|
+
.then(() => decisionTopic().publish({ tenantId: requestId, data: null }))
|
|
162
|
+
.catch((error) => log.warn("App login decision hint failed", { error: String(error) }));
|
|
163
|
+
},
|
|
164
|
+
wait: async (requestId, signal) => {
|
|
165
|
+
for await (const _event of decisionTopic().live({ tenantId: requestId, signal })) return;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
139
168
|
const view = (row: DeviceRow): AppDeviceView => ({
|
|
140
169
|
id: row.id,
|
|
141
170
|
name: row.name,
|
|
@@ -163,6 +192,7 @@ const verify = async (key: AppDevicePublicKey, message: string, signature: strin
|
|
|
163
192
|
export const createAppApprovalService = (
|
|
164
193
|
db: SQL = sql,
|
|
165
194
|
configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
|
|
195
|
+
hints: AppLoginDecisionHints = syncLoginDecisionHints,
|
|
166
196
|
) => {
|
|
167
197
|
const config = async (requireEnabled = true) => {
|
|
168
198
|
const value = await configuration(requireEnabled);
|
|
@@ -409,7 +439,7 @@ export const createAppApprovalService = (
|
|
|
409
439
|
)
|
|
410
440
|
return reject("FORBIDDEN", 403);
|
|
411
441
|
await cleanup();
|
|
412
|
-
|
|
442
|
+
const outcome = await db.begin(async (tx) => {
|
|
413
443
|
const device = await activeDevice(tx, proof.deviceId, cfg.issuer);
|
|
414
444
|
if (!(await verify(AppDevicePublicKeySchema.parse(device.public_key), appDeviceProofMessage(proof), signature)))
|
|
415
445
|
return reject("FORBIDDEN", 403);
|
|
@@ -460,12 +490,39 @@ export const createAppApprovalService = (
|
|
|
460
490
|
await record(tx, `login.${command.decision}`, device.user_id, device.id, { requestId: row.id });
|
|
461
491
|
return { state };
|
|
462
492
|
});
|
|
493
|
+
// Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
|
|
494
|
+
if (proof.command.operation === "decide") hints.publish(proof.command.requestId);
|
|
495
|
+
return outcome;
|
|
463
496
|
},
|
|
464
|
-
|
|
497
|
+
/** With `hold`, a pending request is answered on its decision, its expiry, or after `hold.ms`, whichever comes first. */
|
|
498
|
+
browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
|
|
465
499
|
const cfg = await config();
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
500
|
+
const read = async () => {
|
|
501
|
+
const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
502
|
+
if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
|
|
503
|
+
const expiresIn = new Date(row.expires_at).getTime() - Date.now();
|
|
504
|
+
return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
|
|
505
|
+
};
|
|
506
|
+
const result = (state: string) => ({ state, pollAfterSeconds: limits.pollSeconds });
|
|
507
|
+
if (!hold) return result((await read()).state);
|
|
508
|
+
// Listen before the first read so a decision committed in between still wakes this request.
|
|
509
|
+
const waiting = new AbortController();
|
|
510
|
+
const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
|
|
511
|
+
const stop = () => waiting.abort();
|
|
512
|
+
hold.signal?.addEventListener("abort", stop, { once: true });
|
|
513
|
+
if (hold.signal?.aborted) stop();
|
|
514
|
+
const hint = hints.wait(id, waiting.signal).catch(() => {});
|
|
515
|
+
try {
|
|
516
|
+
const first = await read();
|
|
517
|
+
if (first.state !== "pending") return result(first.state);
|
|
518
|
+
const timer = setTimeout(stop, Math.min(hold.ms, first.expiresIn));
|
|
519
|
+
await Promise.race([hint, ended]);
|
|
520
|
+
clearTimeout(timer);
|
|
521
|
+
return result((await read()).state);
|
|
522
|
+
} finally {
|
|
523
|
+
hold.signal?.removeEventListener("abort", stop);
|
|
524
|
+
stop();
|
|
525
|
+
}
|
|
469
526
|
},
|
|
470
527
|
consumeLogin: async (id: string, token: string) => {
|
|
471
528
|
const cfg = await config();
|
|
@@ -22,6 +22,7 @@ const toBaseGroup = (row: IpaGroupRow): BaseGroup => ({
|
|
|
22
22
|
name: row.name,
|
|
23
23
|
description: row.description,
|
|
24
24
|
gidnumber: row.gidNumber,
|
|
25
|
+
personalOwner: null,
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
const getIpaGroupById = async (id: string): Promise<IpaGroupRow | null> => {
|
|
@@ -142,6 +143,7 @@ export const list = async (params: {
|
|
|
142
143
|
name: row.name as string,
|
|
143
144
|
description: row.description as string | null,
|
|
144
145
|
gidnumber: row.gid_number as number | null,
|
|
146
|
+
personalOwner: null,
|
|
145
147
|
})),
|
|
146
148
|
total,
|
|
147
149
|
pagination: { page, perPage, totalPages, hasNext: page < totalPages },
|
|
@@ -377,6 +379,7 @@ export const add = async (params: {
|
|
|
377
379
|
name: row.name as string,
|
|
378
380
|
description: row.description as string | null,
|
|
379
381
|
gidnumber: row.gid_number as number | null,
|
|
382
|
+
personalOwner: null,
|
|
380
383
|
},
|
|
381
384
|
};
|
|
382
385
|
};
|
|
@@ -205,7 +205,7 @@ const list = async (
|
|
|
205
205
|
AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
206
206
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
207
207
|
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
208
|
-
ORDER BY created_at DESC
|
|
208
|
+
ORDER BY created_at DESC, id DESC
|
|
209
209
|
LIMIT ${perPage} OFFSET ${offset}
|
|
210
210
|
`;
|
|
211
211
|
} else if (hasSourceList) {
|
|
@@ -225,7 +225,7 @@ const list = async (
|
|
|
225
225
|
AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
226
226
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
227
227
|
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
228
|
-
ORDER BY created_at DESC
|
|
228
|
+
ORDER BY created_at DESC, id DESC
|
|
229
229
|
LIMIT ${perPage} OFFSET ${offset}
|
|
230
230
|
`;
|
|
231
231
|
} else {
|
|
@@ -243,7 +243,7 @@ const list = async (
|
|
|
243
243
|
WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
|
|
244
244
|
AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
|
|
245
245
|
AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
|
|
246
|
-
ORDER BY created_at DESC
|
|
246
|
+
ORDER BY created_at DESC, id DESC
|
|
247
247
|
LIMIT ${perPage} OFFSET ${offset}
|
|
248
248
|
`;
|
|
249
249
|
}
|
|
@@ -789,7 +789,7 @@ const list = async (
|
|
|
789
789
|
) AS event_count
|
|
790
790
|
FROM logging.trace_spans s
|
|
791
791
|
WHERE ${where}
|
|
792
|
-
ORDER BY COALESCE(s.ended_at, s.updated_at, s.started_at) DESC
|
|
792
|
+
ORDER BY COALESCE(s.ended_at, s.updated_at, s.started_at) DESC, s.trace_id DESC, s.span_id DESC
|
|
793
793
|
LIMIT ${perPage} OFFSET ${offset}
|
|
794
794
|
`,
|
|
795
795
|
]);
|
|
@@ -460,7 +460,7 @@ export const list = async (params?: {
|
|
|
460
460
|
SELECT *, COUNT(*) OVER() AS total
|
|
461
461
|
FROM notifications.batches
|
|
462
462
|
WHERE (${params?.status ?? null}::text IS NULL OR status = ${params?.status ?? null})
|
|
463
|
-
ORDER BY created_at DESC
|
|
463
|
+
ORDER BY created_at DESC, id DESC
|
|
464
464
|
LIMIT ${perPage} OFFSET ${offset}
|
|
465
465
|
`;
|
|
466
466
|
return {
|
|
@@ -235,7 +235,7 @@ export const list = async (
|
|
|
235
235
|
WHERE
|
|
236
236
|
(${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
237
237
|
AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
|
|
238
|
-
ORDER BY m.created_at DESC
|
|
238
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
239
239
|
LIMIT ${perPage} OFFSET ${offset}
|
|
240
240
|
`;
|
|
241
241
|
} else {
|
|
@@ -251,7 +251,7 @@ export const list = async (
|
|
|
251
251
|
FROM notifications.messages m
|
|
252
252
|
LEFT JOIN auth.users u ON m.sent_by = u.id
|
|
253
253
|
WHERE ${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
|
|
254
|
-
ORDER BY m.created_at DESC
|
|
254
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
255
255
|
LIMIT ${perPage} OFFSET ${offset}
|
|
256
256
|
`;
|
|
257
257
|
}
|
|
@@ -276,7 +276,7 @@ export const list = async (
|
|
|
276
276
|
m.sent_by = ${sentBy}
|
|
277
277
|
AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
278
278
|
AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
|
|
279
|
-
ORDER BY m.created_at DESC
|
|
279
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
280
280
|
LIMIT ${perPage} OFFSET ${offset}
|
|
281
281
|
`;
|
|
282
282
|
} else {
|
|
@@ -296,7 +296,7 @@ export const list = async (
|
|
|
296
296
|
WHERE
|
|
297
297
|
m.sent_by = ${sentBy}
|
|
298
298
|
AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
|
|
299
|
-
ORDER BY m.created_at DESC
|
|
299
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
300
300
|
LIMIT ${perPage} OFFSET ${offset}
|
|
301
301
|
`;
|
|
302
302
|
}
|
|
@@ -553,7 +553,7 @@ export const listOverview = async (config?: {
|
|
|
553
553
|
OR sa.resource_type ILIKE '%' || ${search} || '%'
|
|
554
554
|
OR sa.resource_id ILIKE '%' || ${search} || '%'
|
|
555
555
|
)
|
|
556
|
-
ORDER BY c.created_at DESC
|
|
556
|
+
ORDER BY c.created_at DESC, c.id DESC
|
|
557
557
|
LIMIT ${perPage}
|
|
558
558
|
OFFSET ${offset}
|
|
559
559
|
`;
|
package/src/services/webauthn.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { coreSettings } from "./settings/api";
|
|
|
21
21
|
const CHALLENGE_TTL_SECONDS = 300;
|
|
22
22
|
const REGISTRATION_CHALLENGE_PREFIX = "webauthn:registration:";
|
|
23
23
|
const AUTHENTICATION_CHALLENGE_PREFIX = "webauthn:authentication:";
|
|
24
|
-
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/i;
|
|
24
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
25
25
|
const log = logger("auth:webauthn");
|
|
26
26
|
|
|
27
27
|
type DbPasskeyRow = {
|
|
@@ -13,3 +13,17 @@ export const getAccountTypeLabel = (user: Pick<AccountLike, "profile">): string
|
|
|
13
13
|
export const getManagementLabel = (user: Pick<AccountLike, "provider">): string => (user.provider === "ipa" ? "FreeIPA" : "Local");
|
|
14
14
|
|
|
15
15
|
export const getSupplementalRoleLabel = (role: SupplementalRole): string => (role === "group-manager" ? "Group Manager" : "Admin");
|
|
16
|
+
|
|
17
|
+
const isGerman = (locale: string | null | undefined): boolean => locale?.toLowerCase().split(/[-_]/)[0] === "de";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Display form of a group name for people. In German, the first letter of each
|
|
21
|
+
* word separated by whitespace or a hyphen is capitalised (`buchhaltung` →
|
|
22
|
+
* `Buchhaltung`); other locales get the name unchanged. Use it only for rendered
|
|
23
|
+
* text: stored names, identifiers, URLs, search input, and API or JSON output
|
|
24
|
+
* keep the original name.
|
|
25
|
+
*/
|
|
26
|
+
export const groupDisplayName = (name: string, locale: string | null | undefined): string =>
|
|
27
|
+
isGerman(locale)
|
|
28
|
+
? name.replace(/(^|[\s-])(\p{L})/gu, (_, separator: string, letter: string) => separator + letter.toLocaleUpperCase("de"))
|
|
29
|
+
: name;
|