Skip to content

Commit 8d2027b

Browse files
committed
feat(bb-auth-cognito): close auth.admin type-safety & ergonomics gaps
Addresses reviewer-identified gaps in the admin surface: - Gap 1 (typed reads): AdminUser is now AdminUser<O> — getUser/scan/listUsersInGroup return narrowed groups (GroupOf<O>) and attributes (ReadAttrOf<O>), matching the client-side CognitoUser. createUser attributes narrow via AttrOf<O> too, catching typos the way signUp does. - Gap 3 (ungranted-action feedback): a new AdminActionGate rest-param makes calling an ungranted method (e.g. deleteUser under actions:['groups']) a COMPILE error — variance-safe because the gate is in parameter position, not the surface shape (verified against the call sites the earlier shape-narrowing attempt regressed). A runtime guard also fast-fails with a clear message instead of AWS AccessDenied. - Gap 4 (scan filter): scan(filter?) accepts an AdminUserFilter mapped to Cognito's ListUsers Filter (startsWith/equals); mock filters in memory. - Gap 5 (readability): setUserPassword(username, password, { permanent }) uses a named options object instead of a bare boolean. Umbrella @aws-blocks/blocks re-exports the new public types. API reports + changeset updated. Package suite: 228 pass, 0 fail; full type-test incl. gate + variance guard.
1 parent 5398ce8 commit 8d2027b

10 files changed

Lines changed: 415 additions & 109 deletions

File tree

.changeset/auth-cognito-admin-handle.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@
44

55
Add an opt-in `auth.admin` handle to `AuthCognito` for server-side group-membership and user-lifecycle administration.
66

7-
Enable it by passing an `admin` options object; `admin.actions` scopes the granted `Admin*` / `List*` IAM. Without it, `auth.admin` is a compile error and no admin IAM is granted (unchanged default). Group names on the admin methods narrow via `GroupOf<O>`.
7+
Enable it by passing an `admin` options object; `admin.actions` scopes both the granted `Admin*` / `List*` IAM **and** the compile-time method surface. Without it, `auth.admin` is a compile error and no admin IAM is granted (unchanged default).
88

99
```ts
1010
const auth = new AuthCognito(scope, 'auth', { groups: ['admins'], admin: { actions: ['groups'] } });
1111
await auth.admin.addUserToGroup('alice', 'admins');
1212
```
1313

14+
The admin surface is fully typed by the pool config `O`:
15+
16+
- **Action gating:** calling a method whose action group wasn't granted (e.g. `deleteUser` under `actions: ['groups']`) is a compile error, and fast-fails at runtime with a clear message instead of a cryptic AWS `AccessDenied`.
17+
- **Typed reads:** `getUser` / `scan` / `listUsersInGroup` return `AdminUser<O>``groups` narrows to the configured group union and `attributes` keys to the declared attributes, matching the client-side `CognitoUser`.
18+
- **Typed writes:** `createUser`'s `attributes` narrow to the declared keys (catches typos like `signUp` does).
19+
- **`scan(filter?)`** accepts a server-side `AdminUserFilter` mapped to Cognito's `ListUsers` `Filter`.
20+
- **`setUserPassword(username, password, { permanent })`** takes a named options object instead of a bare boolean.
21+
1422
The `AuthCognito` class generic is now a `const` type parameter, so inline options literals narrow without `as const`.
1523

1624
BREAKING CHANGE: `const O` narrows the params of `requireRole`, `updateUserAttribute`, and `updateMFAPreference` for inline-literal options. Callers passing widened `string` variables to these now need a cast or literal arguments.

packages/bb-auth-cognito/API.md

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@ import { Scope } from '@aws-blocks/core';
1313
import type { ScopeParent } from '@aws-blocks/core';
1414

