@anthusai/papyrus 5.43.0-next.6 → 5.43.0-next.7
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/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/papyrus-console-shell.tsx +2 -1
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/reader-subject.ts +3 -2
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +2 -1
- package/routes.manifest.json +1 -1
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
requestDelegation,
|
|
25
25
|
type DelegationStore,
|
|
26
26
|
} from "../shared/curation-delegation-flow";
|
|
27
|
+
import { claimGroups } from "../../../lib/staff-groups";
|
|
27
28
|
|
|
28
29
|
type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
|
|
29
30
|
type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
|
|
@@ -360,7 +361,7 @@ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; la
|
|
|
360
361
|
}
|
|
361
362
|
const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
|
|
362
363
|
const claims = identity.claims ?? {};
|
|
363
|
-
const groups =
|
|
364
|
+
const groups = claimGroups(claims["cognito:groups"]);
|
|
364
365
|
return {
|
|
365
366
|
sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
|
|
366
367
|
label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
|
|
@@ -3,6 +3,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
|
|
|
3
3
|
import { Amplify } from "aws-amplify";
|
|
4
4
|
import { generateClient } from "aws-amplify/data";
|
|
5
5
|
import type { Schema } from "../../data/resource";
|
|
6
|
+
import { claimGroups } from "../../../lib/staff-groups";
|
|
6
7
|
|
|
7
8
|
type DataClient = ReturnType<typeof generateClient<Schema>>;
|
|
8
9
|
type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
|
|
@@ -474,20 +475,11 @@ function validateJsonSchemaInput(schema: Record<string, unknown>, input: Record<
|
|
|
474
475
|
|
|
475
476
|
function requireAdmin(event: ProcedureEvent): void {
|
|
476
477
|
const claims = objectValue(event.identity?.claims);
|
|
477
|
-
const groups = [
|
|
478
|
-
...normalizeClaimGroups(claims["cognito:groups"]),
|
|
479
|
-
...normalizeClaimGroups(claims.groups),
|
|
480
|
-
];
|
|
478
|
+
const groups = [...claimGroups(claims["cognito:groups"]), ...claimGroups(claims.groups)];
|
|
481
479
|
if (groups.includes("admin")) return;
|
|
482
480
|
throw new Error("Admin role required.");
|
|
483
481
|
}
|
|
484
482
|
|
|
485
|
-
function normalizeClaimGroups(value: unknown): string[] {
|
|
486
|
-
if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
487
|
-
if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
488
|
-
return [];
|
|
489
|
-
}
|
|
490
|
-
|
|
491
483
|
async function getDataClient(): Promise<DataClient> {
|
|
492
484
|
if (!clientPromise) {
|
|
493
485
|
clientPromise = (async () => {
|
|
@@ -92,6 +92,25 @@ def is_appsync_event(event: dict[str, Any]) -> bool:
|
|
|
92
92
|
return "arguments" in event and ("info" in event or event_field_name(event) is not None)
|
|
93
93
|
PROPOSER_AI = "ai"
|
|
94
94
|
STAFF_GROUPS = frozenset({"editor", "admin"})
|
|
95
|
+
|
|
96
|
+
_GROUP_SEPARATORS = re.compile(r"[\s,]+")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def claim_groups(value: Any) -> list[str]:
|
|
100
|
+
"""Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
|
|
101
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
102
|
+
raw = [item for item in value if isinstance(item, str)]
|
|
103
|
+
elif isinstance(value, str):
|
|
104
|
+
raw = _GROUP_SEPARATORS.split(value)
|
|
105
|
+
else:
|
|
106
|
+
return []
|
|
107
|
+
cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
|
|
108
|
+
return [item for item in cleaned if item]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def has_editor_rights(groups: Any) -> bool:
|
|
112
|
+
"""``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
|
|
113
|
+
return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
|
|
95
114
|
#: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
|
|
96
115
|
OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
|
|
97
116
|
BY_STAFF, BY_OPERATOR = "staff", "operator"
|
|
@@ -140,10 +159,8 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
|
|
|
140
159
|
"""The Cognito subject of an editor or admin, or ``None`` for anyone else."""
|
|
141
160
|
claims = identity.get("claims") or {}
|
|
142
161
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
143
|
-
if isinstance(groups, str):
|
|
144
|
-
groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
|
|
145
162
|
subject = identity.get("sub")
|
|
146
|
-
if not isinstance(subject, str) or not subject or not
|
|
163
|
+
if not isinstance(subject, str) or not subject or not has_editor_rights(groups):
|
|
147
164
|
return None
|
|
148
165
|
return subject
|
|
149
166
|
|
|
@@ -1468,10 +1468,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
|
|
|
1468
1468
|
identity = event.get("identity") or {}
|
|
1469
1469
|
claims = identity.get("claims") or {}
|
|
1470
1470
|
groups = claims.get("cognito:groups") or identity.get("groups") or []
|
|
1471
|
-
if isinstance(groups, str):
|
|
1472
|
-
groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
|
|
1473
1471
|
subject = identity.get("sub")
|
|
1474
|
-
if not isinstance(subject, str) or not subject or not moderation_rules.
|
|
1472
|
+
if not isinstance(subject, str) or not subject or not moderation_rules.has_editor_rights(groups):
|
|
1475
1473
|
return moderation_rules.refused("not_staff", "Only editors and admins can moderate.").to_dict()
|
|
1476
1474
|
arguments = event.get("arguments") or {}
|
|
1477
1475
|
outcome = moderation_rules.apply_action(
|
|
@@ -37,6 +37,25 @@ READER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
|
|
|
37
37
|
ACTION_ID = re.compile(r"mod-[0-9a-f]{32}")
|
|
38
38
|
MAXIMUM_REASON = 500
|
|
39
39
|
STAFF_GROUPS = frozenset({"editor", "admin"})
|
|
40
|
+
|
|
41
|
+
_GROUP_SEPARATORS = re.compile(r"[\s,]+")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def claim_groups(value: Any) -> list[str]:
|
|
45
|
+
"""Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
|
|
46
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
47
|
+
raw = [item for item in value if isinstance(item, str)]
|
|
48
|
+
elif isinstance(value, str):
|
|
49
|
+
raw = _GROUP_SEPARATORS.split(value)
|
|
50
|
+
else:
|
|
51
|
+
return []
|
|
52
|
+
cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
|
|
53
|
+
return [item for item in cleaned if item]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def has_editor_rights(groups: Any) -> bool:
|
|
57
|
+
"""``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
|
|
58
|
+
return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
|
|
40
59
|
ITEM_ID_PREFIX = "item-"
|
|
41
60
|
|
|
42
61
|
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { fetchAuthSession, signIn, signOut } from "aws-amplify/auth";
|
|
4
4
|
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
5
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
5
6
|
import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
6
7
|
|
|
7
|
-
const EDITOR_GROUPS = new Set(["editor", "admin"]);
|
|
8
8
|
|
|
9
9
|
function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>): string[] {
|
|
10
10
|
const accessPayload = session.tokens?.accessToken?.payload ?? {};
|
|
@@ -29,7 +29,7 @@ function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>
|
|
|
29
29
|
|
|
30
30
|
function sessionHasEditorAccess(session: Awaited<ReturnType<typeof fetchAuthSession>>): boolean {
|
|
31
31
|
if (!session.tokens?.accessToken) return false;
|
|
32
|
-
return readSessionGroups(session)
|
|
32
|
+
return hasEditorRights(readSessionGroups(session));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export async function ensureDevSandboxEditorSession(
|
|
@@ -113,7 +113,8 @@ export function NewsDeskClientProvider({
|
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
115
|
if (isAuthorizationError(error)) {
|
|
116
|
-
|
|
116
|
+
// The session already has editor rights, so the backend refusing a read is not a missing role. Say what was refused.
|
|
117
|
+
setShell((current) => resolveDashboardFailure(current, access.auth, refusedReadMessage(error)));
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
120
|
setShell((current) => resolveDashboardFailure(
|
|
@@ -215,9 +216,14 @@ function isAuthorizationError(error: unknown): boolean {
|
|
|
215
216
|
return /not authorized|unauthorized|access denied|forbidden/i.test(message);
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
function refusedReadMessage(error: unknown): string {
|
|
220
|
+
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
221
|
+
return `The Newsroom backend refused a read for this signed-in account (${detail}). The account has editor or admin rights, so this is a backend authorization rule, not a missing role.`;
|
|
222
|
+
}
|
|
223
|
+
|
|
218
224
|
function resolvePrivateReadFailure(state: NewsDeskShellState, auth: NewsDeskShellState["auth"], error: unknown, fallback: string): NewsDeskShellState {
|
|
219
225
|
if (isUnauthenticatedError(error)) return resolveSignedOut(state, { status: "signedOut", label: "Signed out" });
|
|
220
|
-
if (isAuthorizationError(error)) return
|
|
226
|
+
if (isAuthorizationError(error)) return resolveDashboardFailure(state, auth, refusedReadMessage(error));
|
|
221
227
|
return resolveDashboardFailure(state, auth, error instanceof Error ? error.message : fallback);
|
|
222
228
|
}
|
|
223
229
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { loadReferenceReviewDetails } from "../lib/reference-review-details";
|
|
4
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
4
5
|
|
|
5
6
|
import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
|
|
6
7
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
@@ -838,7 +839,7 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
|
|
|
838
839
|
if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
|
|
839
840
|
return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
|
|
840
841
|
}
|
|
841
|
-
const isEditor = refreshed.groups
|
|
842
|
+
const isEditor = hasEditorRights(refreshed.groups);
|
|
842
843
|
if (!isEditor) {
|
|
843
844
|
return {
|
|
844
845
|
status: "forbidden",
|
|
@@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
|
|
|
6
6
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
7
7
|
import { CheckIcon, UserIcon } from "lucide-react";
|
|
8
8
|
import { Md5 } from "@smithy/md5-js";
|
|
9
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
9
10
|
import {
|
|
10
11
|
createConsoleMessage,
|
|
11
12
|
createConsoleThread,
|
|
@@ -154,7 +155,7 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
|
|
|
154
155
|
const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
|
|
155
156
|
const internalPathname = browserPathToInternalNewsroomPath(pathname);
|
|
156
157
|
const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
|
|
157
|
-
const canUseConsole = Boolean(session?.hasSession && session.groups
|
|
158
|
+
const canUseConsole = Boolean(session?.hasSession && hasEditorRights(session.groups));
|
|
158
159
|
const shouldOfferConsole = isNewsroomPath && canUseConsole;
|
|
159
160
|
const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
|
|
160
161
|
|
|
@@ -2,6 +2,7 @@ import { CognitoJwtVerifier } from "aws-jwt-verify";
|
|
|
2
2
|
import type { JwksCache } from "aws-jwt-verify/jwk";
|
|
3
3
|
import type { NextRequest } from "next/server";
|
|
4
4
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
5
|
+
import { STAFF_GROUPS } from "./staff-groups";
|
|
5
6
|
|
|
6
7
|
export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
|
|
7
8
|
export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
|
|
@@ -14,7 +15,7 @@ export function newsroomChatIdentityResolver(
|
|
|
14
15
|
userPoolId: auth.user_pool_id,
|
|
15
16
|
clientId: auth.user_pool_client_id,
|
|
16
17
|
tokenUse: "access",
|
|
17
|
-
groups: [
|
|
18
|
+
groups: [...STAFF_GROUPS],
|
|
18
19
|
}, jwksCache ? { jwksCache } : undefined);
|
|
19
20
|
return async (request) => {
|
|
20
21
|
const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
|
package/lib/reader-subject.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
2
|
import type { ProspectusSignedInSession } from "./prospectus-session";
|
|
3
|
+
import { hasEditorRights, STAFF_GROUPS } from "./staff-groups";
|
|
3
4
|
|
|
4
5
|
export const SITE_SUBJECT_PATTERN = /^ss1_[A-Za-z0-9_-]{43}$/;
|
|
5
6
|
export const INTERIM_SUBJECT_PATTERN = /^ssdev_[A-Za-z0-9_-]{43}$/;
|
|
6
7
|
export const READER_DEV_SECRET_ENV = "PAPYRUS_READER_DEV_SECRET";
|
|
7
|
-
export const READER_STAFF_GROUPS =
|
|
8
|
+
export const READER_STAFF_GROUPS = STAFF_GROUPS;
|
|
8
9
|
|
|
9
10
|
const MINIMUM_DEV_SECRET_LENGTH = 16;
|
|
10
11
|
const INTERIM_SUBJECT_PREFIX = "ssdev_";
|
|
@@ -46,7 +47,7 @@ export function readerIsStaff(session: Pick<ProspectusSignedInSession, "accessTo
|
|
|
46
47
|
try {
|
|
47
48
|
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { "cognito:groups"?: unknown };
|
|
48
49
|
const groups = claims["cognito:groups"];
|
|
49
|
-
return Array.isArray(groups) && groups
|
|
50
|
+
return Array.isArray(groups) && hasEditorRights(groups);
|
|
50
51
|
} catch {
|
|
51
52
|
return false;
|
|
52
53
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who counts as newsroom staff. `admin` has every editor right: a site owner who is in `admin` but not `editor` can do
|
|
3
|
+
* everything an editor can. Every editor check in the browser and in the Lambdas reads this one list.
|
|
4
|
+
*/
|
|
5
|
+
export const STAFF_GROUPS = ["editor", "admin"] as const;
|
|
6
|
+
|
|
7
|
+
/** True when any group is `editor` or `admin`. */
|
|
8
|
+
export function hasEditorRights(groups: Iterable<unknown> | null | undefined): boolean {
|
|
9
|
+
if (!groups) return false;
|
|
10
|
+
for (const group of groups) {
|
|
11
|
+
if (typeof group === "string" && (STAFF_GROUPS as readonly string[]).includes(group.trim().toLowerCase())) return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cognito groups from a `cognito:groups` claim, which arrives as an array, a JSON array, a bracketed list
|
|
18
|
+
* (`[admin Google_123]`), or a comma or space separated string depending on who forwarded it.
|
|
19
|
+
*/
|
|
20
|
+
export function claimGroups(value: unknown): string[] {
|
|
21
|
+
const raw = Array.isArray(value) ? value.map((entry) => String(entry)) : typeof value === "string" ? value.split(/[\s,]+/) : [];
|
|
22
|
+
return raw.map((entry) => entry.trim().replace(/^["'[\](){}]+|["'[\](){}]+$/g, "")).filter(Boolean);
|
|
23
|
+
}
|
package/lib/staging-gate.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { fetchAuthSession } from "aws-amplify/auth/server";
|
|
3
3
|
import { getAmplifyServerRuntime } from "./amplify-server-runtime";
|
|
4
|
+
import { hasEditorRights } from "./staff-groups";
|
|
4
5
|
|
|
5
6
|
export type StagingAccess = "anonymous" | "forbidden" | "ok";
|
|
6
7
|
|
|
7
|
-
const STAGING_ALLOWED_GROUPS = ["editor", "admin"];
|
|
8
8
|
|
|
9
9
|
export function groupsFromIdTokenPayload(payload: Record<string, unknown> | undefined): string[] {
|
|
10
10
|
const raw = payload?.["cognito:groups"];
|
|
@@ -15,7 +15,7 @@ export function groupsFromIdTokenPayload(payload: Record<string, unknown> | unde
|
|
|
15
15
|
|
|
16
16
|
export function accessForGroups(groups: string[] | null): StagingAccess {
|
|
17
17
|
if (groups === null) return "anonymous";
|
|
18
|
-
return groups
|
|
18
|
+
return hasEditorRights(groups) ? "ok" : "forbidden";
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function getStagingAccess(request: NextRequest, response: NextResponse): Promise<StagingAccess> {
|
package/package.json
CHANGED
|
@@ -341,6 +341,7 @@
|
|
|
341
341
|
"./lib/site-renderer": "./lib/site-renderer.ts",
|
|
342
342
|
"./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
|
|
343
343
|
"./lib/site-stack": "./lib/site-stack.ts",
|
|
344
|
+
"./lib/staff-groups": "./lib/staff-groups.ts",
|
|
344
345
|
"./lib/staging-gate": "./lib/staging-gate.ts",
|
|
345
346
|
"./lib/staging-gated-path": "./lib/staging-gated-path.ts",
|
|
346
347
|
"./lib/staging-preview-object": "./lib/staging-preview-object.ts",
|
|
@@ -467,7 +468,7 @@
|
|
|
467
468
|
"optional": true
|
|
468
469
|
}
|
|
469
470
|
},
|
|
470
|
-
"version": "5.43.0-next.
|
|
471
|
+
"version": "5.43.0-next.7",
|
|
471
472
|
"dependencies": {
|
|
472
473
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
473
474
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED