@valentinkolb/cloud 0.4.0 → 0.5.1
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 +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { crypto, err, fail, ok, type PageParams, type Paginated, type Result } from "@valentinkolb/stdlib";
|
|
3
|
+
import { accounts } from "./accounts";
|
|
4
|
+
import { audit } from "./audit";
|
|
5
|
+
import { isUniqueViolation, toPgTextArray } from "./postgres";
|
|
6
|
+
import { serviceAccounts, type ServiceAccount } from "./service-accounts";
|
|
7
|
+
import type { User } from "../contracts/shared";
|
|
8
|
+
|
|
9
|
+
export type ServiceAccountCredentialStatus = "active" | "revoked";
|
|
10
|
+
export type ServiceAccountCredentialKind = "api_token";
|
|
11
|
+
|
|
12
|
+
export type ServiceAccountCredential = {
|
|
13
|
+
id: string;
|
|
14
|
+
serviceAccountId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
kind: ServiceAccountCredentialKind;
|
|
17
|
+
status: ServiceAccountCredentialStatus;
|
|
18
|
+
tokenPrefix: string;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
expiresAt: string | null;
|
|
21
|
+
lastUsedAt: string | null;
|
|
22
|
+
createdBy: string | null;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
revokedAt: string | null;
|
|
25
|
+
revokedBy: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type AuthenticatedServiceAccountCredential = {
|
|
29
|
+
credential: ServiceAccountCredential;
|
|
30
|
+
serviceAccount: ServiceAccount;
|
|
31
|
+
delegatedUser: User | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ServiceAccountCredentialOwner =
|
|
35
|
+
| {
|
|
36
|
+
type: "user";
|
|
37
|
+
userId: string;
|
|
38
|
+
uid: string;
|
|
39
|
+
displayName: string;
|
|
40
|
+
mail: string | null;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
type: "resource";
|
|
44
|
+
appId: string;
|
|
45
|
+
resourceType: string;
|
|
46
|
+
resourceId: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ServiceAccountCredentialOverview = ServiceAccountCredential & {
|
|
50
|
+
serviceAccount: ServiceAccount;
|
|
51
|
+
owner: ServiceAccountCredentialOwner;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type DbCredentialRow = {
|
|
55
|
+
id: string;
|
|
56
|
+
service_account_id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
kind: ServiceAccountCredentialKind;
|
|
59
|
+
status: ServiceAccountCredentialStatus;
|
|
60
|
+
token_prefix: string;
|
|
61
|
+
scopes: string[];
|
|
62
|
+
expires_at: Date | null;
|
|
63
|
+
last_used_at: Date | null;
|
|
64
|
+
created_by: string | null;
|
|
65
|
+
created_at: Date;
|
|
66
|
+
revoked_at: Date | null;
|
|
67
|
+
revoked_by: string | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type DbCredentialWithSecretRow = DbCredentialRow & {
|
|
71
|
+
secret_hash: string;
|
|
72
|
+
} & DbCredentialServiceAccountFields;
|
|
73
|
+
|
|
74
|
+
type DbCredentialServiceAccountFields = {
|
|
75
|
+
service_account_id: string;
|
|
76
|
+
service_account_name: string;
|
|
77
|
+
service_account_kind: ServiceAccount["kind"];
|
|
78
|
+
service_account_status: ServiceAccount["status"];
|
|
79
|
+
delegated_user_id: string | null;
|
|
80
|
+
app_id: string | null;
|
|
81
|
+
resource_type: string | null;
|
|
82
|
+
resource_id: string | null;
|
|
83
|
+
service_account_created_by: string | null;
|
|
84
|
+
service_account_created_at: Date;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type SqlRunner = typeof sql;
|
|
88
|
+
type DbCredentialOverviewRow = DbCredentialRow & DbCredentialServiceAccountFields & {
|
|
89
|
+
delegated_uid: string | null;
|
|
90
|
+
delegated_display_name: string | null;
|
|
91
|
+
delegated_mail: string | null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const TOKEN_PREFIX = "cld";
|
|
95
|
+
const TOKEN_PATTERN = /^cld_([0-9a-f]{24})_([0-9a-f]{64})$/i;
|
|
96
|
+
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;
|
|
97
|
+
|
|
98
|
+
const isForeignKeyViolation = (error: unknown): boolean => {
|
|
99
|
+
if (!error || typeof error !== "object") return false;
|
|
100
|
+
const e = error as { code?: string; errno?: string };
|
|
101
|
+
return e.code === "23503" || e.errno === "23503";
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const USER_DELEGATED_UNIQUE_CONSTRAINT = "uniq_service_accounts_user_delegated";
|
|
105
|
+
|
|
106
|
+
const mapCredential = (row: DbCredentialRow): ServiceAccountCredential => ({
|
|
107
|
+
id: row.id,
|
|
108
|
+
serviceAccountId: row.service_account_id,
|
|
109
|
+
name: row.name,
|
|
110
|
+
kind: row.kind,
|
|
111
|
+
status: row.status,
|
|
112
|
+
tokenPrefix: row.token_prefix,
|
|
113
|
+
scopes: row.scopes ?? [],
|
|
114
|
+
expiresAt: row.expires_at?.toISOString() ?? null,
|
|
115
|
+
lastUsedAt: row.last_used_at?.toISOString() ?? null,
|
|
116
|
+
createdBy: row.created_by,
|
|
117
|
+
createdAt: row.created_at.toISOString(),
|
|
118
|
+
revokedAt: row.revoked_at?.toISOString() ?? null,
|
|
119
|
+
revokedBy: row.revoked_by,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const mapServiceAccount = (row: DbCredentialServiceAccountFields): ServiceAccount => ({
|
|
123
|
+
id: row.service_account_id,
|
|
124
|
+
name: row.service_account_name,
|
|
125
|
+
kind: row.service_account_kind,
|
|
126
|
+
status: row.service_account_status,
|
|
127
|
+
delegatedUserId: row.delegated_user_id,
|
|
128
|
+
appId: row.app_id,
|
|
129
|
+
resourceType: row.resource_type,
|
|
130
|
+
resourceId: row.resource_id,
|
|
131
|
+
createdBy: row.service_account_created_by,
|
|
132
|
+
createdAt: row.service_account_created_at.toISOString(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const mapCredentialOverview = (row: DbCredentialOverviewRow): ServiceAccountCredentialOverview => {
|
|
136
|
+
const serviceAccount = mapServiceAccount(row);
|
|
137
|
+
return {
|
|
138
|
+
...mapCredential(row),
|
|
139
|
+
serviceAccount,
|
|
140
|
+
owner:
|
|
141
|
+
serviceAccount.kind === "user_delegated" && serviceAccount.delegatedUserId
|
|
142
|
+
? {
|
|
143
|
+
type: "user",
|
|
144
|
+
userId: serviceAccount.delegatedUserId,
|
|
145
|
+
uid: row.delegated_uid ?? serviceAccount.delegatedUserId,
|
|
146
|
+
displayName: row.delegated_display_name ?? "",
|
|
147
|
+
mail: row.delegated_mail,
|
|
148
|
+
}
|
|
149
|
+
: {
|
|
150
|
+
type: "resource",
|
|
151
|
+
appId: serviceAccount.appId ?? "",
|
|
152
|
+
resourceType: serviceAccount.resourceType ?? "",
|
|
153
|
+
resourceId: serviceAccount.resourceId ?? "",
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const actorForUser = (user: Pick<User, "id" | "uid" | "provider" | "roles">) => ({
|
|
159
|
+
userId: user.id,
|
|
160
|
+
uid: user.uid,
|
|
161
|
+
provider: user.provider,
|
|
162
|
+
roles: user.roles,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const normalizeName = (value: string): string => value.trim();
|
|
166
|
+
|
|
167
|
+
const parseToken = (token: string): { tokenPrefix: string; secret: string } | null => {
|
|
168
|
+
const match = token.match(TOKEN_PATTERN);
|
|
169
|
+
if (!match) return null;
|
|
170
|
+
return { tokenPrefix: match[1]!.toLowerCase(), secret: match[2]!.toLowerCase() };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const generateTokenParts = (): { tokenPrefix: string; secret: string; token: string } => {
|
|
174
|
+
const tokenPrefix = crypto.common.generateKey(12);
|
|
175
|
+
const secret = crypto.common.generateKey(32);
|
|
176
|
+
return { tokenPrefix, secret, token: `${TOKEN_PREFIX}_${tokenPrefix}_${secret}` };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const generateUniqueTokenParts = async (): Promise<{ tokenPrefix: string; secret: string; token: string }> => {
|
|
180
|
+
for (let i = 0; i < 5; i += 1) {
|
|
181
|
+
const parts = generateTokenParts();
|
|
182
|
+
const [row] = await sql<{ exists: boolean }[]>`
|
|
183
|
+
SELECT EXISTS(
|
|
184
|
+
SELECT 1 FROM auth.service_account_credentials WHERE token_prefix = ${parts.tokenPrefix}
|
|
185
|
+
) AS exists
|
|
186
|
+
`;
|
|
187
|
+
if (!row?.exists) return parts;
|
|
188
|
+
}
|
|
189
|
+
throw new Error("Failed to generate unique API token prefix");
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const isApiToken = (token: string | null | undefined): boolean => Boolean(token && TOKEN_PATTERN.test(token));
|
|
193
|
+
|
|
194
|
+
export const getOrCreateUserDelegatedServiceAccount = async (params: {
|
|
195
|
+
userId: string;
|
|
196
|
+
createdBy?: string | null;
|
|
197
|
+
}): Promise<Result<ServiceAccount>> => {
|
|
198
|
+
const [existing] = await sql<{
|
|
199
|
+
id: string;
|
|
200
|
+
name: string;
|
|
201
|
+
kind: ServiceAccount["kind"];
|
|
202
|
+
status: ServiceAccount["status"];
|
|
203
|
+
delegated_user_id: string | null;
|
|
204
|
+
app_id: string | null;
|
|
205
|
+
resource_type: string | null;
|
|
206
|
+
resource_id: string | null;
|
|
207
|
+
created_by: string | null;
|
|
208
|
+
created_at: Date;
|
|
209
|
+
}[]>`
|
|
210
|
+
SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
|
|
211
|
+
FROM auth.service_accounts
|
|
212
|
+
WHERE kind = 'user_delegated'
|
|
213
|
+
AND delegated_user_id = ${params.userId}::uuid
|
|
214
|
+
ORDER BY created_at ASC
|
|
215
|
+
LIMIT 1
|
|
216
|
+
`;
|
|
217
|
+
if (existing) {
|
|
218
|
+
return ok({
|
|
219
|
+
id: existing.id,
|
|
220
|
+
name: existing.name,
|
|
221
|
+
kind: existing.kind,
|
|
222
|
+
status: existing.status,
|
|
223
|
+
delegatedUserId: existing.delegated_user_id,
|
|
224
|
+
appId: existing.app_id,
|
|
225
|
+
resourceType: existing.resource_type,
|
|
226
|
+
resourceId: existing.resource_id,
|
|
227
|
+
createdBy: existing.created_by,
|
|
228
|
+
createdAt: existing.created_at.toISOString(),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
return await serviceAccounts.createUserDelegated({
|
|
234
|
+
name: "Personal API keys",
|
|
235
|
+
delegatedUserId: params.userId,
|
|
236
|
+
createdBy: params.createdBy ?? params.userId,
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (!isUniqueViolation(error, USER_DELEGATED_UNIQUE_CONSTRAINT)) throw error;
|
|
240
|
+
const [row] = await sql<{
|
|
241
|
+
id: string;
|
|
242
|
+
name: string;
|
|
243
|
+
kind: ServiceAccount["kind"];
|
|
244
|
+
status: ServiceAccount["status"];
|
|
245
|
+
delegated_user_id: string | null;
|
|
246
|
+
app_id: string | null;
|
|
247
|
+
resource_type: string | null;
|
|
248
|
+
resource_id: string | null;
|
|
249
|
+
created_by: string | null;
|
|
250
|
+
created_at: Date;
|
|
251
|
+
}[]>`
|
|
252
|
+
SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
|
|
253
|
+
FROM auth.service_accounts
|
|
254
|
+
WHERE kind = 'user_delegated'
|
|
255
|
+
AND delegated_user_id = ${params.userId}::uuid
|
|
256
|
+
LIMIT 1
|
|
257
|
+
`;
|
|
258
|
+
if (!row) return fail(err.internal("Failed to load user service account"));
|
|
259
|
+
return ok({
|
|
260
|
+
id: row.id,
|
|
261
|
+
name: row.name,
|
|
262
|
+
kind: row.kind,
|
|
263
|
+
status: row.status,
|
|
264
|
+
delegatedUserId: row.delegated_user_id,
|
|
265
|
+
appId: row.app_id,
|
|
266
|
+
resourceType: row.resource_type,
|
|
267
|
+
resourceId: row.resource_id,
|
|
268
|
+
createdBy: row.created_by,
|
|
269
|
+
createdAt: row.created_at.toISOString(),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const insertApiToken = async (db: SqlRunner, params: {
|
|
275
|
+
serviceAccountId: string;
|
|
276
|
+
name: string;
|
|
277
|
+
createdBy?: string | null;
|
|
278
|
+
expiresAt?: string | null;
|
|
279
|
+
scopes?: string[];
|
|
280
|
+
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
281
|
+
if (!UUID_PATTERN.test(params.serviceAccountId)) return fail(err.notFound("Service account"));
|
|
282
|
+
const name = normalizeName(params.name);
|
|
283
|
+
if (!name) return fail(err.badInput("API key name is required"));
|
|
284
|
+
if (name.length > 120) return fail(err.badInput("API key name must be 120 characters or fewer"));
|
|
285
|
+
|
|
286
|
+
let expiresAt: Date | null = null;
|
|
287
|
+
if (params.expiresAt) {
|
|
288
|
+
expiresAt = new Date(params.expiresAt);
|
|
289
|
+
if (Number.isNaN(expiresAt.getTime())) return fail(err.badInput("Invalid expiry date"));
|
|
290
|
+
if (expiresAt.getTime() <= Date.now()) return fail(err.badInput("Expiry must be in the future"));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const parts = await generateUniqueTokenParts();
|
|
294
|
+
const secretHash = await Bun.password.hash(parts.secret);
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const [row] = await db<DbCredentialRow[]>`
|
|
298
|
+
INSERT INTO auth.service_account_credentials (
|
|
299
|
+
service_account_id,
|
|
300
|
+
name,
|
|
301
|
+
token_prefix,
|
|
302
|
+
secret_hash,
|
|
303
|
+
scopes,
|
|
304
|
+
expires_at,
|
|
305
|
+
created_by
|
|
306
|
+
)
|
|
307
|
+
VALUES (
|
|
308
|
+
${params.serviceAccountId}::uuid,
|
|
309
|
+
${name},
|
|
310
|
+
${parts.tokenPrefix},
|
|
311
|
+
${secretHash},
|
|
312
|
+
${toPgTextArray(params.scopes ?? [])}::text[],
|
|
313
|
+
${expiresAt},
|
|
314
|
+
${params.createdBy ?? null}::uuid
|
|
315
|
+
)
|
|
316
|
+
RETURNING id, service_account_id, name, kind, status, token_prefix, scopes, expires_at, last_used_at, created_by, created_at, revoked_at, revoked_by
|
|
317
|
+
`;
|
|
318
|
+
if (!row) return fail(err.internal("Failed to create API key"));
|
|
319
|
+
return ok({ credential: mapCredential(row), token: parts.token });
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (isForeignKeyViolation(error)) return fail(err.notFound("Service account"));
|
|
322
|
+
if (isUniqueViolation(error)) return fail(err.conflict("API key"));
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const createApiToken = (params: {
|
|
328
|
+
serviceAccountId: string;
|
|
329
|
+
name: string;
|
|
330
|
+
createdBy?: string | null;
|
|
331
|
+
expiresAt?: string | null;
|
|
332
|
+
scopes?: string[];
|
|
333
|
+
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => insertApiToken(sql, params);
|
|
334
|
+
|
|
335
|
+
export const createUserApiToken = async (params: {
|
|
336
|
+
user: User;
|
|
337
|
+
name: string;
|
|
338
|
+
expiresAt?: string | null;
|
|
339
|
+
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
340
|
+
const serviceAccountResult = await getOrCreateUserDelegatedServiceAccount({
|
|
341
|
+
userId: params.user.id,
|
|
342
|
+
createdBy: params.user.id,
|
|
343
|
+
});
|
|
344
|
+
if (!serviceAccountResult.ok) return fail(serviceAccountResult.error);
|
|
345
|
+
|
|
346
|
+
return sql.begin(async (tx) => {
|
|
347
|
+
const result = await insertApiToken(tx, {
|
|
348
|
+
serviceAccountId: serviceAccountResult.data.id,
|
|
349
|
+
name: params.name,
|
|
350
|
+
expiresAt: params.expiresAt,
|
|
351
|
+
createdBy: params.user.id,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
return audit.recordResult({
|
|
355
|
+
action: "service_account_credential.create",
|
|
356
|
+
actor: actorForUser(params.user),
|
|
357
|
+
target: { type: "service_account_credential", id: result.ok ? result.data.credential.id : null, label: params.name },
|
|
358
|
+
metadata: {
|
|
359
|
+
serviceAccountId: serviceAccountResult.data.id,
|
|
360
|
+
kind: "api_token",
|
|
361
|
+
expiresAt: params.expiresAt ?? null,
|
|
362
|
+
},
|
|
363
|
+
result,
|
|
364
|
+
db: tx,
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export const createResourceApiToken = async (params: {
|
|
370
|
+
serviceAccountId: string;
|
|
371
|
+
actor: User;
|
|
372
|
+
name: string;
|
|
373
|
+
expiresAt?: string | null;
|
|
374
|
+
scopes?: string[];
|
|
375
|
+
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
376
|
+
const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
|
|
377
|
+
if (!serviceAccount || serviceAccount.kind !== "resource_bound") return fail(err.notFound("Resource service account"));
|
|
378
|
+
if (serviceAccount.status !== "active") return fail(err.badInput("Resource service account is disabled"));
|
|
379
|
+
|
|
380
|
+
return sql.begin(async (tx) => {
|
|
381
|
+
const result = await insertApiToken(tx, {
|
|
382
|
+
serviceAccountId: serviceAccount.id,
|
|
383
|
+
name: params.name,
|
|
384
|
+
expiresAt: params.expiresAt,
|
|
385
|
+
createdBy: params.actor.id,
|
|
386
|
+
scopes: params.scopes,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return audit.recordResult({
|
|
390
|
+
action: "service_account_credential.create",
|
|
391
|
+
actor: actorForUser(params.actor),
|
|
392
|
+
target: { type: "service_account_credential", id: result.ok ? result.data.credential.id : null, label: params.name },
|
|
393
|
+
metadata: {
|
|
394
|
+
serviceAccountId: serviceAccount.id,
|
|
395
|
+
kind: "api_token",
|
|
396
|
+
serviceAccountKind: serviceAccount.kind,
|
|
397
|
+
appId: serviceAccount.appId,
|
|
398
|
+
resourceType: serviceAccount.resourceType,
|
|
399
|
+
resourceId: serviceAccount.resourceId,
|
|
400
|
+
expiresAt: params.expiresAt ?? null,
|
|
401
|
+
},
|
|
402
|
+
result,
|
|
403
|
+
db: tx,
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
export const listForDelegatedUser = async (params: { userId: string }): Promise<ServiceAccountCredential[]> => {
|
|
409
|
+
const rows = await sql<DbCredentialRow[]>`
|
|
410
|
+
SELECT c.id, c.service_account_id, c.name, c.kind, c.status, c.token_prefix, c.scopes, c.expires_at,
|
|
411
|
+
c.last_used_at, c.created_by, c.created_at, c.revoked_at, c.revoked_by
|
|
412
|
+
FROM auth.service_account_credentials c
|
|
413
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
414
|
+
WHERE sa.kind = 'user_delegated'
|
|
415
|
+
AND sa.delegated_user_id = ${params.userId}::uuid
|
|
416
|
+
AND c.status = 'active'
|
|
417
|
+
ORDER BY c.created_at DESC
|
|
418
|
+
`;
|
|
419
|
+
return rows.map(mapCredential);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
export const listOverview = async (config?: {
|
|
423
|
+
pagination?: PageParams;
|
|
424
|
+
filter?: {
|
|
425
|
+
search?: string;
|
|
426
|
+
serviceAccountKind?: ServiceAccount["kind"];
|
|
427
|
+
credentialStatus?: ServiceAccountCredentialStatus;
|
|
428
|
+
userId?: string;
|
|
429
|
+
appId?: string;
|
|
430
|
+
resourceType?: string;
|
|
431
|
+
resourceId?: string;
|
|
432
|
+
serviceAccountId?: string;
|
|
433
|
+
};
|
|
434
|
+
}): Promise<Paginated<ServiceAccountCredentialOverview>> => {
|
|
435
|
+
const page = Math.max(1, config?.pagination?.page ?? 1);
|
|
436
|
+
const perPage = Math.max(1, Math.min(config?.pagination?.perPage ?? 100, 500));
|
|
437
|
+
const offset = (page - 1) * perPage;
|
|
438
|
+
const search = config?.filter?.search?.trim() || null;
|
|
439
|
+
const serviceAccountKind = config?.filter?.serviceAccountKind ?? null;
|
|
440
|
+
const credentialStatus = config?.filter?.credentialStatus ?? null;
|
|
441
|
+
const userId = config?.filter?.userId ?? null;
|
|
442
|
+
const appId = config?.filter?.appId ?? null;
|
|
443
|
+
const resourceType = config?.filter?.resourceType ?? null;
|
|
444
|
+
const resourceId = config?.filter?.resourceId ?? null;
|
|
445
|
+
const serviceAccountId = config?.filter?.serviceAccountId ?? null;
|
|
446
|
+
|
|
447
|
+
const [countRow] = await sql<{ count: string }[]>`
|
|
448
|
+
SELECT COUNT(*)::text AS count
|
|
449
|
+
FROM auth.service_account_credentials c
|
|
450
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
451
|
+
LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
|
|
452
|
+
WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
453
|
+
AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
|
|
454
|
+
AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
|
|
455
|
+
AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
|
|
456
|
+
AND (${appId}::text IS NULL OR sa.app_id = ${appId})
|
|
457
|
+
AND (${resourceType}::text IS NULL OR sa.resource_type = ${resourceType})
|
|
458
|
+
AND (${resourceId}::text IS NULL OR sa.resource_id = ${resourceId})
|
|
459
|
+
AND (
|
|
460
|
+
${search}::text IS NULL
|
|
461
|
+
OR c.name ILIKE '%' || ${search} || '%'
|
|
462
|
+
OR c.token_prefix ILIKE '%' || ${search} || '%'
|
|
463
|
+
OR sa.name ILIKE '%' || ${search} || '%'
|
|
464
|
+
OR du.uid ILIKE '%' || ${search} || '%'
|
|
465
|
+
OR du.display_name ILIKE '%' || ${search} || '%'
|
|
466
|
+
OR du.mail ILIKE '%' || ${search} || '%'
|
|
467
|
+
OR sa.app_id ILIKE '%' || ${search} || '%'
|
|
468
|
+
OR sa.resource_type ILIKE '%' || ${search} || '%'
|
|
469
|
+
OR sa.resource_id ILIKE '%' || ${search} || '%'
|
|
470
|
+
)
|
|
471
|
+
`;
|
|
472
|
+
|
|
473
|
+
const rows = await sql<DbCredentialOverviewRow[]>`
|
|
474
|
+
SELECT
|
|
475
|
+
c.id,
|
|
476
|
+
c.service_account_id,
|
|
477
|
+
c.name,
|
|
478
|
+
c.kind,
|
|
479
|
+
c.status,
|
|
480
|
+
c.token_prefix,
|
|
481
|
+
c.scopes,
|
|
482
|
+
c.expires_at,
|
|
483
|
+
c.last_used_at,
|
|
484
|
+
c.created_by,
|
|
485
|
+
c.created_at,
|
|
486
|
+
c.revoked_at,
|
|
487
|
+
c.revoked_by,
|
|
488
|
+
sa.name AS service_account_name,
|
|
489
|
+
sa.kind AS service_account_kind,
|
|
490
|
+
sa.status AS service_account_status,
|
|
491
|
+
sa.delegated_user_id,
|
|
492
|
+
sa.app_id,
|
|
493
|
+
sa.resource_type,
|
|
494
|
+
sa.resource_id,
|
|
495
|
+
sa.created_by AS service_account_created_by,
|
|
496
|
+
sa.created_at AS service_account_created_at,
|
|
497
|
+
du.uid AS delegated_uid,
|
|
498
|
+
du.display_name AS delegated_display_name,
|
|
499
|
+
du.mail AS delegated_mail
|
|
500
|
+
FROM auth.service_account_credentials c
|
|
501
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
502
|
+
LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
|
|
503
|
+
WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
504
|
+
AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
|
|
505
|
+
AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
|
|
506
|
+
AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
|
|
507
|
+
AND (${appId}::text IS NULL OR sa.app_id = ${appId})
|
|
508
|
+
AND (${resourceType}::text IS NULL OR sa.resource_type = ${resourceType})
|
|
509
|
+
AND (${resourceId}::text IS NULL OR sa.resource_id = ${resourceId})
|
|
510
|
+
AND (
|
|
511
|
+
${search}::text IS NULL
|
|
512
|
+
OR c.name ILIKE '%' || ${search} || '%'
|
|
513
|
+
OR c.token_prefix ILIKE '%' || ${search} || '%'
|
|
514
|
+
OR sa.name ILIKE '%' || ${search} || '%'
|
|
515
|
+
OR du.uid ILIKE '%' || ${search} || '%'
|
|
516
|
+
OR du.display_name ILIKE '%' || ${search} || '%'
|
|
517
|
+
OR du.mail ILIKE '%' || ${search} || '%'
|
|
518
|
+
OR sa.app_id ILIKE '%' || ${search} || '%'
|
|
519
|
+
OR sa.resource_type ILIKE '%' || ${search} || '%'
|
|
520
|
+
OR sa.resource_id ILIKE '%' || ${search} || '%'
|
|
521
|
+
)
|
|
522
|
+
ORDER BY c.created_at DESC
|
|
523
|
+
LIMIT ${perPage}
|
|
524
|
+
OFFSET ${offset}
|
|
525
|
+
`;
|
|
526
|
+
|
|
527
|
+
const total = Number.parseInt(countRow?.count ?? "0", 10);
|
|
528
|
+
return {
|
|
529
|
+
items: rows.map(mapCredentialOverview),
|
|
530
|
+
page,
|
|
531
|
+
perPage,
|
|
532
|
+
total,
|
|
533
|
+
hasNext: page * perPage < total,
|
|
534
|
+
};
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
export const revokeForDelegatedUser = async (params: {
|
|
538
|
+
credentialId: string;
|
|
539
|
+
user: User;
|
|
540
|
+
}): Promise<Result<void>> => {
|
|
541
|
+
if (!UUID_PATTERN.test(params.credentialId)) return fail(err.notFound("API key"));
|
|
542
|
+
|
|
543
|
+
return sql.begin(async (tx) => {
|
|
544
|
+
const [row] = await tx<DbCredentialRow[]>`
|
|
545
|
+
UPDATE auth.service_account_credentials c
|
|
546
|
+
SET status = 'revoked',
|
|
547
|
+
revoked_at = now(),
|
|
548
|
+
revoked_by = ${params.user.id}::uuid
|
|
549
|
+
FROM auth.service_accounts sa
|
|
550
|
+
WHERE c.id = ${params.credentialId}::uuid
|
|
551
|
+
AND c.service_account_id = sa.id
|
|
552
|
+
AND sa.kind = 'user_delegated'
|
|
553
|
+
AND sa.delegated_user_id = ${params.user.id}::uuid
|
|
554
|
+
AND c.status = 'active'
|
|
555
|
+
RETURNING c.id, c.service_account_id, c.name, c.kind, c.status, c.token_prefix, c.scopes, c.expires_at,
|
|
556
|
+
c.last_used_at, c.created_by, c.created_at, c.revoked_at, c.revoked_by
|
|
557
|
+
`;
|
|
558
|
+
|
|
559
|
+
const result = row ? ok() : fail(err.notFound("API key"));
|
|
560
|
+
return audit.recordResult({
|
|
561
|
+
action: "service_account_credential.revoke",
|
|
562
|
+
actor: actorForUser(params.user),
|
|
563
|
+
target: { type: "service_account_credential", id: params.credentialId, label: row?.name ?? null },
|
|
564
|
+
metadata: { serviceAccountId: row?.service_account_id ?? null },
|
|
565
|
+
result,
|
|
566
|
+
db: tx,
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
export const revoke = async (params: {
|
|
572
|
+
credentialId: string;
|
|
573
|
+
actor: User;
|
|
574
|
+
}): Promise<Result<void>> => {
|
|
575
|
+
if (!UUID_PATTERN.test(params.credentialId)) return fail(err.notFound("API key"));
|
|
576
|
+
|
|
577
|
+
return sql.begin(async (tx) => {
|
|
578
|
+
const [row] = await tx<DbCredentialRow[]>`
|
|
579
|
+
UPDATE auth.service_account_credentials
|
|
580
|
+
SET status = 'revoked',
|
|
581
|
+
revoked_at = now(),
|
|
582
|
+
revoked_by = ${params.actor.id}::uuid
|
|
583
|
+
WHERE id = ${params.credentialId}::uuid
|
|
584
|
+
AND status = 'active'
|
|
585
|
+
RETURNING id, service_account_id, name, kind, status, token_prefix, scopes, expires_at,
|
|
586
|
+
last_used_at, created_by, created_at, revoked_at, revoked_by
|
|
587
|
+
`;
|
|
588
|
+
|
|
589
|
+
const result = row ? ok() : fail(err.notFound("API key"));
|
|
590
|
+
return audit.recordResult({
|
|
591
|
+
action: "service_account_credential.revoke",
|
|
592
|
+
actor: actorForUser(params.actor),
|
|
593
|
+
target: { type: "service_account_credential", id: params.credentialId, label: row?.name ?? null },
|
|
594
|
+
metadata: { serviceAccountId: row?.service_account_id ?? null, adminAction: true },
|
|
595
|
+
result,
|
|
596
|
+
db: tx,
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const findActiveByTokenPrefix = async (tokenPrefix: string): Promise<DbCredentialWithSecretRow | null> => {
|
|
602
|
+
const [row] = await sql<DbCredentialWithSecretRow[]>`
|
|
603
|
+
SELECT
|
|
604
|
+
c.id,
|
|
605
|
+
c.service_account_id,
|
|
606
|
+
c.name,
|
|
607
|
+
c.kind,
|
|
608
|
+
c.status,
|
|
609
|
+
c.token_prefix,
|
|
610
|
+
c.secret_hash,
|
|
611
|
+
c.scopes,
|
|
612
|
+
c.expires_at,
|
|
613
|
+
c.last_used_at,
|
|
614
|
+
c.created_by,
|
|
615
|
+
c.created_at,
|
|
616
|
+
c.revoked_at,
|
|
617
|
+
c.revoked_by,
|
|
618
|
+
sa.name AS service_account_name,
|
|
619
|
+
sa.kind AS service_account_kind,
|
|
620
|
+
sa.status AS service_account_status,
|
|
621
|
+
sa.delegated_user_id,
|
|
622
|
+
sa.app_id,
|
|
623
|
+
sa.resource_type,
|
|
624
|
+
sa.resource_id,
|
|
625
|
+
sa.created_by AS service_account_created_by,
|
|
626
|
+
sa.created_at AS service_account_created_at
|
|
627
|
+
FROM auth.service_account_credentials c
|
|
628
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
629
|
+
WHERE c.token_prefix = ${tokenPrefix}
|
|
630
|
+
AND c.status = 'active'
|
|
631
|
+
AND sa.status = 'active'
|
|
632
|
+
AND (c.expires_at IS NULL OR c.expires_at > now())
|
|
633
|
+
LIMIT 1
|
|
634
|
+
`;
|
|
635
|
+
return row ?? null;
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
export const authenticateApiToken = async (token: string): Promise<AuthenticatedServiceAccountCredential | null> => {
|
|
639
|
+
const parsed = parseToken(token);
|
|
640
|
+
if (!parsed) return null;
|
|
641
|
+
|
|
642
|
+
const row = await findActiveByTokenPrefix(parsed.tokenPrefix);
|
|
643
|
+
if (!row) {
|
|
644
|
+
await audit.record({
|
|
645
|
+
action: "service_account_credential.authenticate",
|
|
646
|
+
outcome: "denied",
|
|
647
|
+
reason: "API key not found, inactive, or expired",
|
|
648
|
+
metadata: { tokenPrefix: parsed.tokenPrefix },
|
|
649
|
+
});
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const valid = await Bun.password.verify(parsed.secret, row.secret_hash);
|
|
654
|
+
if (!valid) {
|
|
655
|
+
await audit.record({
|
|
656
|
+
action: "service_account_credential.authenticate",
|
|
657
|
+
outcome: "denied",
|
|
658
|
+
target: { type: "service_account_credential", id: row.id, label: row.name },
|
|
659
|
+
reason: "Invalid API key secret",
|
|
660
|
+
metadata: { tokenPrefix: parsed.tokenPrefix, serviceAccountId: row.service_account_id },
|
|
661
|
+
});
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const serviceAccount = mapServiceAccount(row);
|
|
666
|
+
const delegatedUser = serviceAccount.delegatedUserId ? await accounts.users.get({ id: serviceAccount.delegatedUserId }) : null;
|
|
667
|
+
if (serviceAccount.kind === "user_delegated" && !delegatedUser) {
|
|
668
|
+
await audit.record({
|
|
669
|
+
action: "service_account_credential.authenticate",
|
|
670
|
+
outcome: "denied",
|
|
671
|
+
target: { type: "service_account_credential", id: row.id, label: row.name },
|
|
672
|
+
reason: "Delegated user is missing",
|
|
673
|
+
metadata: { serviceAccountId: row.service_account_id },
|
|
674
|
+
});
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
await sql.begin(async (tx) => {
|
|
679
|
+
await tx`
|
|
680
|
+
UPDATE auth.service_account_credentials
|
|
681
|
+
SET last_used_at = now()
|
|
682
|
+
WHERE id = ${row.id}::uuid
|
|
683
|
+
`;
|
|
684
|
+
|
|
685
|
+
await audit.record({
|
|
686
|
+
action: "service_account_credential.authenticate",
|
|
687
|
+
outcome: "allowed",
|
|
688
|
+
actor: delegatedUser ? actorForUser(delegatedUser) : null,
|
|
689
|
+
target: { type: "service_account_credential", id: row.id, label: row.name },
|
|
690
|
+
metadata: {
|
|
691
|
+
serviceAccountId: row.service_account_id,
|
|
692
|
+
serviceAccountKind: serviceAccount.kind,
|
|
693
|
+
},
|
|
694
|
+
}, tx);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
return {
|
|
698
|
+
credential: mapCredential({ ...row, last_used_at: new Date() }),
|
|
699
|
+
serviceAccount,
|
|
700
|
+
delegatedUser,
|
|
701
|
+
};
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
export const serviceAccountCredentials = {
|
|
705
|
+
isApiToken,
|
|
706
|
+
getOrCreateUserDelegatedServiceAccount,
|
|
707
|
+
createApiToken,
|
|
708
|
+
createUserApiToken,
|
|
709
|
+
createResourceApiToken,
|
|
710
|
+
listForDelegatedUser,
|
|
711
|
+
listOverview,
|
|
712
|
+
revokeForDelegatedUser,
|
|
713
|
+
revoke,
|
|
714
|
+
authenticateApiToken,
|
|
715
|
+
};
|