1515
// @public
16-
export interface AdminCreateInit {
17-
attributes?: Record<string, string>;
16+
export type AdminAction = 'groups' | 'lifecycle';
17+
18+
// @public
19+
export type AdminActionGate<O extends AuthCognitoOptions, A extends AdminAction> = AdminGrants<O, A> extends true ? [] : [ERROR_admin_action_not_granted: never];
20+
21+
// @public
22+
export interface AdminCreateInit<O extends AuthCognitoOptions = AuthCognitoOptions> {
23+
attributes?: Partial<Record<AttrOf<O>, string>>;
1824
suppressInvite?: boolean;
1925
temporaryPassword?: string;
2026
}
@@ -29,28 +35,42 @@ export type AdminGetterOf<O extends AuthCognitoOptions> = O extends {
2935
admin: object;
3036
} ? AdminSurface<O> : AdminDisabled;
3137

38+
// @public
39+
export type AdminGrants<O extends AuthCognitoOptions, A extends AdminAction> = O extends {
40+
admin: {
41+
actions: infer L extends readonly string[];
42+
};
43+
} ? (A extends L[number] ? true : false) : true;
44+
3245
// @public
3346
export interface AdminOptions {
34-
actions?: readonly ('groups' | 'lifecycle')[];
47+
actions?: readonly AdminAction[];
3548
}
3649

3750
// @public
3851
export type AdminSurface<O extends AuthCognitoOptions = AuthCognitoOptions> = GroupAdmin<O> & LifecycleAdmin<O>;
3952

4053
// @public
41-
export interface AdminUser {
54+
export interface AdminUser<O extends AuthCognitoOptions = AuthCognitoOptions> {
4255
// (undocumented)
43-
attributes: Record<string, string>;
56+
attributes: Partial<Record<ReadAttrOf<O>, string>>;
4457
// (undocumented)
4558
enabled: boolean;
4659
// (undocumented)
47-
groups?: string[];
60+
groups?: GroupOf<O>[];
4861
// (undocumented)
4962
username: string;
5063
// (undocumented)
5164
userSub: string;
5265
}
5366

67+
// @public
68+
export interface AdminUserFilter {
69+
attribute: string;
70+
match: 'startsWith' | 'equals';
71+
value: string;
72+
}
73+
5474
// Warning: (ae-incompatible-release-tags) The symbol "AttrOf" is marked as @public, but its signature references "CustomAttrNames" which is marked as @internal
5575
//
5676
// @public
@@ -314,13 +334,13 @@ export interface FetchAuthSessionOptions {
314334
// @public
315335
export interface GroupAdmin<O extends AuthCognitoOptions = AuthCognitoOptions> {
316336
// (undocumented)
317-
addUserToGroup(username: string, group: GroupOf<O>): Promise<void>;
337+
addUserToGroup(username: string, group: GroupOf<O>, ...gate: AdminActionGate<O, 'groups'>): Promise<void>;
318338
// (undocumented)
319-
listGroupsForUser(username: string): Promise<GroupOf<O>[]>;
339+
listGroupsForUser(username: string, ...gate: AdminActionGate<O, 'groups'>): Promise<GroupOf<O>[]>;
320340
// (undocumented)
321-
listUsersInGroup(group: GroupOf<O>): Promise<AdminUser[]>;
341+
listUsersInGroup(group: GroupOf<O>, ...gate: AdminActionGate<O, 'groups'>): Promise<AdminUser<O>[]>;
322342
// (undocumented)
323-
removeUserFromGroup(username: string, group: GroupOf<O>): Promise<void>;
343+
removeUserFromGroup(username: string, group: GroupOf<O>, ...gate: AdminActionGate<O, 'groups'>): Promise<void>;
324344
}
325345

326346
// @public
@@ -348,23 +368,23 @@ export interface JWT {
348368
// @public
349369
export interface LifecycleAdmin<O extends AuthCognitoOptions = AuthCognitoOptions> {
350370
// (undocumented)
351-
createUser(username: string, init?: AdminCreateInit): Promise<AdminUser>;
371+
createUser(username: string, init?: AdminCreateInit<O>, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<AdminUser<O>>;
352372
// (undocumented)
353-
deleteUser(username: string): Promise<void>;
373+
deleteUser(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
354374
// (undocumented)
355-
disableUser(username: string): Promise<void>;
375+
disableUser(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
356376
// (undocumented)
357-
enableUser(username: string): Promise<void>;
377+
enableUser(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
358378
// (undocumented)
359-
getUser(username: string): Promise<AdminUser | null>;
379+
getUser(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<AdminUser<O> | null>;
360380
// (undocumented)
361-
resetUserPassword(username: string): Promise<void>;
381+
resetUserPassword(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
362382
// (undocumented)
363-
revokeUserSessions(username: string): Promise<void>;
383+
revokeUserSessions(username: string, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
364384
// (undocumented)
365-
scan(): AsyncIterable<AdminUser>;
385+
scan(filter?: AdminUserFilter, ...gate: AdminActionGate<O, 'lifecycle'>): AsyncIterable<AdminUser<O>>;
366386
// (undocumented)
367-
setUserPassword(username: string, password: string, permanent: boolean): Promise<void>;
387+
setUserPassword(username: string, password: string, options?: SetPasswordOptions, ...gate: AdminActionGate<O, 'lifecycle'>): Promise<void>;
368388
}
369389

370390
// @public
@@ -458,6 +478,11 @@ export class SessionStore {
458478
updateSession(sessionId: string, update: Partial<SessionRecord>): Promise<void>;
459479
}
460480

481+
// @public
482+
export interface SetPasswordOptions {
483+
permanent?: boolean;
484+
}
485+
461486
// @public
462487
export type SignInNextStep = {
463488
name: 'CONFIRM_SIGN_IN_WITH_SMS_CODE';

packages/bb-auth-cognito/src/admin.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ describe('auth.admin user lifecycle', () => {
172172
test('setUserPassword(permanent) lets the user sign in with the new password', async () => {
173173
const auth = new AuthCognito(ROOT, unique(), { admin: {} });
174174
await signUpAndConfirm(auth, 'hank');
175-
await auth.admin.setUserPassword('hank', 'NewPass!2', true);
175+
await auth.admin.setUserPassword('hank', 'NewPass!2', { permanent: true });
176176
const r = await auth.signIn('hank', 'NewPass!2', freshContext());
177177
assert.strictEqual(r.status, 'signedIn');
178178
});
@@ -186,6 +186,18 @@ describe('auth.admin user lifecycle', () => {
186186
assert.deepStrictEqual(seen.sort(), ['ida', 'jack']);
187187
});
188188

189+
test('scan with a startsWith filter narrows results (Gap 4)', async () => {
190+
const auth = new AuthCognito(ROOT, unique(), { admin: {} });
191+
await auth.admin.createUser('alice');
192+
await auth.admin.createUser('albert');
193+
await auth.admin.createUser('bob');
194+
const seen: string[] = [];
195+
for await (const u of auth.admin.scan({ attribute: 'username', match: 'startsWith', value: 'al' })) {
196+
seen.push(u.username);
197+
}
198+
assert.deepStrictEqual(seen.sort(), ['albert', 'alice']);
199+
});
200+
189201
test('revokeUserSessions deletes the user session (forces re-auth)', async () => {
190202
const auth = new AuthCognito(ROOT, unique(), { admin: {} });
191203
await signUpAndConfirm(auth, 'kara');
@@ -197,3 +209,34 @@ describe('auth.admin user lifecycle', () => {
197209
assert.strictEqual(await auth.checkAuth(ctx), false);
198210
});
199211
});
212+
213+
describe('auth.admin action-scope runtime gate (Gap 3)', () => {
214+
test('groups-only pool fast-fails a lifecycle call with a clear error', async () => {
215+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: { actions: ['groups'] } });
216+
// Cast past the compile-time gate to reach the runtime guard (an untyped
217+
// JS caller would hit this path). Error must be clear, not AWS AccessDenied.
218+
const admin = auth.admin as unknown as { createUser(u: string): Promise<unknown> };
219+
await assert.rejects(
220+
() => admin.createUser('x'),
221+
(e: unknown) => isBlocksError(e, AuthCognitoErrors.NotAuthorized) && /lifecycle actions not granted/.test((e as Error).message),
222+
);
223+
});
224+
225+
test('lifecycle-only pool fast-fails a groups call', async () => {
226+
const auth = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: { actions: ['lifecycle'] } });
227+
const admin = auth.admin as unknown as { addUserToGroup(u: string, g: string): Promise<unknown> };
228+
await assert.rejects(
229+
() => admin.addUserToGroup('x', 'admins'),
230+
(e: unknown) => isBlocksError(e, AuthCognitoErrors.NotAuthorized) && /groups actions not granted/.test((e as Error).message),
231+
);
232+
});
233+
234+
test('granted action + admin:{} (all) do not fast-fail', async () => {
235+
const groupsOnly = new AuthCognito(ROOT, unique(), { groups: ['admins'], admin: { actions: ['groups'] } });
236+
await signUpAndConfirm(groupsOnly, 'gwen');
237+
await groupsOnly.admin.addUserToGroup('gwen', 'admins'); // granted → no throw
238+
239+
const all = new AuthCognito(ROOT, unique(), { admin: {} });
240+
await all.admin.createUser('hugo'); // no actions restriction → no throw
241+
});
242+
});

packages/bb-auth-cognito/src/admin.types-test.ts

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,26 @@ async function fullSurface() {
4040
}
4141

4242
// ─────────────────────────────────────────────────────────────────────────────
43-
// (3) `actions` scopes the IAM grant, NOT the typed method set — the full
44-
// surface is present at the type level regardless of `actions`. (Narrowing
45-
// the type by `actions` would force `AuthCognito<O>` invariant; see
46-
// `AdminSurface` doc.) A method whose action wasn't granted fails at
47-
// runtime with IAM AccessDenied, not at compile time.
43+
// (3) `actions` gates the methods at COMPILE TIME (via AdminActionGate rest
44+
// params) as well as scoping the IAM grant — calling an ungranted method is
45+
// a type error. This is variance-safe (the gate lives in a parameter
46+
// position, not the surface shape); the variance guard is case (8).
4847
// ─────────────────────────────────────────────────────────────────────────────
49-
async function actionsScopeGrantNotTypes() {
48+
async function actionsGateMethodsAtCompileTime() {
5049
const groupsScoped = new AuthCognito(scope, 'a3', { groups: ['admins'], admin: { actions: ['groups'] } });
51-
await groupsScoped.admin.addUserToGroup('u', 'admins');
52-
await groupsScoped.admin.createUser('u'); // present at type level (grant-scoped at runtime)
50+
await groupsScoped.admin.addUserToGroup('u', 'admins'); // granted → ok
51+
// @ts-expect-error — lifecycle not granted by actions: ['groups'].
52+
await groupsScoped.admin.createUser('u');
5353

5454
const lifecycleScoped = new AuthCognito(scope, 'a4', { groups: ['admins'], admin: { actions: ['lifecycle'] } });
55-
await lifecycleScoped.admin.createUser('u');
56-
await lifecycleScoped.admin.addUserToGroup('u', 'admins'); // present at type level
55+
await lifecycleScoped.admin.createUser('u'); // granted → ok
56+
// @ts-expect-error — groups not granted by actions: ['lifecycle'].
57+
await lifecycleScoped.admin.addUserToGroup('u', 'admins');
58+
59+
// admin: {} (no actions) grants everything — both call groups compile.
60+
const all = new AuthCognito(scope, 'a3b', { groups: ['admins'], admin: {} });
61+
await all.admin.addUserToGroup('u', 'admins');
62+
await all.admin.createUser('u');
5763
}
5864

5965
// ─────────────────────────────────────────────────────────────────────────────
@@ -85,3 +91,60 @@ async function defaultO() {
8591
// @ts-expect-error — admin disabled on the default (wide) O.
8692
await auth.admin.createUser('u');
8793
}
94+
95+
// ─────────────────────────────────────────────────────────────────────────────
96+
// (8) Variance guard — an instance narrowed on groups/attributes/mfa is still
97+
// assignable to the wide AuthCognito. This is the exact property the earlier
98+
// shape-narrowing attempt broke (it regressed 14 call sites); the
99+
// parameter-position action gate must NOT reintroduce it.
100+
//
101+
// Note: an admin-*enabled* instance is intentionally NOT assignable to a
102+
// wide instance whose O leaves admin disabled (AdminSurface vs AdminDisabled)
103+
// — that is a property of the opt-in gate itself, unrelated to the action
104+
// gate, and does not affect real call sites (nothing assigns an
105+
// admin-enabled instance to a plain `AuthCognito`).
106+
// ─────────────────────────────────────────────────────────────────────────────
107+
function takesWide(_auth: AuthCognito) { /* no-op */ }
108+
function varianceGuard() {
109+
takesWide(new AuthCognito(scope, 'a8a', { groups: ['admins', 'readers'] }));
110+
takesWide(new AuthCognito(scope, 'a8b', { userAttributes: [{ name: 'department' }] }));
111+
takesWide(new AuthCognito(scope, 'a8c', { mfa: 'optional', mfaTypes: ['TOTP'] }));
112+
}
113+
114+
// ─────────────────────────────────────────────────────────────────────────────
115+
// (9) Gap 1 — admin reads are typed by O: `attributes` keys and `groups` narrow
116+
// just like the client-side CognitoUser, catching typos with no autocomplete
117+
// loss. (Previously AdminUser was un-parameterized: string[] / untyped bag.)
118+
// ─────────────────────────────────────────────────────────────────────────────
119+
async function typedAdminReads() {
120+
const auth = new AuthCognito(scope, 'a9', {
121+
groups: ['admins', 'readers'],
122+
userAttributes: [{ name: 'department' }],
123+
admin: {},
124+
});
125+
const user = await auth.admin.getUser('alice');
126+
if (user) {
127+
const dept: string | undefined = user.attributes['custom:department']; // declared attr → ok
128+
const email: string | undefined = user.attributes['email']; // standard attr → ok
129+
void dept; void email;
130+
// @ts-expect-error — 'custom:deparment' (typo) is not a known attribute key.
131+
void user.attributes['custom:deparment'];
132+
if (user.groups) {
133+
const g: 'admins' | 'readers' = user.groups[0]; // groups narrowed to the union
134+
void g;
135+
}
136+
}
137+
}
138+
139+
// ─────────────────────────────────────────────────────────────────────────────
140+
// (10) Gap 4 — scan accepts an optional server-side filter.
141+
// ─────────────────────────────────────────────────────────────────────────────
142+
async function scanFilter() {
143+
const auth = new AuthCognito(scope, 'a10', { admin: {} });
144+
for await (const u of auth.admin.scan({ attribute: 'email', match: 'startsWith', value: 'a' })) {
145+
void u.username;
146+
}
147+
for await (const u of auth.admin.scan()) void u.username; // filter is optional
148+
// @ts-expect-error — 'contains' is not a supported match mode.
149+
auth.admin.scan({ attribute: 'email', match: 'contains', value: 'a' });
150+
}

0 commit comments

Comments
 (0)