@anthusai/papyrus 0.0.0 → 1.0.0-next.2
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/LICENSE +21 -0
- package/amplify/auth/resource.js +61 -0
- package/amplify/auth/resource.ts +65 -0
- package/amplify/data/resource.js +2037 -0
- package/amplify/data/resource.ts +2148 -0
- package/amplify/data/schema.js +1850 -0
- package/amplify/data/schema.ts +1953 -0
- package/amplify/functions/assignment-action/handler.ts +1416 -0
- package/amplify/functions/assignment-action/resource.js +8 -0
- package/amplify/functions/assignment-action/resource.ts +9 -0
- package/amplify/functions/category-action/handler.ts +2259 -0
- package/amplify/functions/category-action/resource.js +8 -0
- package/amplify/functions/category-action/resource.ts +9 -0
- package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
- package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
- package/amplify/functions/console-chat-responder/Dockerfile +19 -0
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
- package/amplify/functions/console-chat-responder/resource.js +153 -0
- package/amplify/functions/console-chat-responder/resource.ts +179 -0
- package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
- package/amplify/functions/email-submission-processor/handler.py +46 -0
- package/amplify/functions/email-submission-processor/requirements.txt +2 -0
- package/amplify/functions/email-submission-processor/resource.js +56 -0
- package/amplify/functions/email-submission-processor/resource.ts +69 -0
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
- package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
- package/amplify/functions/knowledge-query/handler.py +1 -0
- package/amplify/functions/knowledge-query/requirements.txt +3 -0
- package/amplify/functions/knowledge-query/resource.js +50 -0
- package/amplify/functions/knowledge-query/resource.ts +61 -0
- package/amplify/functions/manage-user-role/handler.ts +799 -0
- package/amplify/functions/manage-user-role/resource.js +8 -0
- package/amplify/functions/manage-user-role/resource.ts +9 -0
- package/amplify/functions/model-attachment-upload/handler.ts +387 -0
- package/amplify/functions/model-attachment-upload/resource.js +8 -0
- package/amplify/functions/model-attachment-upload/resource.ts +9 -0
- package/amplify/functions/newsroom-summary/handler.ts +321 -0
- package/amplify/functions/newsroom-summary/resource.js +8 -0
- package/amplify/functions/newsroom-summary/resource.ts +9 -0
- package/amplify/functions/procedure-action/handler.ts +621 -0
- package/amplify/functions/procedure-action/resource.js +8 -0
- package/amplify/functions/procedure-action/resource.ts +9 -0
- package/amplify/functions/reader-settings/handler.ts +322 -0
- package/amplify/functions/reader-settings/resource.js +8 -0
- package/amplify/functions/reader-settings/resource.ts +9 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
- package/amplify/functions/ses-inbound-receive/resource.js +8 -0
- package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
- package/amplify/functions/shared/amplify-secrets.ts +65 -0
- package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
- package/amplify/functions/shared/email-mime-intake.ts +196 -0
- package/amplify/functions/shared/email-submission.ts +415 -0
- package/amplify/functions/shared/inbound-email-intake.ts +37 -0
- package/amplify/functions/shared/lambda-data-client.ts +21 -0
- package/amplify/functions/shared/model-payloads.ts +186 -0
- package/amplify/functions/shared/python-bundle.js +115 -0
- package/amplify/functions/shared/python-bundle.ts +141 -0
- package/amplify/functions/shared/slack-events.ts +111 -0
- package/amplify/functions/slack-delivery/handler.py +112 -0
- package/amplify/functions/slack-delivery/requirements.txt +2 -0
- package/amplify/functions/slack-delivery/resource.js +38 -0
- package/amplify/functions/slack-delivery/resource.ts +51 -0
- package/amplify/functions/slack-events/handler.ts +155 -0
- package/amplify/functions/slack-events/resource.js +11 -0
- package/amplify/functions/slack-events/resource.ts +12 -0
- package/amplify/inbound-email/stack.js +46 -0
- package/amplify/inbound-email/stack.ts +61 -0
- package/amplify/package.json +3 -0
- package/amplify/site-backend-config.js +1 -0
- package/amplify/site-backend-config.ts +30 -0
- package/amplify/site-backend.js +587 -0
- package/amplify/site-backend.ts +704 -0
- package/amplify/storage/resource.js +18 -0
- package/amplify/storage/resource.ts +19 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
- package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
- package/app/[year]/[month]/[day]/page.tsx +22 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
- package/app/api/archive/editions/route.ts +18 -0
- package/app/api/media/[...path]/route.ts +35 -0
- package/app/api/revalidate/route.ts +94 -0
- package/app/archive/page.tsx +24 -0
- package/app/articles/[slug]/page.tsx +35 -0
- package/app/globals.css +10329 -0
- package/app/layout.tsx +146 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
- package/app/newsroom/[section]/page.tsx +47 -0
- package/app/newsroom/forum/[threadId]/page.tsx +35 -0
- package/app/newsroom/insights/[threadId]/page.tsx +22 -0
- package/app/newsroom/insights/page.tsx +14 -0
- package/app/newsroom/layout.tsx +12 -0
- package/app/newsroom/page.tsx +31 -0
- package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
- package/app/page.tsx +272 -0
- package/app/settings/page.tsx +14 -0
- package/app/tailwind.css +65 -0
- package/bin/papyrus-app.mjs +10 -0
- package/bin/papyrus-infra.mjs +39 -0
- package/components/ai-elements/conversation.tsx +168 -0
- package/components/ai-elements/message.tsx +360 -0
- package/components/ai-elements/model-selector.tsx +30 -0
- package/components/ai-elements/prompt-input.tsx +1461 -0
- package/components/ai-elements/shimmer.tsx +76 -0
- package/components/ai-elements/suggestion.tsx +54 -0
- package/components/ai-elements/tool.tsx +173 -0
- package/components/amplify-client-provider.tsx +140 -0
- package/components/archive-grid.tsx +89 -0
- package/components/archive-shell.tsx +41 -0
- package/components/article-video.tsx +47 -0
- package/components/blog-page-background.tsx +20 -0
- package/components/dev-sandbox-editor-sign-in.tsx +60 -0
- package/components/news-desk-client-provider.tsx +211 -0
- package/components/news-desk-page.tsx +119 -0
- package/components/news-desk-taxonomy-client.ts +3684 -0
- package/components/newspaper.tsx +1929 -0
- package/components/newsroom-assignments-view.tsx +664 -0
- package/components/newsroom-ops-overview.tsx +226 -0
- package/components/newsroom-ops-shell.tsx +319 -0
- package/components/newsroom-references-view.tsx +598 -0
- package/components/newsroom-topics-view.tsx +425 -0
- package/components/papyrus-console-shell.tsx +1912 -0
- package/components/pictogram-figure.tsx +65 -0
- package/components/presentation-footer.tsx +146 -0
- package/components/reader-auth-control.tsx +162 -0
- package/components/reader-auth-state.ts +122 -0
- package/components/reader-settings.ts +332 -0
- package/components/reader-theme-control.tsx +85 -0
- package/components/reference-source-preview.tsx +163 -0
- package/components/settings-page.tsx +239 -0
- package/components/topic-steering-workspace.tsx +18063 -0
- package/components/ui/badge.tsx +30 -0
- package/components/ui/button-group.tsx +87 -0
- package/components/ui/button.tsx +58 -0
- package/components/ui/card.tsx +32 -0
- package/components/ui/command.tsx +194 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/dropdown-menu.tsx +266 -0
- package/components/ui/hover-card.tsx +51 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/scroll-area.tsx +16 -0
- package/components/ui/select.tsx +199 -0
- package/components/ui/separator.tsx +23 -0
- package/components/ui/sheet.tsx +92 -0
- package/components/ui/spinner.tsx +10 -0
- package/components/ui/tabs.tsx +113 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/use-resolved-papyrus-theme.ts +47 -0
- package/infra/amplify-app-shell.js +109 -0
- package/infra/amplify-app-shell.ts +155 -0
- package/infra/index.js +12 -0
- package/lib/amplify-outputs-guard.ts +24 -0
- package/lib/amplify-outputs-path.ts +16 -0
- package/lib/amplify-server-runtime.ts +63 -0
- package/lib/archive-data.ts +30 -0
- package/lib/archive-types.ts +15 -0
- package/lib/articles.ts +320 -0
- package/lib/cached-content-repository.ts +89 -0
- package/lib/category-dashboard.ts +37 -0
- package/lib/category-repository.ts +1907 -0
- package/lib/console-chat-client.ts +1316 -0
- package/lib/console-web-context.ts +118 -0
- package/lib/content-load-timing.ts +23 -0
- package/lib/content-repository.ts +40 -0
- package/lib/content-types.ts +104 -0
- package/lib/define-site.js +3 -0
- package/lib/define-site.ts +21 -0
- package/lib/demo-amplify-outputs.ts +15 -0
- package/lib/dev-sandbox-editor-auth.ts +18 -0
- package/lib/doctrine.ts +128 -0
- package/lib/edition-routes.ts +155 -0
- package/lib/edition-sections.ts +111 -0
- package/lib/empty-edition-layout-plan.ts +109 -0
- package/lib/excerpts.ts +8 -0
- package/lib/graphql-content-repository.ts +986 -0
- package/lib/image-url.ts +10 -0
- package/lib/layout-plan.ts +981 -0
- package/lib/layout-scenarios.ts +564 -0
- package/lib/markus-body.ts +168 -0
- package/lib/markus-ir.ts +235 -0
- package/lib/markus-projection.ts +243 -0
- package/lib/markus-to-article.ts +93 -0
- package/lib/news-desk-session.ts +160 -0
- package/lib/newspaper-layout.ts +3459 -0
- package/lib/newsroom-assignments.ts +173 -0
- package/lib/newsroom-base-path.ts +48 -0
- package/lib/newsroom-card-layout.ts +102 -0
- package/lib/newsroom-category-drilldown.ts +133 -0
- package/lib/newsroom-demo-dashboard.ts +267 -0
- package/lib/newsroom-demo-profile.ts +46 -0
- package/lib/newsroom-forum-routes.ts +153 -0
- package/lib/newsroom-index-filters.ts +202 -0
- package/lib/newsroom-knowledge-query-request.ts +51 -0
- package/lib/newsroom-list-selection.ts +42 -0
- package/lib/newsroom-nav.ts +42 -0
- package/lib/newsroom-references.ts +116 -0
- package/lib/newsroom-sections.ts +52 -0
- package/lib/newsroom-topics.ts +118 -0
- package/lib/papyrus-object-kinds.ts +24 -0
- package/lib/papyrus-web-locations.ts +480 -0
- package/lib/pilobol-sample.ts +167 -0
- package/lib/presentation-footer.ts +92 -0
- package/lib/pretext-layout.ts +179 -0
- package/lib/public-placeholder-config.ts +102 -0
- package/lib/publication-items.ts +181 -0
- package/lib/reader-route-config.ts +1 -0
- package/lib/reader-static-params.ts +56 -0
- package/lib/reader-storage-url.ts +63 -0
- package/lib/reference-display.ts +74 -0
- package/lib/reference-policy.ts +97 -0
- package/lib/reference-source-preview.ts +175 -0
- package/lib/renderer-config.ts +50 -0
- package/lib/renderer.ts +87 -0
- package/lib/reporting-story-budget.ts +638 -0
- package/lib/semantic-graph.ts +554 -0
- package/lib/site-brand-tenant.ts +41 -0
- package/lib/site-brand.ts +179 -0
- package/lib/site-renderer.ts +17 -0
- package/lib/site-stack.ts +195 -0
- package/lib/themed-image.ts +30 -0
- package/lib/utils.ts +6 -0
- package/middleware.ts +78 -0
- package/package.json +296 -1
- package/publications/anth_us/brand.ts +68 -0
- package/publications/anth_us/theme.css +66 -0
- package/publications/papyrus/theme.css +11 -0
- package/publications/pilobol_us/brand.ts +37 -0
- package/publications/pilobol_us/theme.css +140 -0
- package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
- package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
- package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
- package/publications/threat_intelligence/brand.ts +40 -0
- package/publications/threat_intelligence/pictograms/art.tsx +608 -0
- package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
- package/publications/threat_intelligence/pictograms/registry.ts +30 -0
- package/publications/threat_intelligence/pictograms/system.tsx +305 -0
- package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
- package/publications/threat_intelligence/theme.css +1440 -0
- package/renderers/markus/index.ts +25 -0
- package/renderers/markus/stubs.tsx +33 -0
- package/renderers/pretext/article-page.tsx +174 -0
- package/renderers/pretext/index.ts +30 -0
- package/renderers/pretext/layouts.ts +16 -0
- package/renderers/pretext/presentation-shell.tsx +477 -0
- package/routes.manifest.json +182 -0
- package/scripts/fixtures/pilobol-sample.ast.json +234 -0
- package/src/backend.js +1 -0
- package/src/backend.ts +3 -0
- package/src/sync.mjs +87 -0
- package/src/with-papyrus.d.mts +2 -0
- package/src/with-papyrus.mjs +47 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
AdminAddUserToGroupCommand,
|
|
4
|
+
AdminListGroupsForUserCommand,
|
|
5
|
+
AdminRemoveUserFromGroupCommand,
|
|
6
|
+
CognitoIdentityProviderClient,
|
|
7
|
+
ListUsersCommand,
|
|
8
|
+
} from "@aws-sdk/client-cognito-identity-provider";
|
|
9
|
+
import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtime";
|
|
10
|
+
import { Amplify } from "aws-amplify";
|
|
11
|
+
import { generateClient } from "aws-amplify/data";
|
|
12
|
+
import type { Schema } from "../../data/resource";
|
|
13
|
+
|
|
14
|
+
const MANAGED_ROLES = new Set(["admin", "editor", "curator"]);
|
|
15
|
+
const ACTIVE_IDENTITY_STATUS = "active";
|
|
16
|
+
const ACTIVE_ROLE_STATUS = "active";
|
|
17
|
+
const REVOKED_ROLE_STATUS = "revoked";
|
|
18
|
+
|
|
19
|
+
type GrantHandler = Schema["grantUserRole"]["functionHandler"];
|
|
20
|
+
type RevokeHandler = Schema["revokeUserRole"]["functionHandler"];
|
|
21
|
+
type DirectoryHandler = Schema["listUserDirectory"]["functionHandler"];
|
|
22
|
+
type MergeHandler = Schema["mergeUserProfiles"]["functionHandler"];
|
|
23
|
+
type RoleFunctionEvent = (
|
|
24
|
+
| Parameters<GrantHandler>[0]
|
|
25
|
+
| Parameters<RevokeHandler>[0]
|
|
26
|
+
| Parameters<DirectoryHandler>[0]
|
|
27
|
+
| Parameters<MergeHandler>[0]
|
|
28
|
+
) & {
|
|
29
|
+
fieldName?: string | null;
|
|
30
|
+
info?: { fieldName?: string | null } | null;
|
|
31
|
+
};
|
|
32
|
+
type DataClient = ReturnType<typeof generateClient<Schema>>;
|
|
33
|
+
type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
|
|
34
|
+
type DataClientResult<T = unknown> = {
|
|
35
|
+
data?: T | null;
|
|
36
|
+
errors?: DataClientErrors;
|
|
37
|
+
nextToken?: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type UserProfileRecord = {
|
|
41
|
+
id: string;
|
|
42
|
+
email?: string | null;
|
|
43
|
+
displayName?: string | null;
|
|
44
|
+
status?: string | null;
|
|
45
|
+
mergedIntoProfileId?: string | null;
|
|
46
|
+
mergedAt?: string | null;
|
|
47
|
+
mergedBy?: string | null;
|
|
48
|
+
mergeReason?: string | null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type UserIdentityRecord = {
|
|
52
|
+
id: string;
|
|
53
|
+
userProfileId: string;
|
|
54
|
+
cognitoSub: string;
|
|
55
|
+
provider?: string | null;
|
|
56
|
+
email?: string | null;
|
|
57
|
+
status: string;
|
|
58
|
+
linkedAt: string;
|
|
59
|
+
lastSeenAt?: string | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type UserRoleAssignmentRecord = {
|
|
63
|
+
id: string;
|
|
64
|
+
userProfileId: string;
|
|
65
|
+
userSub?: string | null;
|
|
66
|
+
email?: string | null;
|
|
67
|
+
role: string;
|
|
68
|
+
status: string;
|
|
69
|
+
grantedBy: string;
|
|
70
|
+
grantedAt: string;
|
|
71
|
+
revokedAt?: string | null;
|
|
72
|
+
notes?: string | null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type CognitoDirectoryUser = {
|
|
76
|
+
userSub: string;
|
|
77
|
+
username: string;
|
|
78
|
+
email?: string | null;
|
|
79
|
+
displayName?: string | null;
|
|
80
|
+
provider?: string | null;
|
|
81
|
+
enabled?: boolean | null;
|
|
82
|
+
cognitoStatus?: string | null;
|
|
83
|
+
activeRoles: string[];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const cognito = new CognitoIdentityProviderClient();
|
|
87
|
+
let clientPromise: Promise<DataClient> | null = null;
|
|
88
|
+
|
|
89
|
+
export const handler = async (event: RoleFunctionEvent) => {
|
|
90
|
+
const operation = getOperationName(event);
|
|
91
|
+
if (operation === "listUserDirectory") return listUserDirectory();
|
|
92
|
+
if (operation === "mergeUserProfiles") return mergeUserProfiles(event as Parameters<MergeHandler>[0]);
|
|
93
|
+
if (operation === "grantUserRole" || operation === "revokeUserRole") {
|
|
94
|
+
return updateUserRole(event as Parameters<GrantHandler>[0] | Parameters<RevokeHandler>[0], operation);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Unsupported role operation ${operation}.`);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function getOperationName(event: RoleFunctionEvent): string {
|
|
100
|
+
const operation = normalizeOptionalString(event.info?.fieldName) ?? normalizeOptionalString(event.fieldName);
|
|
101
|
+
if (!operation) throw new Error("Role operation event did not include a field name.");
|
|
102
|
+
return operation;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function listUserDirectory() {
|
|
106
|
+
const { entries, profiles, identities, cognitoUsers } = await loadUserDirectorySnapshot();
|
|
107
|
+
return {
|
|
108
|
+
entries,
|
|
109
|
+
profileCount: profiles.length,
|
|
110
|
+
identityCount: identities.length,
|
|
111
|
+
cognitoUserCount: cognitoUsers.length,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function loadUserDirectorySnapshot() {
|
|
116
|
+
const client = await getDataClient();
|
|
117
|
+
const userPoolId = getUserPoolId();
|
|
118
|
+
const [profiles, identities, assignments, cognitoUsers] = await Promise.all([
|
|
119
|
+
listDataRecords<UserProfileRecord>(client.models.UserProfile),
|
|
120
|
+
listDataRecords<UserIdentityRecord>(client.models.UserIdentity),
|
|
121
|
+
listDataRecords<UserRoleAssignmentRecord>(client.models.UserRoleAssignment),
|
|
122
|
+
listCognitoUsers(userPoolId),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
const profilesById = new Map(profiles.map((profile) => [profile.id, profile]));
|
|
126
|
+
const activeProfiles = profiles.filter((profile) => (profile.status ?? ACTIVE_IDENTITY_STATUS) !== "merged");
|
|
127
|
+
const identitiesBySub = new Map(identities.map((identity) => [identity.cognitoSub, identity]));
|
|
128
|
+
const identitiesByProfile = groupBy(identities, (identity) => identity.userProfileId);
|
|
129
|
+
const assignmentsByProfile = groupBy(assignments, (assignment) => assignment.userProfileId);
|
|
130
|
+
const cognitoUsersBySub = new Map(cognitoUsers.map((user) => [user.userSub, user]));
|
|
131
|
+
const entries = [];
|
|
132
|
+
const seenCognitoSubs = new Set<string>();
|
|
133
|
+
|
|
134
|
+
for (const profile of activeProfiles) {
|
|
135
|
+
const profileIdentities = identitiesByProfile.get(profile.id) ?? [];
|
|
136
|
+
const roleAssignments = assignmentsByProfile.get(profile.id) ?? [];
|
|
137
|
+
const profileCognitoUsers = compactStrings(profileIdentities.map((identity) => identity.cognitoSub))
|
|
138
|
+
.map((sub) => cognitoUsersBySub.get(sub))
|
|
139
|
+
.filter(isDefined);
|
|
140
|
+
for (const user of profileCognitoUsers) seenCognitoSubs.add(user.userSub);
|
|
141
|
+
const primaryCognito = profileCognitoUsers[0] ?? null;
|
|
142
|
+
const primaryIdentity = profileIdentities[0] ?? null;
|
|
143
|
+
entries.push({
|
|
144
|
+
userProfileId: profile.id,
|
|
145
|
+
userSub: primaryCognito?.userSub ?? primaryIdentity?.cognitoSub ?? null,
|
|
146
|
+
username: primaryCognito?.username ?? null,
|
|
147
|
+
email: profile.email ?? primaryIdentity?.email ?? primaryCognito?.email ?? null,
|
|
148
|
+
displayName: profile.displayName ?? profile.email ?? primaryCognito?.displayName ?? primaryCognito?.email ?? profile.id,
|
|
149
|
+
provider: primaryIdentity?.provider ?? primaryCognito?.provider ?? null,
|
|
150
|
+
enabled: profileCognitoUsers.some((user) => user.enabled === true) || null,
|
|
151
|
+
cognitoStatus: compactStrings(profileCognitoUsers.map((user) => user.cognitoStatus)).join(" / ") || null,
|
|
152
|
+
profileStatus: profile.status ?? ACTIVE_IDENTITY_STATUS,
|
|
153
|
+
mergedIntoProfileId: profile.mergedIntoProfileId ?? null,
|
|
154
|
+
identityStatus: primaryIdentity?.status ?? null,
|
|
155
|
+
activeRoles: mergeRoles(activeAssignmentRoles(roleAssignments), ...profileCognitoUsers.map((user) => user.activeRoles)),
|
|
156
|
+
identities: profileIdentities.map(identitySnapshot),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const cognitoUser of cognitoUsers) {
|
|
161
|
+
if (seenCognitoSubs.has(cognitoUser.userSub)) continue;
|
|
162
|
+
const identity = identitiesBySub.get(cognitoUser.userSub);
|
|
163
|
+
const profile = identity ? profilesById.get(identity.userProfileId) : null;
|
|
164
|
+
const profileIdentities = profile?.id ? identitiesByProfile.get(profile.id) ?? [] : identity ? [identity] : [];
|
|
165
|
+
const roleAssignments = profile?.id ? assignmentsByProfile.get(profile.id) ?? [] : [];
|
|
166
|
+
entries.push({
|
|
167
|
+
userProfileId: profile?.id ?? identity?.userProfileId ?? null,
|
|
168
|
+
userSub: cognitoUser.userSub,
|
|
169
|
+
username: cognitoUser.username,
|
|
170
|
+
email: profile?.email ?? identity?.email ?? cognitoUser.email ?? null,
|
|
171
|
+
displayName: profile?.displayName ?? cognitoUser.displayName ?? cognitoUser.email ?? cognitoUser.username,
|
|
172
|
+
provider: identity?.provider ?? cognitoUser.provider ?? null,
|
|
173
|
+
enabled: cognitoUser.enabled ?? null,
|
|
174
|
+
cognitoStatus: cognitoUser.cognitoStatus ?? null,
|
|
175
|
+
profileStatus: profile?.status ?? null,
|
|
176
|
+
mergedIntoProfileId: profile?.mergedIntoProfileId ?? null,
|
|
177
|
+
identityStatus: identity?.status ?? null,
|
|
178
|
+
activeRoles: mergeRoles(cognitoUser.activeRoles, activeAssignmentRoles(roleAssignments)),
|
|
179
|
+
identities: profileIdentities.map(identitySnapshot),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
entries.sort((left, right) => String(left.displayName ?? left.email ?? left.userSub ?? "").localeCompare(String(right.displayName ?? right.email ?? right.userSub ?? "")));
|
|
184
|
+
return { entries, profiles, identities, assignments, cognitoUsers };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function updateUserRole(
|
|
188
|
+
event: Parameters<GrantHandler>[0] | Parameters<RevokeHandler>[0],
|
|
189
|
+
operation: "grantUserRole" | "revokeUserRole",
|
|
190
|
+
) {
|
|
191
|
+
const client = await getDataClient();
|
|
192
|
+
const userPoolId = getUserPoolId();
|
|
193
|
+
const role = normalizeRole(event.arguments.role);
|
|
194
|
+
const now = new Date().toISOString();
|
|
195
|
+
const actor = getIdentityLabel(event) ?? "Papyrus admin";
|
|
196
|
+
const target = await resolveRoleTarget(client, event.arguments);
|
|
197
|
+
const cognitoTargets = await Promise.all(target.cognitoSubs.map((sub) => findCognitoUserBySub(userPoolId, sub)));
|
|
198
|
+
|
|
199
|
+
for (const user of cognitoTargets) {
|
|
200
|
+
if (operation === "grantUserRole") {
|
|
201
|
+
await cognito.send(
|
|
202
|
+
new AdminAddUserToGroupCommand({
|
|
203
|
+
UserPoolId: userPoolId,
|
|
204
|
+
Username: user.username,
|
|
205
|
+
GroupName: role,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
await cognito.send(
|
|
210
|
+
new AdminRemoveUserFromGroupCommand({
|
|
211
|
+
UserPoolId: userPoolId,
|
|
212
|
+
Username: user.username,
|
|
213
|
+
GroupName: role,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (target.profileId) {
|
|
220
|
+
await upsertRoleAssignment(client, {
|
|
221
|
+
operation,
|
|
222
|
+
userProfileId: target.profileId,
|
|
223
|
+
userSub: target.cognitoSubs[0] ?? null,
|
|
224
|
+
email: target.email ?? cognitoTargets[0]?.email ?? null,
|
|
225
|
+
role,
|
|
226
|
+
actor,
|
|
227
|
+
now,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const refreshedGroups = await Promise.all(
|
|
232
|
+
cognitoTargets.map((user) => listGroupsForUsername(userPoolId, user.username)),
|
|
233
|
+
);
|
|
234
|
+
const activeRoles = mergeRoles(...refreshedGroups);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
userProfileId: target.profileId ?? null,
|
|
239
|
+
userSub: target.cognitoSubs[0] ?? null,
|
|
240
|
+
username: cognitoTargets[0]?.username ?? null,
|
|
241
|
+
role,
|
|
242
|
+
userSubs: target.cognitoSubs,
|
|
243
|
+
usernames: cognitoTargets.map((user) => user.username),
|
|
244
|
+
activeRoles,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function mergeUserProfiles(event: Parameters<MergeHandler>[0]) {
|
|
249
|
+
const client = await getDataClient();
|
|
250
|
+
const userPoolId = getUserPoolId();
|
|
251
|
+
const now = new Date().toISOString();
|
|
252
|
+
const actor = getIdentityLabel(event) ?? "Papyrus admin";
|
|
253
|
+
const requestedTargetProfileId = normalizeOptionalString(event.arguments.targetUserProfileId);
|
|
254
|
+
const requestedTargetSub = normalizeOptionalString((event.arguments as { targetUserSub?: string | null }).targetUserSub);
|
|
255
|
+
const requestedSourceProfileId = normalizeOptionalString(event.arguments.sourceUserProfileId);
|
|
256
|
+
const requestedSourceSub = normalizeOptionalString(event.arguments.sourceUserSub);
|
|
257
|
+
const reason = normalizeOptionalString(event.arguments.reason);
|
|
258
|
+
if (!requestedTargetProfileId && !requestedTargetSub) {
|
|
259
|
+
throw new Error("targetUserProfileId or targetUserSub is required.");
|
|
260
|
+
}
|
|
261
|
+
if (!requestedSourceProfileId && !requestedSourceSub) {
|
|
262
|
+
throw new Error("sourceUserProfileId or sourceUserSub is required.");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const [profiles, identities, assignments] = await Promise.all([
|
|
266
|
+
listDataRecords<UserProfileRecord>(client.models.UserProfile),
|
|
267
|
+
listDataRecords<UserIdentityRecord>(client.models.UserIdentity),
|
|
268
|
+
listDataRecords<UserRoleAssignmentRecord>(client.models.UserRoleAssignment),
|
|
269
|
+
]);
|
|
270
|
+
const profilesById = new Map(profiles.map((profile) => [profile.id, profile]));
|
|
271
|
+
const targetProfileId = await ensureMergeTargetProfile(client, {
|
|
272
|
+
userPoolId,
|
|
273
|
+
now,
|
|
274
|
+
requestedTargetProfileId,
|
|
275
|
+
requestedTargetSub,
|
|
276
|
+
profilesById,
|
|
277
|
+
identities,
|
|
278
|
+
});
|
|
279
|
+
const targetProfile = profilesById.get(targetProfileId)
|
|
280
|
+
?? (await getDataRecord<UserProfileRecord>(client.models.UserProfile, targetProfileId, "Target UserProfile"));
|
|
281
|
+
if (!targetProfile) throw new Error(`Target profile ${targetProfileId} was not found.`);
|
|
282
|
+
if ((targetProfile.status ?? ACTIVE_IDENTITY_STATUS) === "merged") {
|
|
283
|
+
throw new Error(`Target profile ${targetProfileId} is already merged.`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const existingSourceIdentity = requestedSourceSub ? identities.find((identity) => identity.cognitoSub === requestedSourceSub) : undefined;
|
|
287
|
+
const sourceProfileId = requestedSourceProfileId ?? existingSourceIdentity?.userProfileId ?? null;
|
|
288
|
+
if (sourceProfileId && sourceProfileId === targetProfileId) {
|
|
289
|
+
throw new Error("Source and target profiles must be different.");
|
|
290
|
+
}
|
|
291
|
+
const sourceProfile = sourceProfileId ? profilesById.get(sourceProfileId) : null;
|
|
292
|
+
if (sourceProfileId && !sourceProfile) throw new Error(`Source profile ${sourceProfileId} was not found.`);
|
|
293
|
+
|
|
294
|
+
const sourceIdentities = sourceProfileId
|
|
295
|
+
? identities.filter((identity) => identity.userProfileId === sourceProfileId)
|
|
296
|
+
: [];
|
|
297
|
+
const identitiesToMove = uniqueBy(
|
|
298
|
+
[...sourceIdentities, ...(existingSourceIdentity ? [existingSourceIdentity] : [])],
|
|
299
|
+
(identity) => identity.id,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (!identitiesToMove.length && requestedSourceSub) {
|
|
303
|
+
const cognitoUser = await findCognitoUserBySub(userPoolId, requestedSourceSub);
|
|
304
|
+
await requireDataResult(
|
|
305
|
+
client.models.UserIdentity.create({
|
|
306
|
+
id: `user-identity-${safeId(requestedSourceSub)}-${randomUUID().slice(0, 8)}`,
|
|
307
|
+
userProfileId: targetProfileId,
|
|
308
|
+
cognitoSub: requestedSourceSub,
|
|
309
|
+
provider: cognitoUser.provider ?? null,
|
|
310
|
+
email: cognitoUser.email ?? null,
|
|
311
|
+
status: ACTIVE_IDENTITY_STATUS,
|
|
312
|
+
linkedAt: now,
|
|
313
|
+
lastSeenAt: now,
|
|
314
|
+
}),
|
|
315
|
+
"create merged UserIdentity",
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
await Promise.all(identitiesToMove.map((identity) =>
|
|
319
|
+
requireDataResult(
|
|
320
|
+
client.models.UserIdentity.update({
|
|
321
|
+
id: identity.id,
|
|
322
|
+
userProfileId: targetProfileId,
|
|
323
|
+
status: identity.status || ACTIVE_IDENTITY_STATUS,
|
|
324
|
+
lastSeenAt: now,
|
|
325
|
+
}),
|
|
326
|
+
"move UserIdentity",
|
|
327
|
+
),
|
|
328
|
+
));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const targetAssignments = assignments.filter((assignment) => assignment.userProfileId === targetProfileId);
|
|
332
|
+
const sourceAssignments = sourceProfileId ? assignments.filter((assignment) => assignment.userProfileId === sourceProfileId) : [];
|
|
333
|
+
const sourceActiveRoles = activeAssignmentRoles(sourceAssignments);
|
|
334
|
+
const targetActiveRoles = activeAssignmentRoles(targetAssignments);
|
|
335
|
+
const movedCognitoSubs = compactStrings([
|
|
336
|
+
...identitiesToMove.map((identity) => identity.cognitoSub),
|
|
337
|
+
requestedSourceSub,
|
|
338
|
+
...identities.filter((identity) => identity.userProfileId === targetProfileId).map((identity) => identity.cognitoSub),
|
|
339
|
+
]);
|
|
340
|
+
const movedCognitoUsers = await Promise.all(movedCognitoSubs.map((sub) => findCognitoUserBySub(userPoolId, sub)));
|
|
341
|
+
const targetRoleUnion = mergeRoles(sourceActiveRoles, targetActiveRoles, ...movedCognitoUsers.map((user) => user.activeRoles));
|
|
342
|
+
const primaryTargetSub = movedCognitoSubs[0] ?? null;
|
|
343
|
+
const primaryTargetEmail = movedCognitoUsers[0]?.email ?? null;
|
|
344
|
+
|
|
345
|
+
await Promise.all(targetRoleUnion.map((role) =>
|
|
346
|
+
upsertRoleAssignment(client, {
|
|
347
|
+
operation: "grantUserRole",
|
|
348
|
+
userProfileId: targetProfileId,
|
|
349
|
+
userSub: primaryTargetSub,
|
|
350
|
+
email: primaryTargetEmail,
|
|
351
|
+
role,
|
|
352
|
+
actor,
|
|
353
|
+
now,
|
|
354
|
+
}),
|
|
355
|
+
));
|
|
356
|
+
|
|
357
|
+
await Promise.all(sourceAssignments.filter((assignment) => assignment.status === ACTIVE_ROLE_STATUS).map((assignment) =>
|
|
358
|
+
requireDataResult(
|
|
359
|
+
client.models.UserRoleAssignment.update({
|
|
360
|
+
id: assignment.id,
|
|
361
|
+
status: REVOKED_ROLE_STATUS,
|
|
362
|
+
revokedAt: now,
|
|
363
|
+
notes: `Merged into ${targetProfileId}${assignment.notes ? `; ${assignment.notes}` : ""}`,
|
|
364
|
+
}),
|
|
365
|
+
"archive source UserRoleAssignment",
|
|
366
|
+
),
|
|
367
|
+
));
|
|
368
|
+
|
|
369
|
+
if (sourceProfile) {
|
|
370
|
+
await requireDataResult(
|
|
371
|
+
client.models.UserProfile.update({
|
|
372
|
+
id: sourceProfile.id,
|
|
373
|
+
status: "merged",
|
|
374
|
+
mergedIntoProfileId: targetProfileId,
|
|
375
|
+
mergedAt: now,
|
|
376
|
+
mergedBy: actor,
|
|
377
|
+
mergeReason: reason,
|
|
378
|
+
updatedAt: now,
|
|
379
|
+
}),
|
|
380
|
+
"archive source UserProfile",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
await mirrorRolesToCognitoUsers(userPoolId, movedCognitoUsers, targetRoleUnion);
|
|
385
|
+
|
|
386
|
+
const snapshot = await loadUserDirectorySnapshot();
|
|
387
|
+
const targetEntry = snapshot.entries.find((entry) => entry.userProfileId === targetProfileId);
|
|
388
|
+
if (!targetEntry) throw new Error(`Merged target profile ${targetProfileId} was not returned by the directory.`);
|
|
389
|
+
return targetEntry;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function ensureMergeTargetProfile(
|
|
393
|
+
client: DataClient,
|
|
394
|
+
input: {
|
|
395
|
+
userPoolId: string;
|
|
396
|
+
now: string;
|
|
397
|
+
requestedTargetProfileId: string | null;
|
|
398
|
+
requestedTargetSub: string | null;
|
|
399
|
+
profilesById: Map<string, UserProfileRecord>;
|
|
400
|
+
identities: UserIdentityRecord[];
|
|
401
|
+
},
|
|
402
|
+
): Promise<string> {
|
|
403
|
+
if (input.requestedTargetProfileId) {
|
|
404
|
+
if (!input.profilesById.has(input.requestedTargetProfileId)) {
|
|
405
|
+
throw new Error(`Target profile ${input.requestedTargetProfileId} was not found.`);
|
|
406
|
+
}
|
|
407
|
+
return input.requestedTargetProfileId;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const targetSub = input.requestedTargetSub;
|
|
411
|
+
if (!targetSub) throw new Error("targetUserProfileId or targetUserSub is required.");
|
|
412
|
+
const existingIdentity = input.identities.find((identity) => identity.cognitoSub === targetSub);
|
|
413
|
+
if (existingIdentity) {
|
|
414
|
+
if (!input.profilesById.has(existingIdentity.userProfileId)) {
|
|
415
|
+
throw new Error(`Target profile ${existingIdentity.userProfileId} was not found.`);
|
|
416
|
+
}
|
|
417
|
+
return existingIdentity.userProfileId;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const cognitoUser = await findCognitoUserBySub(input.userPoolId, targetSub);
|
|
421
|
+
const profileId = `user-profile-${safeId(targetSub)}`;
|
|
422
|
+
if (!input.profilesById.has(profileId)) {
|
|
423
|
+
await requireDataResult(
|
|
424
|
+
client.models.UserProfile.create({
|
|
425
|
+
id: profileId,
|
|
426
|
+
email: cognitoUser.email ?? null,
|
|
427
|
+
displayName: cognitoUser.displayName ?? cognitoUser.email ?? cognitoUser.username,
|
|
428
|
+
status: ACTIVE_IDENTITY_STATUS,
|
|
429
|
+
createdAt: input.now,
|
|
430
|
+
updatedAt: input.now,
|
|
431
|
+
}),
|
|
432
|
+
"create target UserProfile",
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
await requireDataResult(
|
|
436
|
+
client.models.UserIdentity.create({
|
|
437
|
+
id: `user-identity-${safeId(targetSub)}-${randomUUID().slice(0, 8)}`,
|
|
438
|
+
userProfileId: profileId,
|
|
439
|
+
cognitoSub: targetSub,
|
|
440
|
+
provider: cognitoUser.provider ?? null,
|
|
441
|
+
email: cognitoUser.email ?? null,
|
|
442
|
+
status: ACTIVE_IDENTITY_STATUS,
|
|
443
|
+
linkedAt: input.now,
|
|
444
|
+
lastSeenAt: input.now,
|
|
445
|
+
}),
|
|
446
|
+
"create target UserIdentity",
|
|
447
|
+
);
|
|
448
|
+
return profileId;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function resolveRoleTarget(
|
|
452
|
+
client: DataClient,
|
|
453
|
+
args: { userProfileId?: string | null; userSub?: string | null; cognitoSubs?: Array<string | null> | null },
|
|
454
|
+
): Promise<{ profileId: string | null; cognitoSubs: string[]; email?: string | null }> {
|
|
455
|
+
const requestedProfileId = normalizeOptionalString(args.userProfileId);
|
|
456
|
+
const requestedSubs = compactStrings([...(args.cognitoSubs ?? []), args.userSub]);
|
|
457
|
+
const identities = await listDataRecords<UserIdentityRecord>(client.models.UserIdentity);
|
|
458
|
+
|
|
459
|
+
if (requestedProfileId) {
|
|
460
|
+
const profileIdentities = identities.filter((identity) => (
|
|
461
|
+
identity.userProfileId === requestedProfileId && identity.status === ACTIVE_IDENTITY_STATUS
|
|
462
|
+
));
|
|
463
|
+
const subs = compactStrings([...requestedSubs, ...profileIdentities.map((identity) => identity.cognitoSub)]);
|
|
464
|
+
return {
|
|
465
|
+
profileId: requestedProfileId,
|
|
466
|
+
cognitoSubs: uniqueStrings(subs),
|
|
467
|
+
email: profileIdentities[0]?.email ?? null,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const firstRequestedSub = requestedSubs[0];
|
|
472
|
+
if (!firstRequestedSub) throw new Error("userProfileId or userSub is required.");
|
|
473
|
+
const existingIdentity = identities.find((identity) => identity.cognitoSub === firstRequestedSub);
|
|
474
|
+
if (existingIdentity) {
|
|
475
|
+
const profileIdentities = identities.filter((identity) => (
|
|
476
|
+
identity.userProfileId === existingIdentity.userProfileId && identity.status === ACTIVE_IDENTITY_STATUS
|
|
477
|
+
));
|
|
478
|
+
return {
|
|
479
|
+
profileId: existingIdentity.userProfileId,
|
|
480
|
+
cognitoSubs: uniqueStrings([...requestedSubs, ...profileIdentities.map((identity) => identity.cognitoSub)]),
|
|
481
|
+
email: existingIdentity.email ?? null,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const cognitoUser = await findCognitoUserBySub(getUserPoolId(), firstRequestedSub);
|
|
486
|
+
const profileId = `user-profile-${safeId(firstRequestedSub)}`;
|
|
487
|
+
const linkedAt = new Date().toISOString();
|
|
488
|
+
await requireDataResult(
|
|
489
|
+
client.models.UserProfile.create({
|
|
490
|
+
id: profileId,
|
|
491
|
+
email: cognitoUser.email ?? null,
|
|
492
|
+
displayName: cognitoUser.displayName ?? cognitoUser.email ?? cognitoUser.username,
|
|
493
|
+
createdAt: linkedAt,
|
|
494
|
+
updatedAt: linkedAt,
|
|
495
|
+
}),
|
|
496
|
+
"create UserProfile",
|
|
497
|
+
);
|
|
498
|
+
await requireDataResult(
|
|
499
|
+
client.models.UserIdentity.create({
|
|
500
|
+
id: `user-identity-${safeId(firstRequestedSub)}-${randomUUID().slice(0, 8)}`,
|
|
501
|
+
userProfileId: profileId,
|
|
502
|
+
cognitoSub: firstRequestedSub,
|
|
503
|
+
provider: cognitoUser.provider ?? null,
|
|
504
|
+
email: cognitoUser.email ?? null,
|
|
505
|
+
status: ACTIVE_IDENTITY_STATUS,
|
|
506
|
+
linkedAt,
|
|
507
|
+
lastSeenAt: linkedAt,
|
|
508
|
+
}),
|
|
509
|
+
"create UserIdentity",
|
|
510
|
+
);
|
|
511
|
+
return {
|
|
512
|
+
profileId,
|
|
513
|
+
cognitoSubs: uniqueStrings(requestedSubs),
|
|
514
|
+
email: cognitoUser.email ?? null,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function upsertRoleAssignment(
|
|
519
|
+
client: DataClient,
|
|
520
|
+
input: {
|
|
521
|
+
operation: "grantUserRole" | "revokeUserRole";
|
|
522
|
+
userProfileId: string;
|
|
523
|
+
userSub: string | null;
|
|
524
|
+
email: string | null;
|
|
525
|
+
role: string;
|
|
526
|
+
actor: string;
|
|
527
|
+
now: string;
|
|
528
|
+
},
|
|
529
|
+
): Promise<void> {
|
|
530
|
+
const id = `user-role-${safeId(input.userProfileId)}-${safeId(input.role)}`;
|
|
531
|
+
const existing = await client.models.UserRoleAssignment.get({ id });
|
|
532
|
+
assertNoDataErrors(existing.errors, "get UserRoleAssignment");
|
|
533
|
+
|
|
534
|
+
if (existing.data) {
|
|
535
|
+
await requireDataResult(
|
|
536
|
+
client.models.UserRoleAssignment.update({
|
|
537
|
+
id,
|
|
538
|
+
userSub: input.userSub,
|
|
539
|
+
email: input.email,
|
|
540
|
+
status: input.operation === "grantUserRole" ? ACTIVE_ROLE_STATUS : REVOKED_ROLE_STATUS,
|
|
541
|
+
grantedBy: input.operation === "grantUserRole" ? input.actor : existing.data.grantedBy,
|
|
542
|
+
grantedAt: input.operation === "grantUserRole" ? input.now : existing.data.grantedAt,
|
|
543
|
+
revokedAt: input.operation === "revokeUserRole" ? input.now : null,
|
|
544
|
+
}),
|
|
545
|
+
"update UserRoleAssignment",
|
|
546
|
+
);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
await requireDataResult(
|
|
551
|
+
client.models.UserRoleAssignment.create({
|
|
552
|
+
id,
|
|
553
|
+
userProfileId: input.userProfileId,
|
|
554
|
+
userSub: input.userSub,
|
|
555
|
+
email: input.email,
|
|
556
|
+
role: input.role,
|
|
557
|
+
status: input.operation === "grantUserRole" ? ACTIVE_ROLE_STATUS : REVOKED_ROLE_STATUS,
|
|
558
|
+
grantedBy: input.actor,
|
|
559
|
+
grantedAt: input.now,
|
|
560
|
+
revokedAt: input.operation === "revokeUserRole" ? input.now : null,
|
|
561
|
+
notes: "Managed by Papyrus News Desk",
|
|
562
|
+
}),
|
|
563
|
+
"create UserRoleAssignment",
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function listCognitoUsers(userPoolId: string): Promise<CognitoDirectoryUser[]> {
|
|
568
|
+
const users: CognitoDirectoryUser[] = [];
|
|
569
|
+
let paginationToken: string | undefined;
|
|
570
|
+
do {
|
|
571
|
+
const page = await cognito.send(
|
|
572
|
+
new ListUsersCommand({
|
|
573
|
+
UserPoolId: userPoolId,
|
|
574
|
+
Limit: 60,
|
|
575
|
+
PaginationToken: paginationToken,
|
|
576
|
+
}),
|
|
577
|
+
);
|
|
578
|
+
for (const user of page.Users ?? []) {
|
|
579
|
+
const username = user.Username;
|
|
580
|
+
const sub = readCognitoAttribute(user.Attributes, "sub");
|
|
581
|
+
if (!username || !sub) continue;
|
|
582
|
+
users.push({
|
|
583
|
+
userSub: sub,
|
|
584
|
+
username,
|
|
585
|
+
email: readCognitoAttribute(user.Attributes, "email"),
|
|
586
|
+
displayName: readCognitoAttribute(user.Attributes, "name"),
|
|
587
|
+
provider: readCognitoAttribute(user.Attributes, "identities") ? "federated" : "cognito",
|
|
588
|
+
enabled: user.Enabled ?? null,
|
|
589
|
+
cognitoStatus: user.UserStatus ?? null,
|
|
590
|
+
activeRoles: await listGroupsForUsername(userPoolId, username),
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
paginationToken = page.PaginationToken;
|
|
594
|
+
} while (paginationToken);
|
|
595
|
+
return users;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function findCognitoUserBySub(userPoolId: string, userSub: string): Promise<CognitoDirectoryUser> {
|
|
599
|
+
const response = await cognito.send(
|
|
600
|
+
new ListUsersCommand({
|
|
601
|
+
Filter: `sub = "${userSub.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`,
|
|
602
|
+
Limit: 1,
|
|
603
|
+
UserPoolId: userPoolId,
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
const user = response.Users?.[0];
|
|
607
|
+
const username = user?.Username;
|
|
608
|
+
const sub = readCognitoAttribute(user?.Attributes, "sub");
|
|
609
|
+
if (!user || !username || !sub) throw new Error(`No Cognito user found for sub ${userSub}.`);
|
|
610
|
+
return {
|
|
611
|
+
userSub: sub,
|
|
612
|
+
username,
|
|
613
|
+
email: readCognitoAttribute(user.Attributes, "email"),
|
|
614
|
+
displayName: readCognitoAttribute(user.Attributes, "name"),
|
|
615
|
+
provider: readCognitoAttribute(user.Attributes, "identities") ? "federated" : "cognito",
|
|
616
|
+
enabled: user.Enabled ?? null,
|
|
617
|
+
cognitoStatus: user.UserStatus ?? null,
|
|
618
|
+
activeRoles: await listGroupsForUsername(userPoolId, username),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function listGroupsForUsername(userPoolId: string, username: string): Promise<string[]> {
|
|
623
|
+
const groups = await cognito.send(
|
|
624
|
+
new AdminListGroupsForUserCommand({
|
|
625
|
+
UserPoolId: userPoolId,
|
|
626
|
+
Username: username,
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
return groups.Groups?.map((group) => group.GroupName).filter(isString).sort() ?? [];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function getDataClient(): Promise<DataClient> {
|
|
633
|
+
if (!clientPromise) {
|
|
634
|
+
clientPromise = (async () => {
|
|
635
|
+
const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(process.env as never);
|
|
636
|
+
Amplify.configure(resourceConfig, libraryOptions);
|
|
637
|
+
return generateClient<Schema>();
|
|
638
|
+
})();
|
|
639
|
+
}
|
|
640
|
+
return clientPromise;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function listDataRecords<T>(model: { list(input?: Record<string, unknown>): Promise<DataClientResult<T[]>> }): Promise<T[]> {
|
|
644
|
+
const records: T[] = [];
|
|
645
|
+
let nextToken: string | null | undefined;
|
|
646
|
+
do {
|
|
647
|
+
const page = await model.list({ limit: 100, nextToken });
|
|
648
|
+
assertNoDataErrors(page.errors, "list records");
|
|
649
|
+
records.push(...((page.data ?? []).filter(Boolean) as T[]));
|
|
650
|
+
nextToken = page.nextToken;
|
|
651
|
+
} while (nextToken);
|
|
652
|
+
return records;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function getDataRecord<T>(
|
|
656
|
+
model: { get(input: { id: string }): Promise<DataClientResult<T>> },
|
|
657
|
+
id: string,
|
|
658
|
+
operation: string,
|
|
659
|
+
): Promise<T | null> {
|
|
660
|
+
const response = await model.get({ id });
|
|
661
|
+
assertNoDataErrors(response.errors, operation);
|
|
662
|
+
return response.data ?? null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function requireDataResult<T>(promise: Promise<DataClientResult<T>>, operation: string): Promise<T> {
|
|
666
|
+
const response = await promise;
|
|
667
|
+
assertNoDataErrors(response.errors, operation);
|
|
668
|
+
if (!response.data) throw new Error(`${operation} returned no data.`);
|
|
669
|
+
return response.data;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function assertNoDataErrors(errors: DataClientErrors, operation: string): void {
|
|
673
|
+
if (!errors?.length) return;
|
|
674
|
+
throw new Error(`${operation} failed: ${errors.map(formatDataError).join("; ")}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function formatDataError(error: { message?: string | null } | string | null): string {
|
|
678
|
+
if (typeof error === "string") return error;
|
|
679
|
+
return error?.message ?? "GraphQL data operation failed.";
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function activeAssignmentRoles(assignments: UserRoleAssignmentRecord[]): string[] {
|
|
683
|
+
return assignments.filter((assignment) => assignment.status === ACTIVE_ROLE_STATUS).map((assignment) => assignment.role).sort();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function identitySnapshot(identity: UserIdentityRecord) {
|
|
687
|
+
return {
|
|
688
|
+
id: identity.id,
|
|
689
|
+
userProfileId: identity.userProfileId,
|
|
690
|
+
cognitoSub: identity.cognitoSub,
|
|
691
|
+
provider: identity.provider ?? null,
|
|
692
|
+
email: identity.email ?? null,
|
|
693
|
+
status: identity.status,
|
|
694
|
+
linkedAt: identity.linkedAt,
|
|
695
|
+
lastSeenAt: identity.lastSeenAt ?? null,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function mergeRoles(...roleSets: string[][]): string[] {
|
|
700
|
+
return uniqueStrings(roleSets.flat().filter((role) => MANAGED_ROLES.has(role)));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function mirrorRolesToCognitoUsers(userPoolId: string, users: CognitoDirectoryUser[], roles: string[]): Promise<void> {
|
|
704
|
+
await Promise.all(users.flatMap((user) =>
|
|
705
|
+
roles.filter((role) => !user.activeRoles.includes(role)).map((role) =>
|
|
706
|
+
cognito.send(
|
|
707
|
+
new AdminAddUserToGroupCommand({
|
|
708
|
+
UserPoolId: userPoolId,
|
|
709
|
+
Username: user.username,
|
|
710
|
+
GroupName: role,
|
|
711
|
+
}),
|
|
712
|
+
),
|
|
713
|
+
),
|
|
714
|
+
));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function readCognitoAttribute(
|
|
718
|
+
attributes: Array<{ Name?: string | null; Value?: string | null }> | undefined,
|
|
719
|
+
name: string,
|
|
720
|
+
): string | null {
|
|
721
|
+
const value = attributes?.find((attribute) => attribute.Name === name)?.Value;
|
|
722
|
+
return normalizeOptionalString(value);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function normalizeRole(value: unknown): string {
|
|
726
|
+
const role = normalizeRequiredString(value, "role").toLowerCase();
|
|
727
|
+
if (!MANAGED_ROLES.has(role)) {
|
|
728
|
+
throw new Error(`Role ${role} is not managed by Papyrus.`);
|
|
729
|
+
}
|
|
730
|
+
return role;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function normalizeRequiredString(value: unknown, fieldName: string): string {
|
|
734
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
735
|
+
throw new Error(`${fieldName} is required.`);
|
|
736
|
+
}
|
|
737
|
+
return value.trim();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function normalizeOptionalString(value: unknown): string | null {
|
|
741
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function getIdentityLabel(event: Parameters<GrantHandler>[0] | Parameters<RevokeHandler>[0] | Parameters<MergeHandler>[0]): string | null {
|
|
745
|
+
const claims = event.identity && "claims" in event.identity ? event.identity.claims : null;
|
|
746
|
+
return normalizeOptionalString(claims?.email)
|
|
747
|
+
?? normalizeOptionalString(claims?.name)
|
|
748
|
+
?? normalizeOptionalString(claims?.username)
|
|
749
|
+
?? normalizeOptionalString(claims?.sub);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function getUserPoolId(): string {
|
|
753
|
+
const userPoolId = process.env.AMPLIFY_AUTH_USERPOOL_ID;
|
|
754
|
+
if (!userPoolId) throw new Error("AMPLIFY_AUTH_USERPOOL_ID is not configured.");
|
|
755
|
+
return userPoolId;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function groupBy<T>(items: T[], keyFor: (item: T) => string): Map<string, T[]> {
|
|
759
|
+
const groups = new Map<string, T[]>();
|
|
760
|
+
for (const item of items) {
|
|
761
|
+
const key = keyFor(item);
|
|
762
|
+
const group = groups.get(key) ?? [];
|
|
763
|
+
group.push(item);
|
|
764
|
+
groups.set(key, group);
|
|
765
|
+
}
|
|
766
|
+
return groups;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function compactStrings(values: Array<string | null | undefined>): string[] {
|
|
770
|
+
return values.map((value) => normalizeOptionalString(value)).filter(isString);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function uniqueStrings(values: string[]): string[] {
|
|
774
|
+
return Array.from(new Set(values)).sort();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function uniqueBy<T>(values: T[], keyFor: (value: T) => string): T[] {
|
|
778
|
+
const seen = new Set<string>();
|
|
779
|
+
const unique: T[] = [];
|
|
780
|
+
for (const value of values) {
|
|
781
|
+
const key = keyFor(value);
|
|
782
|
+
if (seen.has(key)) continue;
|
|
783
|
+
seen.add(key);
|
|
784
|
+
unique.push(value);
|
|
785
|
+
}
|
|
786
|
+
return unique;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function safeId(value: string): string {
|
|
790
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "unknown";
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function isString(value: string | null | undefined): value is string {
|
|
794
|
+
return typeof value === "string" && value.length > 0;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function isDefined<T>(value: T | null | undefined): value is T {
|
|
798
|
+
return value !== null && value !== undefined;
|
|
799
|
+
}
|