@k2b/cloud 0.44.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 +6 -2
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +11 -2
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/ai/dictation-runtime.ts +6 -2
- package/src/ai/executor.ts +9 -3
- package/src/ai/index.ts +1 -0
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +56 -46
- package/src/ai/projects.ts +100 -79
- package/src/ai/recent-resources.ts +3 -3
- package/src/ai/routes.ts +30 -30
- 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/api/accounts-entities.ts +6 -3
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/auth.ts +9 -2
- 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/contracts/shared.ts +2 -1
- package/src/server/api/respond.ts +10 -2
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +23 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +30 -13
- package/src/services/accounts/entities.ts +29 -16
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +16 -13
- package/src/services/accounts/identities.ts +8 -1
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +69 -4
- package/src/services/accounts/users.ts +38 -8
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +1 -0
- package/src/services/session/index.ts +12 -3
- package/src/shared/email-html.ts +1 -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 +428 -77
- 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/types.ts +24 -5
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +19 -3
- package/src/ssr/Layout.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-navigation.ts +1 -11
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/shell-data.tsx +1 -1
- package/src/workflows/language/expressions.ts +3 -2
- package/src/workflows/runtime/executor.ts +2 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { encoding } from "@k2b/stdlib";
|
|
2
|
+
import type { TableActionIntent, TableFocusWitness } from "../shared-text/table-actions";
|
|
3
|
+
|
|
4
|
+
const { fromBase64, toBase64 } = encoding;
|
|
5
|
+
|
|
6
|
+
import * as Y from "yjs";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
/** Approved editor limit, measured in UTF-16 code units. Rejection must preserve the existing text. */
|
|
10
|
+
export const TEXT_TABLE_ACTION_MAX_LENGTH = 2_097_152;
|
|
11
|
+
|
|
12
|
+
// An item relative position is one tag, two safe-integer varuints (at most
|
|
13
|
+
// ceil(53 / 7) bytes each), and the zero-association byte. No root-name/sentinel
|
|
14
|
+
// positions are accepted. The base64 bound follows this Yjs wire format.
|
|
15
|
+
const ITEM_ANCHOR_MAX_BYTES = 1 + 2 * Math.ceil(53 / 7) + 1;
|
|
16
|
+
const ITEM_ANCHOR_MAX_BASE64_LENGTH = 4 * Math.ceil(ITEM_ANCHOR_MAX_BYTES / 3);
|
|
17
|
+
const AnchorSchema = z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(ITEM_ANCHOR_MAX_BASE64_LENGTH)
|
|
21
|
+
.refine((encoded) => {
|
|
22
|
+
if (encoded.length > ITEM_ANCHOR_MAX_BASE64_LENGTH) return false;
|
|
23
|
+
try {
|
|
24
|
+
const bytes = fromBase64(encoded);
|
|
25
|
+
const position = Y.decodeRelativePosition(bytes);
|
|
26
|
+
return (
|
|
27
|
+
bytes.length <= ITEM_ANCHOR_MAX_BYTES &&
|
|
28
|
+
position.item !== null &&
|
|
29
|
+
position.assoc === 0 &&
|
|
30
|
+
Number.isSafeInteger(position.item.client) &&
|
|
31
|
+
position.item.client >= 0 &&
|
|
32
|
+
Number.isSafeInteger(position.item.clock) &&
|
|
33
|
+
position.item.clock >= 0 &&
|
|
34
|
+
toBase64(Y.encodeRelativePosition(position)) === encoded
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}, "Expected a canonical Yjs character anchor");
|
|
40
|
+
|
|
41
|
+
const TableSchema = z.object({ headerBreak: AnchorSchema, separatorHyphen: AnchorSchema }).strict();
|
|
42
|
+
const RowSchema = z.discriminatedUnion("kind", [
|
|
43
|
+
z.object({ kind: z.literal("header") }).strict(),
|
|
44
|
+
z.object({ kind: z.literal("body"), beforeBreak: AnchorSchema, inside: AnchorSchema, insideKind: z.enum(["pipe", "break"]) }).strict(),
|
|
45
|
+
]);
|
|
46
|
+
const ColumnSchema = z.object({ hyphen: AnchorSchema, left: AnchorSchema, right: AnchorSchema }).strict();
|
|
47
|
+
|
|
48
|
+
export const TextTableFocusSchema = z.object({ table: TableSchema, row: RowSchema, column: ColumnSchema }).strict();
|
|
49
|
+
export type EncodedTableFocus = z.infer<typeof TextTableFocusSchema>;
|
|
50
|
+
export const encodeTableFocus = (focus: TableFocusWitness): EncodedTableFocus => ({
|
|
51
|
+
table: { headerBreak: toBase64(focus.table.headerBreak), separatorHyphen: toBase64(focus.table.separatorHyphen) },
|
|
52
|
+
row:
|
|
53
|
+
focus.row.kind === "header"
|
|
54
|
+
? focus.row
|
|
55
|
+
: { ...focus.row, beforeBreak: toBase64(focus.row.beforeBreak), inside: toBase64(focus.row.inside) },
|
|
56
|
+
column: { hyphen: toBase64(focus.column.hyphen), left: toBase64(focus.column.left), right: toBase64(focus.column.right) },
|
|
57
|
+
});
|
|
58
|
+
export const decodeTableFocus = (focus: EncodedTableFocus): TableFocusWitness => ({
|
|
59
|
+
table: { headerBreak: fromBase64(focus.table.headerBreak), separatorHyphen: fromBase64(focus.table.separatorHyphen) },
|
|
60
|
+
row:
|
|
61
|
+
focus.row.kind === "header"
|
|
62
|
+
? focus.row
|
|
63
|
+
: { ...focus.row, beforeBreak: fromBase64(focus.row.beforeBreak), inside: fromBase64(focus.row.inside) },
|
|
64
|
+
column: { hyphen: fromBase64(focus.column.hyphen), left: fromBase64(focus.column.left), right: fromBase64(focus.column.right) },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const EncodedTableActionIntentSchema = z.discriminatedUnion("action", [
|
|
68
|
+
z.object({ action: z.enum(["moveRowUp", "moveRowDown"]), table: TableSchema, row: RowSchema, neighbor: RowSchema }).strict(),
|
|
69
|
+
z.object({ action: z.enum(["moveColLeft", "moveColRight"]), table: TableSchema, column: ColumnSchema, neighbor: ColumnSchema }).strict(),
|
|
70
|
+
z.object({ action: z.enum(["undo", "redo"]), receiptCursor: z.string().min(1).max(256) }).strict(),
|
|
71
|
+
z
|
|
72
|
+
.object({
|
|
73
|
+
action: z.literal("replaceSource"),
|
|
74
|
+
table: TableSchema,
|
|
75
|
+
expected: z.string().max(TEXT_TABLE_ACTION_MAX_LENGTH),
|
|
76
|
+
source: z.string().max(TEXT_TABLE_ACTION_MAX_LENGTH),
|
|
77
|
+
})
|
|
78
|
+
.strict(),
|
|
79
|
+
z.object({ action: z.enum(["completeColumns", "toggleHeader", "deleteTable"]), table: TableSchema }).strict(),
|
|
80
|
+
z.object({ action: z.enum(["rowAbove", "rowBelow", "deleteRow"]), table: TableSchema, row: RowSchema }).strict(),
|
|
81
|
+
z
|
|
82
|
+
.object({
|
|
83
|
+
action: z.enum(["colLeft", "colRight", "deleteCol", "alignLeft", "alignCenter", "alignRight"]),
|
|
84
|
+
table: TableSchema,
|
|
85
|
+
column: ColumnSchema,
|
|
86
|
+
})
|
|
87
|
+
.strict(),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
export const TextTableActionRequestSchema = z
|
|
91
|
+
.object({
|
|
92
|
+
// The application stores this token in a nonnegative Postgres bigint.
|
|
93
|
+
restoreRevision: z
|
|
94
|
+
.string()
|
|
95
|
+
.min(1)
|
|
96
|
+
.max(19)
|
|
97
|
+
.regex(/^(0|[1-9]\d*)$/)
|
|
98
|
+
.refine((value) => value.length <= 19 && /^(0|[1-9]\d*)$/.test(value) && BigInt(value) <= 9_223_372_036_854_775_807n),
|
|
99
|
+
actionId: z.uuid(),
|
|
100
|
+
intent: EncodedTableActionIntentSchema,
|
|
101
|
+
focus: TextTableFocusSchema.optional(),
|
|
102
|
+
caret: z
|
|
103
|
+
.object({
|
|
104
|
+
row: z.number().int().nonnegative().max(TEXT_TABLE_ACTION_MAX_LENGTH),
|
|
105
|
+
column: z.number().int().nonnegative().max(TEXT_TABLE_ACTION_MAX_LENGTH),
|
|
106
|
+
})
|
|
107
|
+
.strict()
|
|
108
|
+
.optional(),
|
|
109
|
+
})
|
|
110
|
+
.strict();
|
|
111
|
+
|
|
112
|
+
export type EncodedTableActionIntent = z.infer<typeof EncodedTableActionIntentSchema>;
|
|
113
|
+
export type TextTableActionRequest = z.infer<typeof TextTableActionRequestSchema>;
|
|
114
|
+
|
|
115
|
+
/** Decode only schema-validated transport; planning still validates identity and current context. */
|
|
116
|
+
export type TextTableIntent = TableActionIntent | { action: "undo" | "redo"; receiptCursor: string };
|
|
117
|
+
export const decodeTableActionIntent = (intent: EncodedTableActionIntent): TextTableIntent => {
|
|
118
|
+
if ("receiptCursor" in intent) return intent;
|
|
119
|
+
const table = { headerBreak: fromBase64(intent.table.headerBreak), separatorHyphen: fromBase64(intent.table.separatorHyphen) };
|
|
120
|
+
if ("source" in intent) return { action: intent.action, table, expected: intent.expected, source: intent.source };
|
|
121
|
+
if (!("row" in intent) && !("column" in intent)) return { action: intent.action, table };
|
|
122
|
+
const decodeRow = (row: z.infer<typeof RowSchema>) =>
|
|
123
|
+
row.kind === "header"
|
|
124
|
+
? row
|
|
125
|
+
: { kind: "body" as const, beforeBreak: fromBase64(row.beforeBreak), inside: fromBase64(row.inside), insideKind: row.insideKind };
|
|
126
|
+
const decodeColumn = (column: z.infer<typeof ColumnSchema>) => ({
|
|
127
|
+
hyphen: fromBase64(column.hyphen),
|
|
128
|
+
left: fromBase64(column.left),
|
|
129
|
+
right: fromBase64(column.right),
|
|
130
|
+
});
|
|
131
|
+
if ("row" in intent) {
|
|
132
|
+
const row = decodeRow(intent.row);
|
|
133
|
+
return "neighbor" in intent
|
|
134
|
+
? { action: intent.action, table, row, neighbor: decodeRow(intent.neighbor) }
|
|
135
|
+
: { action: intent.action, table, row };
|
|
136
|
+
}
|
|
137
|
+
const column = decodeColumn(intent.column);
|
|
138
|
+
return "neighbor" in intent
|
|
139
|
+
? { action: intent.action, table, column, neighbor: decodeColumn(intent.neighbor) }
|
|
140
|
+
: { action: intent.action, table, column };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const TextTableActionReceiptSchema = z.object({ actionId: z.uuid(), update: z.string() });
|
|
144
|
+
export const TextTableActionResponseSchema = z.object({
|
|
145
|
+
changed: z.boolean(),
|
|
146
|
+
focus: TextTableFocusSchema.nullable().optional(),
|
|
147
|
+
cursor: z.string().nullable(),
|
|
148
|
+
receipt: TextTableActionReceiptSchema.nullable(),
|
|
149
|
+
});
|
|
150
|
+
export type TextTableActionResponse = z.infer<typeof TextTableActionResponseSchema>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Browser-safe identity helpers; no server log, storage, or process state. */
|
|
2
|
+
export { captureTextAnchor, resolveTextAnchor } from "../shared-text/anchors";
|
|
3
|
+
export {
|
|
4
|
+
captureTableAction,
|
|
5
|
+
captureTableFocus,
|
|
6
|
+
captureTableSource,
|
|
7
|
+
prepareTableActionFocus,
|
|
8
|
+
resolveTableFocus,
|
|
9
|
+
type TableActionIntent,
|
|
10
|
+
TableActionLimitError,
|
|
11
|
+
TableActionRejectedError,
|
|
12
|
+
type TableFocusWitness,
|
|
13
|
+
} from "../shared-text/table-actions";
|
|
14
|
+
export { createSharedTextActionHistory, type SharedTextInverseRequest } from "./shared-text-history";
|
|
15
|
+
export * from "./shared-text-offline";
|
|
16
|
+
export { openAuthorizedSharedTextOffline } from "./shared-text-offline-authorized";
|
|
17
|
+
export * from "./shared-text-provider";
|
|
18
|
+
|
|
19
|
+
export * from "./shared-text-table-contract";
|
package/src/contracts/shared.ts
CHANGED
|
@@ -146,9 +146,10 @@ export const EntityListItemSchema = z.discriminatedUnion("kind", [
|
|
|
146
146
|
export type EntityListItem = z.infer<typeof EntityListItemSchema>;
|
|
147
147
|
|
|
148
148
|
export const GroupMemberSchema = z.object({
|
|
149
|
-
type: z.enum(["user", "group"]),
|
|
149
|
+
type: z.enum(["user", "group", "service_account"]),
|
|
150
150
|
id: z.string(),
|
|
151
151
|
displayName: z.string().nullable(),
|
|
152
|
+
serviceAccountKind: ServiceAccountKindSchema.optional(),
|
|
152
153
|
});
|
|
153
154
|
export type GroupMember = z.infer<typeof GroupMemberSchema>;
|
|
154
155
|
|
|
@@ -43,6 +43,12 @@ const toErrorResponse = (result: AnyResult): [ApiErrorBody, number] => {
|
|
|
43
43
|
return [{ message: "Internal server error", code: "INTERNAL" }, 500];
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
/** Add explicitly selected transport recovery fields without changing the error envelope. */
|
|
47
|
+
export async function respond<D extends Record<string, unknown>, S extends LegacyErrorStatus>(
|
|
48
|
+
c: Context,
|
|
49
|
+
resultOrFn: ResultOrFn<LegacyErrorResult<S>>,
|
|
50
|
+
options: { errorDetails: D },
|
|
51
|
+
): Promise<JsonTypedResponse<Omit<D, keyof ApiErrorBody> & ApiErrorBody, S>>;
|
|
46
52
|
export async function respond<T>(
|
|
47
53
|
c: Context,
|
|
48
54
|
resultOrFn: ResultOrFn<AnyResult<T>>,
|
|
@@ -76,15 +82,17 @@ export async function respond<T>(
|
|
|
76
82
|
export async function respond<T>(
|
|
77
83
|
c: Context,
|
|
78
84
|
resultOrFn: ResultOrFn<AnyResult<T>>,
|
|
79
|
-
|
|
85
|
+
statusOrOptions: SuccessStatus | 202 | { errorDetails: Record<string, unknown> } = 200,
|
|
80
86
|
): Promise<JsonTypedResponse<T, SuccessStatus | 202> | ApiErrorResponse> {
|
|
81
87
|
const result = typeof resultOrFn === "function" ? await resultOrFn() : await resultOrFn;
|
|
82
88
|
|
|
83
89
|
if (!result.ok) {
|
|
84
90
|
const [body, status] = toErrorResponse(result);
|
|
85
|
-
|
|
91
|
+
const details = typeof statusOrOptions === "object" ? statusOrOptions.errorDetails : {};
|
|
92
|
+
return c.json({ ...details, ...body, code: body.code }, status as ApiErrorStatus) as ApiErrorResponse;
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
const successStatus = typeof statusOrOptions === "number" ? statusOrOptions : 200;
|
|
88
96
|
return c.json(result.data, successStatus) as JsonTypedResponse<T, SuccessStatus | 202>;
|
|
89
97
|
}
|
|
90
98
|
|
package/src/server/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
|
2
2
|
import { type SQLQuery, sql } from "bun";
|
|
3
3
|
import { type ManagerCandidate, removesLastManager } from "../../access/managers";
|
|
4
4
|
import { accessMessages } from "../../access/messages";
|
|
5
|
-
import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
|
|
5
|
+
import { recursiveGroupIdsSubquery, recursiveServiceAccountGroupIdsSubquery } from "../../services/accounts/group-sql";
|
|
6
6
|
import { toPgUuidArray } from "../../services/postgres";
|
|
7
7
|
import type { ServiceAccountKind } from "../../services/service-accounts";
|
|
8
8
|
import type { RequestAuthority } from "../middleware/auth";
|
|
@@ -266,19 +266,26 @@ export const ensureManagerRemains = (params: {
|
|
|
266
266
|
return fail({ code: "LAST_MANAGER", message: t.lastManager({ level: params.level ?? t.manage }), status: 409 });
|
|
267
267
|
};
|
|
268
268
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
type EffectiveGroupParams = { userId: string | null; serviceAccountId?: never } | { serviceAccountId: string | null; userId?: never };
|
|
270
|
+
|
|
271
|
+
/** Resolve authoritative memberships; machine recipients inherit local groups only. */
|
|
272
|
+
export const getEffectiveGroups = async (params: EffectiveGroupParams, db: AccessDb = sql): Promise<EffectiveGroup[]> => {
|
|
273
|
+
const groupIds = params.userId
|
|
274
|
+
? recursiveGroupIdsSubquery(params.userId)
|
|
275
|
+
: params.serviceAccountId
|
|
276
|
+
? recursiveServiceAccountGroupIdsSubquery(params.serviceAccountId)
|
|
277
|
+
: null;
|
|
278
|
+
if (!groupIds) return [];
|
|
272
279
|
const rows = await db<{ id: string; name: string }[]>`
|
|
273
280
|
SELECT g.id, g.name
|
|
274
281
|
FROM auth.groups g
|
|
275
|
-
WHERE g.id IN (${
|
|
282
|
+
WHERE g.id IN (${groupIds})
|
|
276
283
|
ORDER BY g.name
|
|
277
284
|
`;
|
|
278
285
|
return rows;
|
|
279
286
|
};
|
|
280
287
|
|
|
281
|
-
export const getEffectiveGroupIds = async (params:
|
|
288
|
+
export const getEffectiveGroupIds = async (params: EffectiveGroupParams, db: AccessDb = sql): Promise<string[]> =>
|
|
282
289
|
(await getEffectiveGroups(params, db)).map((group) => group.id);
|
|
283
290
|
|
|
284
291
|
/**
|
|
@@ -329,7 +336,7 @@ export type ResourceAccessAdapter<TResourceId = string> = {
|
|
|
329
336
|
*
|
|
330
337
|
* Apps provide column fragments because access rows are commonly joined under
|
|
331
338
|
* different aliases. The subject is authoritative: group membership is always
|
|
332
|
-
* resolved recursively from its user id and never accepted from a caller.
|
|
339
|
+
* resolved recursively from its user or service-account id and never accepted from a caller.
|
|
333
340
|
* Resource binding, credential scopes, and permission precedence remain the
|
|
334
341
|
* responsibility of the app using the predicate.
|
|
335
342
|
*/
|
|
@@ -346,8 +353,13 @@ export const buildAccessPrincipalTierConditions = (params: {
|
|
|
346
353
|
serviceAccount: sql`${columns.serviceAccountId} = ${serviceAccountId}::uuid`,
|
|
347
354
|
user: sql`${columns.userId} = ${userId}::uuid`,
|
|
348
355
|
group: sql`(
|
|
349
|
-
${
|
|
350
|
-
|
|
356
|
+
${columns.groupId} IN (${
|
|
357
|
+
userId
|
|
358
|
+
? recursiveGroupIdsSubquery(userId)
|
|
359
|
+
: serviceAccountId
|
|
360
|
+
? recursiveServiceAccountGroupIdsSubquery(serviceAccountId)
|
|
361
|
+
: sql`SELECT NULL::uuid WHERE FALSE`
|
|
362
|
+
})
|
|
351
363
|
)`,
|
|
352
364
|
authenticated: sql`${authenticated} AND ${columns.authenticatedOnly} = true`,
|
|
353
365
|
public: sql`(
|
|
@@ -369,7 +381,7 @@ export const buildAccessPrincipalCondition = (params: { subject: AccessSubject |
|
|
|
369
381
|
* Get the effective permission level for an actor on a resource.
|
|
370
382
|
* Returns the highest permission from:
|
|
371
383
|
* - Direct user or service-account access
|
|
372
|
-
* - Direct and nested
|
|
384
|
+
* - Direct and nested group memberships for users and active standalone/agent accounts
|
|
373
385
|
* - Authenticated access for every authenticated subject
|
|
374
386
|
* - Public access for every subject, including anonymous callers
|
|
375
387
|
*
|
|
@@ -465,6 +477,7 @@ export const getEffectivePermissions = async (params: {
|
|
|
465
477
|
a.service_account_id = s.service_account_id
|
|
466
478
|
OR a.user_id = s.user_id
|
|
467
479
|
OR (s.user_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(sql`s.user_id`)}))
|
|
480
|
+
OR (s.service_account_id IS NOT NULL AND a.group_id IN (${recursiveServiceAccountGroupIdsSubquery(sql`s.service_account_id`)}))
|
|
468
481
|
OR a.authenticated_only = true
|
|
469
482
|
OR (a.user_id IS NULL AND a.group_id IS NULL AND a.service_account_id IS NULL AND a.authenticated_only = false)
|
|
470
483
|
)
|
|
@@ -415,7 +415,6 @@ export const accountsAppService = {
|
|
|
415
415
|
const userGroups = await users.getGroups({
|
|
416
416
|
id: config.userId,
|
|
417
417
|
recursive: config.recursive,
|
|
418
|
-
includePersonal: false,
|
|
419
418
|
});
|
|
420
419
|
const query = config.filter?.query?.trim().toLowerCase();
|
|
421
420
|
const filtered = query ? userGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : userGroups;
|
|
@@ -970,7 +969,7 @@ export const accountsAppService = {
|
|
|
970
969
|
group: {
|
|
971
970
|
list: async (config: {
|
|
972
971
|
pagination?: PageParams;
|
|
973
|
-
filter?: { search?: string
|
|
972
|
+
filter?: { search?: string };
|
|
974
973
|
scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
|
|
975
974
|
}): Promise<Paginated<BaseGroup>> => {
|
|
976
975
|
const { page, perPage } = paginate(config.pagination);
|
|
@@ -982,7 +981,6 @@ export const accountsAppService = {
|
|
|
982
981
|
scope: config.scope?.mode,
|
|
983
982
|
ids: config.scope?.ids,
|
|
984
983
|
provider: config.scope?.provider,
|
|
985
|
-
includePersonal: config.filter?.includePersonal,
|
|
986
984
|
});
|
|
987
985
|
|
|
988
986
|
return {
|
|
@@ -993,7 +991,10 @@ export const accountsAppService = {
|
|
|
993
991
|
hasNext: result.pagination.hasNext,
|
|
994
992
|
};
|
|
995
993
|
},
|
|
996
|
-
get: async (config: { id: string }) =>
|
|
994
|
+
get: async (config: { id: string }) => {
|
|
995
|
+
const result = await groups.list({ ids: [config.id], perPage: 1 });
|
|
996
|
+
return result.groups[0] ?? null;
|
|
997
|
+
},
|
|
997
998
|
member: {
|
|
998
999
|
list: async (config: {
|
|
999
1000
|
id: string;
|
|
@@ -1016,7 +1017,14 @@ export const accountsAppService = {
|
|
|
1016
1017
|
});
|
|
1017
1018
|
return paginateItems(filtered, config.pagination);
|
|
1018
1019
|
},
|
|
1019
|
-
add: async (config: {
|
|
1020
|
+
add: async (config: {
|
|
1021
|
+
actor: AccountsActor;
|
|
1022
|
+
id: string;
|
|
1023
|
+
provider?: UserProvider;
|
|
1024
|
+
userId?: string;
|
|
1025
|
+
groupId?: string;
|
|
1026
|
+
serviceAccountId?: string;
|
|
1027
|
+
}) => {
|
|
1020
1028
|
const group = await groups.get({ id: config.id });
|
|
1021
1029
|
if (!group) {
|
|
1022
1030
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1035,17 +1043,25 @@ export const accountsAppService = {
|
|
|
1035
1043
|
provider: config.provider,
|
|
1036
1044
|
user: config.userId,
|
|
1037
1045
|
group: config.groupId,
|
|
1046
|
+
serviceAccount: config.serviceAccountId,
|
|
1038
1047
|
}),
|
|
1039
1048
|
);
|
|
1040
1049
|
return recordCompletedMutation({
|
|
1041
1050
|
action: "accounts.group.member.add",
|
|
1042
1051
|
actor: auditActor(config.actor),
|
|
1043
1052
|
target: groupTarget(group),
|
|
1044
|
-
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
1053
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
|
|
1045
1054
|
result,
|
|
1046
1055
|
});
|
|
1047
1056
|
},
|
|
1048
|
-
remove: async (config: {
|
|
1057
|
+
remove: async (config: {
|
|
1058
|
+
actor: AccountsActor;
|
|
1059
|
+
id: string;
|
|
1060
|
+
provider?: UserProvider;
|
|
1061
|
+
userId?: string;
|
|
1062
|
+
groupId?: string;
|
|
1063
|
+
serviceAccountId?: string;
|
|
1064
|
+
}) => {
|
|
1049
1065
|
const group = await groups.get({ id: config.id });
|
|
1050
1066
|
if (!group) {
|
|
1051
1067
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1064,13 +1080,14 @@ export const accountsAppService = {
|
|
|
1064
1080
|
provider: config.provider,
|
|
1065
1081
|
user: config.userId,
|
|
1066
1082
|
group: config.groupId,
|
|
1083
|
+
serviceAccount: config.serviceAccountId,
|
|
1067
1084
|
}),
|
|
1068
1085
|
);
|
|
1069
1086
|
return recordCompletedMutation({
|
|
1070
1087
|
action: "accounts.group.member.remove",
|
|
1071
1088
|
actor: auditActor(config.actor),
|
|
1072
1089
|
target: groupTarget(group),
|
|
1073
|
-
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
1090
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
|
|
1074
1091
|
result,
|
|
1075
1092
|
});
|
|
1076
1093
|
},
|
|
@@ -1255,6 +1272,7 @@ export const accountsAppService = {
|
|
|
1255
1272
|
profile?: UserProfile;
|
|
1256
1273
|
userIds?: string[];
|
|
1257
1274
|
groupIds?: string[];
|
|
1275
|
+
serviceAccountIds?: string[];
|
|
1258
1276
|
serviceAccountKinds?: ServiceAccountKind[];
|
|
1259
1277
|
serviceAccountStatus?: ServiceAccountStatus;
|
|
1260
1278
|
excludeUserIds?: string[];
|
|
@@ -1266,7 +1284,6 @@ export const accountsAppService = {
|
|
|
1266
1284
|
parentGroupId?: string;
|
|
1267
1285
|
managedByUserId?: string;
|
|
1268
1286
|
recursive?: boolean;
|
|
1269
|
-
includePersonal?: boolean;
|
|
1270
1287
|
}): Promise<Paginated<EntityListItem>> => {
|
|
1271
1288
|
const canSearchDirectory = config.actor.roles.includes("user");
|
|
1272
1289
|
const usesRelationFilter = Boolean(
|
|
@@ -1288,6 +1305,7 @@ export const accountsAppService = {
|
|
|
1288
1305
|
profile: config.profile,
|
|
1289
1306
|
userIds: config.userIds,
|
|
1290
1307
|
groupIds: config.groupIds,
|
|
1308
|
+
serviceAccountIds: config.serviceAccountIds,
|
|
1291
1309
|
serviceAccountKinds: config.serviceAccountKinds,
|
|
1292
1310
|
serviceAccountStatus: config.serviceAccountStatus,
|
|
1293
1311
|
excludeUserIds: config.excludeUserIds,
|
|
@@ -1299,7 +1317,6 @@ export const accountsAppService = {
|
|
|
1299
1317
|
parentGroupId: config.parentGroupId,
|
|
1300
1318
|
managedByUserId: config.managedByUserId,
|
|
1301
1319
|
recursive: config.recursive,
|
|
1302
|
-
includePersonal: config.includePersonal,
|
|
1303
1320
|
page,
|
|
1304
1321
|
perPage,
|
|
1305
1322
|
});
|
|
@@ -1661,9 +1678,9 @@ export const accountsAppService = {
|
|
|
1661
1678
|
(SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local') AS local_accounts_total,
|
|
1662
1679
|
(SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'user') AS local_user_accounts_total,
|
|
1663
1680
|
(SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'guest') AS local_guest_accounts_total,
|
|
1664
|
-
(SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.
|
|
1665
|
-
(SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.
|
|
1666
|
-
(SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.
|
|
1681
|
+
(SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS groups_total,
|
|
1682
|
+
(SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS ipa_groups_total,
|
|
1683
|
+
(SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS local_groups_total,
|
|
1667
1684
|
(SELECT COUNT(*)::int FROM auth.account_requests WHERE status = 'pending') AS open_requests,
|
|
1668
1685
|
(
|
|
1669
1686
|
SELECT COUNT(*)::int
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { type SQLQuery, 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
5
|
import type { ServiceAccountKind, ServiceAccountStatus } from "../service-accounts";
|
|
6
6
|
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
7
7
|
import { buildBaseUser } from "./base-user";
|
|
8
|
-
import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
|
|
8
|
+
import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery, recursiveServiceAccountGroupIdsSubquery } from "./group-sql";
|
|
9
9
|
|
|
10
10
|
type DbRow = Record<string, unknown>;
|
|
11
|
-
type SqlFragment =
|
|
11
|
+
type SqlFragment = SQLQuery;
|
|
12
12
|
|
|
13
13
|
export type EntityListParams = {
|
|
14
14
|
visibility:
|
|
@@ -21,6 +21,7 @@ export type EntityListParams = {
|
|
|
21
21
|
profile?: UserProfile;
|
|
22
22
|
userIds?: string[];
|
|
23
23
|
groupIds?: string[];
|
|
24
|
+
serviceAccountIds?: string[];
|
|
24
25
|
serviceAccountKinds?: ServiceAccountKind[];
|
|
25
26
|
serviceAccountStatus?: ServiceAccountStatus;
|
|
26
27
|
excludeUserIds?: string[];
|
|
@@ -32,11 +33,6 @@ export type EntityListParams = {
|
|
|
32
33
|
parentGroupId?: string;
|
|
33
34
|
managedByUserId?: string;
|
|
34
35
|
recursive?: boolean;
|
|
35
|
-
/**
|
|
36
|
-
* Personal Linux groups are left out of directory browsing and search by
|
|
37
|
-
* default. Exact `groupIds` lookups and relation filters always return them.
|
|
38
|
-
*/
|
|
39
|
-
includePersonal?: boolean;
|
|
40
36
|
page?: number;
|
|
41
37
|
perPage?: number;
|
|
42
38
|
};
|
|
@@ -429,11 +425,7 @@ export const list = async (
|
|
|
429
425
|
const groupsAdmin = (await getFreeIpaConfig()).groupsAdmin;
|
|
430
426
|
const groupsAdminLiteral = toPgTextArray(groupsAdmin);
|
|
431
427
|
const spec = buildQuerySpec(params);
|
|
432
|
-
const
|
|
433
|
-
params.includePersonal === true ||
|
|
434
|
-
params.groupIds !== undefined ||
|
|
435
|
-
Boolean(params.memberOfGroupId || params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
|
|
436
|
-
const personalCondition = includePersonal ? sql`TRUE` : sql`(kind <> 'group' OR personal_owner_id IS NULL)`;
|
|
428
|
+
const personalCondition = sql`(kind <> 'group' OR NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = entity_rows.id))`;
|
|
437
429
|
const visibilityCondition =
|
|
438
430
|
params.visibility.type === "directory"
|
|
439
431
|
? sql`TRUE`
|
|
@@ -455,12 +447,13 @@ export const list = async (
|
|
|
455
447
|
)
|
|
456
448
|
)`;
|
|
457
449
|
const kindsCondition = (params.kinds?.length ?? 0) === 0 ? sql`TRUE` : sql`kind = ANY(${toPgTextArray(params.kinds ?? [])}::text[])`;
|
|
458
|
-
const hasIdFilter = params.userIds !== undefined || params.groupIds !== undefined;
|
|
450
|
+
const hasIdFilter = params.userIds !== undefined || params.groupIds !== undefined || params.serviceAccountIds !== undefined;
|
|
459
451
|
const includeIdCondition = !hasIdFilter
|
|
460
452
|
? sql`TRUE`
|
|
461
453
|
: sql`(
|
|
462
454
|
(kind = 'user' AND id = ANY(${toPgUuidArray(params.userIds ?? [])}::uuid[]))
|
|
463
455
|
OR (kind = 'group' AND id = ANY(${toPgUuidArray(params.groupIds ?? [])}::uuid[]))
|
|
456
|
+
OR (kind = 'service_account' AND id = ANY(${toPgUuidArray(params.serviceAccountIds ?? [])}::uuid[]))
|
|
464
457
|
)`;
|
|
465
458
|
const excludeUserCondition =
|
|
466
459
|
(params.excludeUserIds?.length ?? 0) === 0
|
|
@@ -491,11 +484,30 @@ export const list = async (
|
|
|
491
484
|
AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
|
|
492
485
|
))`;
|
|
493
486
|
|
|
487
|
+
// Machine recipients have local membership, but no directory management/parent relation.
|
|
488
|
+
const otherRelation = Boolean(params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
|
|
489
|
+
const machineRelation = params.memberOfGroupId
|
|
490
|
+
? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
|
|
491
|
+
SELECT 1 FROM auth.groups target
|
|
492
|
+
WHERE target.id = ${params.memberOfGroupId}::uuid AND target.provider = 'local'
|
|
493
|
+
AND ${
|
|
494
|
+
params.recursive === true
|
|
495
|
+
? sql`target.id IN (${recursiveServiceAccountGroupIdsSubquery(sql`sa.id`)})`
|
|
496
|
+
: sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
|
|
497
|
+
WHERE sg.service_account_id = sa.id AND sg.group_id = target.id)`
|
|
498
|
+
}
|
|
499
|
+
)`
|
|
500
|
+
: sql`TRUE`;
|
|
501
|
+
const machineDirect = params.memberOfGroupId
|
|
502
|
+
? sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
|
|
503
|
+
WHERE sg.service_account_id = sa.id AND sg.group_id = ${params.memberOfGroupId}::uuid)`
|
|
504
|
+
: sql`NULL::boolean`;
|
|
494
505
|
const where = sql`
|
|
495
506
|
${visibilityCondition}
|
|
496
507
|
AND ${kindsCondition}
|
|
497
508
|
AND ${includeIdCondition}
|
|
498
|
-
AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null}
|
|
509
|
+
AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null}
|
|
510
|
+
OR (kind = 'service_account' AND ${params.provider ?? null}::text = 'local'))
|
|
499
511
|
AND (${params.profile ?? null}::text IS NULL OR kind = 'group' OR profile = ${params.profile ?? null})
|
|
500
512
|
AND ${excludeUserCondition}
|
|
501
513
|
AND ${excludeGroupCondition}
|
|
@@ -612,7 +624,7 @@ export const list = async (
|
|
|
612
624
|
service_account_rows AS (
|
|
613
625
|
SELECT
|
|
614
626
|
'service_account'::text AS kind,
|
|
615
|
-
|
|
627
|
+
${machineDirect} AS direct,
|
|
616
628
|
sa.id,
|
|
617
629
|
NULL::text AS provider,
|
|
618
630
|
NULL::text AS profile,
|
|
@@ -644,6 +656,7 @@ export const list = async (
|
|
|
644
656
|
NULL::text AS personal_owner_display_name,
|
|
645
657
|
LOWER(sa.name) AS sort_label
|
|
646
658
|
FROM auth.service_accounts sa
|
|
659
|
+
WHERE NOT ${otherRelation} AND ${machineRelation}
|
|
647
660
|
),
|
|
648
661
|
entity_rows AS (
|
|
649
662
|
SELECT * FROM user_rows
|
|
@@ -104,3 +104,26 @@ export const buildManagedGroupScopeCondition = (params: { userId: SqlValue; grou
|
|
|
104
104
|
})}
|
|
105
105
|
)
|
|
106
106
|
`;
|
|
107
|
+
|
|
108
|
+
/** Local recipient membership only; never supplies user roles or directory identity. */
|
|
109
|
+
export const recursiveServiceAccountGroupIdsSubquery = (serviceAccountId: SqlValue) => sql`
|
|
110
|
+
WITH RECURSIVE account_subject AS (
|
|
111
|
+
-- Resolve a correlated column before introducing aliases used by directory callers (for example sa.id).
|
|
112
|
+
SELECT ${serviceAccountId}::uuid AS id
|
|
113
|
+
), account_groups AS (
|
|
114
|
+
SELECT sg.group_id
|
|
115
|
+
FROM auth.service_account_groups sg
|
|
116
|
+
JOIN auth.service_accounts sa ON sa.id = sg.service_account_id
|
|
117
|
+
JOIN account_subject subject ON subject.id = sa.id
|
|
118
|
+
JOIN auth.groups g ON g.id = sg.group_id
|
|
119
|
+
WHERE sa.status = 'active' AND sa.kind IN ('standalone', 'agent')
|
|
120
|
+
AND g.provider = 'local'
|
|
121
|
+
UNION
|
|
122
|
+
SELECT gg.parent_group_id
|
|
123
|
+
FROM auth.group_groups_v2 gg
|
|
124
|
+
JOIN auth.groups parent ON parent.id = gg.parent_group_id
|
|
125
|
+
JOIN account_groups ag ON ag.group_id = gg.child_group_id
|
|
126
|
+
WHERE parent.provider = 'local'
|
|
127
|
+
)
|
|
128
|
+
SELECT group_id FROM account_groups
|
|
129
|
+
`;
|