Task #162: Let admins pre-set required permissions while creating an app

The "Required permissions" section was previously edit-only because
`POST /api/apps/:id/permissions` needs an app id, leaving a brief
window where a freshly created app was visible to everyone before
the admin could re-open the dialog and gate it. The Add app dialog
now lets the admin pick required permissions up front and the new
app + its `app_permissions` rows are written in a single transaction.

Changes:
- `lib/api-spec/openapi.yaml`: extended `CreateAppBody` with an optional
  `permissionIds: integer[]` field. Ran `pnpm --filter @workspace/api-spec
  run codegen` so `lib/api-zod` and `lib/api-client-react` reflect it.
- `artifacts/api-server/src/routes/apps.ts`: `POST /apps` now de-dupes
  and pre-validates `permissionIds`, returns 404 if any id is unknown
  (without creating the app), and inside one transaction inserts the
  app, the `app_permissions` rows (with `.onConflictDoNothing()` against
  the composite primary key), and a single `permission_audit` row
  (`previousIds: []`, `newIds: requestedIds`). After the transaction it
  also writes one `app.permission.add` audit_logs entry per inserted
  permission so the admin log mirrors the post-create flow.
- `artifacts/tx-os/src/pages/admin.tsx`: added `permissionIds: number[]`
  to `AppForm`, a new `NewAppPermissionsPicker` component (rendered only
  in create mode — edit mode keeps the existing `AppPermissionsEditor`
  with its impact preview) that lets admins add/remove permissions
  locally before submit, and wired `handleSaveApp` to forward the
  selected ids when creating. Existing edit path strips the field so
  the update payload remains unchanged.
- `replit.md`: documented the new picker and POST /api/apps behavior.

No impact preview is shown in the create-mode picker because a brand
new app starts with zero users seeing it, so adding permissions cannot
hide it from anyone.

Code-review follow-up: tightened input validation so non-integer or
non-positive `permissionIds` now return 400 with a clear error instead
of being silently dropped by the previous filter. The legacy single-add
endpoint already used this exact 400 message, so behavior stays
consistent across both create and update paths.

Verification:
- `pnpm --filter @workspace/api-spec run codegen` passes.
- `pnpm --filter @workspace/api-server` typechecks with no new errors
  (executive-meetings.ts errors are pre-existing and unrelated).
- Ran the existing app-permission test suites
  (`app-permission-audit.test.mjs`, `app-permissions-crud.test.mjs`,
  `app-permissions-impact.test.mjs`) directly — all 16 tests pass.
- Ran an e2e Playwright test (login as admin → Add app → pick a
  permission → save → verify the row shows 1 restriction → reopen and
  confirm the assigned permission). All steps passed.

Follow-up proposed: automated tests for the new create-with-permissions
endpoint behavior (#231).
This commit is contained in:
Riyadh
2026-04-30 18:21:21 +00:00
parent 063b896c37
commit 6330c1f03d
6 changed files with 245 additions and 5 deletions
+94 -1
View File
@@ -232,7 +232,78 @@ router.post("/apps", requireAdmin, async (req, res): Promise<void> => {
return;
}
const [app] = await db.insert(appsTable).values(parsed.data).returning();
// Accept an optional permissionIds[] alongside the app fields so admins
// can gate the app at creation time. Pulling it out before the insert
// keeps appsTable.values strictly typed against the Drizzle schema.
const { permissionIds: rawPermissionIds, ...appValues } = parsed.data;
// The zod schema already restricts permissionIds to numbers, but we
// explicitly reject non-integer / non-positive values with 400 here so
// a malformed request never silently drops ids — the admin should know
// their gate request was wrong instead of getting a partially gated app.
if (
rawPermissionIds !== undefined &&
rawPermissionIds.some((n) => !Number.isInteger(n) || n <= 0)
) {
res.status(400).json({
error: "permissionIds must be an array of positive integers",
});
return;
}
const requestedPermissionIds = Array.from(new Set(rawPermissionIds ?? []));
// If permission ids were supplied, look them up upfront so we can 404
// before creating the app row instead of leaving an orphaned app behind.
// We also need the names later for the per-permission audit entries.
const requestedPermissions = requestedPermissionIds.length > 0
? await db
.select({ id: permissionsTable.id, name: permissionsTable.name })
.from(permissionsTable)
.where(inArray(permissionsTable.id, requestedPermissionIds))
: [];
if (requestedPermissions.length !== requestedPermissionIds.length) {
const foundIds = new Set(requestedPermissions.map((p) => p.id));
const missing = requestedPermissionIds.filter((id) => !foundIds.has(id));
res.status(404).json({
error: `Permission(s) not found: ${missing.join(", ")}`,
});
return;
}
// Create the app + insert its permission rows in a single transaction so
// the app never exists in an "unrestricted" state when the admin asked
// for permission gating. onConflictDoNothing keeps it idempotent against
// the (app_id, permission_id) primary key.
const { app, insertedPermissionIds } = await db.transaction(async (tx) => {
const [created] = await tx.insert(appsTable).values(appValues).returning();
let inserted: number[] = [];
if (requestedPermissionIds.length > 0) {
const ins = await tx
.insert(appPermissionsTable)
.values(
requestedPermissionIds.map((permissionId) => ({
appId: created.id,
permissionId,
})),
)
.onConflictDoNothing()
.returning({ permissionId: appPermissionsTable.permissionId });
inserted = ins.map((r) => r.permissionId);
// Brand-new app starts with no permissions, so previousIds=[] and the
// newIds is the sorted set of what we just attached. This matches the
// single-add endpoint's audit semantics so the history view renders
// a normal "added X" entry instead of a synthetic create marker.
await recordPermissionAudit(tx, {
targetKind: "app",
targetId: created.id,
changeKind: "app.permissions",
actorUserId: req.session.userId ?? null,
previousIds: [],
newIds: requestedPermissionIds,
});
}
return { app: created, insertedPermissionIds: inserted };
});
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "app.create",
@@ -244,8 +315,30 @@ router.post("/apps", requireAdmin, async (req, res): Promise<void> => {
nameEn: app.nameEn,
route: app.route,
isActive: app.isActive,
permissionIds: requestedPermissionIds,
},
});
// Mirror the per-permission audit rows POST /apps/:id/permissions writes
// so the admin log shows the same "permission added" entries whether the
// gate was set at create time or in a follow-up dialog. We only emit
// entries for rows the transaction actually inserted.
if (insertedPermissionIds.length > 0) {
const nameById = new Map(requestedPermissions.map((p) => [p.id, p.name]));
await db.insert(auditLogsTable).values(
insertedPermissionIds.map((permissionId) => ({
actorUserId: req.session.userId ?? null,
action: "app.permission.add",
targetType: "app" as const,
targetId: app.id,
metadata: {
slug: app.slug,
nameEn: app.nameEn,
permissionId,
permissionName: nameById.get(permissionId) ?? null,
},
})),
);
}
res.status(201).json(app);
});