Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d3e466d
fix: harden RBAC permission boundaries
lorenzocorallo Sep 16, 2026
36505b0
fix: deny wildcard bootstrap without explicit admin configuration
viganogabriele Sep 17, 2026
8e7c7b6
fix: reject legacy inherited Master Admin wildcards during resolution
viganogabriele Sep 17, 2026
6f5bc6b
fix: resolve fresh tenant-bound evidence and atomic RBAC snapshots
viganogabriele Sep 17, 2026
1899b8d
fix: serialize and reauthorize RBAC mutations with append-only audit …
viganogabriele Sep 17, 2026
c4b13d6
fix: prevent writers from delegating or editing authority above their…
viganogabriele Sep 17, 2026
eeb1394
fix: serialize evidence changes to prevent verification replay and lo…
viganogabriele Sep 17, 2026
a3d14a4
fix: enforce repository read boundaries and stop write-only member di…
viganogabriele Sep 17, 2026
ef96aa3
fix: validate security settings before startup in production containers
viganogabriele Sep 17, 2026
77c7174
fix: deny unconfigured resource-policy administration and cover plugi…
viganogabriele Sep 17, 2026
87280c2
docs: record RBAC threat model findings and deployment requirements
viganogabriele Sep 17, 2026
1291fc4
fix: preserve verification resend limits after code consumption
lorenzocorallo Sep 17, 2026
defba77
fix: keep RBAC authorization current without blocking on Graph
lorenzocorallo Sep 17, 2026
2812c32
docs: explain RBAC rollout and final security review
lorenzocorallo Sep 17, 2026
2d84c28
fix: reset member pagination when changing roles
lorenzocorallo Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ PN_ENTRA_MEMBER_GROUP_ID=1c68dbb8-4ac3-4569-a886-283b5a825cbd
# Microsoft Entra security group whose direct members hold the built-in Direttivo role.
# Unset: nobody is inferred as Direttivo.
# PN_ENTRA_DIRETTIVO_GROUP_ID=
# Membership is checked with Microsoft Graph again after this interval.
# Lifetime of persisted sign-in evidence. Authorization independently rechecks Graph
# using a fixed maximum 60-second cache; this setting cannot extend RBAC access.
PN_ENTRA_MEMBER_REFRESH_HOURS=24

# Google login.
Expand All @@ -39,9 +40,9 @@ PN_ENTRA_MEMBER_REFRESH_HOURS=24
AZURE_EMAIL_SENDER=noreply@polinetwork.org
STUDENT_VERIFICATION_TTL_DAYS=365

# Who holds the built-in Master Admin role, which carries every permission. By default
# every signed-in PN Entra account does. Set this to a stricter Microsoft Entra group
# (object ID) to limit it to that group's direct members; the PN_ENTRA app checks it
# Who holds the built-in Master Admin role, which carries every permission. Configure
# this Microsoft Entra administrators group (object ID) or a nonempty allowlist below.
# Missing both stops startup. Group membership is verified by the PN_ENTRA app
# through Graph. Everyone else is administered through roles at /access.
# PN_ENTRA_OIDC_ADMIN_GROUP_ID=
# Comma-separated local user IDs that are always Master Admin (break-glass).
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ COPY --from=build --chown=node:node /app/.output ./.output
COPY --from=build --chown=node:node /app/drizzle ./drizzle
COPY --from=build --chown=node:node /app/scripts/migrate.mjs ./scripts/migrate.mjs
COPY --from=build --chown=node:node /app/scripts/start.mjs ./scripts/start.mjs
COPY --from=build --chown=node:node /app/scripts/security-config.mjs ./scripts/security-config.mjs
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
Expand Down
93 changes: 71 additions & 22 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docker/write-runtime-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// the versions bundled into .output, which matters for Sentry in particular.
import { readFileSync, writeFileSync } from "node:fs";

const runtimePackages = ["@sentry/tanstackstart-react", "drizzle-orm", "pg"];
const runtimePackages = ["@sentry/tanstackstart-react", "drizzle-orm", "pg", "zod"];

const dependencies = Object.fromEntries(
runtimePackages.map((name) => {
Expand Down
84 changes: 84 additions & 0 deletions docs/rbac-security-review.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions drizzle/0006_volatile_pandemic.sql
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
ALTER TABLE "permission" ADD COLUMN "managed" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Master Admin holds every permission that exists, as a wildcard rather than a stored
-- grant list, so it keeps covering permissions created later. Its membership comes from
-- IDP_ADMIN_USER_IDS or the configured Entra administrators group rather than from
-- identity evidence, which is why it has no source_state: that is the bootstrap path that
-- keeps the service from being locked out of its own administration.
-- IDP_ADMIN_USER_IDS or the Entra administration policy rather than from identity evidence.
-- Master Admin requires an explicitly configured administrators group or user allowlist.
-- It has no source_state because deployment configuration provides the bootstrap path
-- independently of the editable role graph.
INSERT INTO "role" ("id", "key", "name", "description", "managed", "source_state") VALUES
('static-role-master-admin', 'master-admin', 'Master Admin', 'Complete control of this identity provider.', true, NULL)
ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint
Expand Down
51 changes: 51 additions & 0 deletions drizzle/0007_mushy_the_fury.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
CREATE TABLE "rbac_audit_event" (
"id" text PRIMARY KEY NOT NULL,
"actor_id" text NOT NULL,
"operation" text NOT NULL,
"target_id" text NOT NULL,
"before" jsonb NOT NULL,
"after" jsonb NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE FUNCTION reject_rbac_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'RBAC audit events are append-only';
END;
$$;
--> statement-breakpoint
CREATE TRIGGER rbac_audit_append_only BEFORE UPDATE OR DELETE OR TRUNCATE ON rbac_audit_event
FOR EACH STATEMENT EXECUTE FUNCTION reject_rbac_audit_mutation();
--> statement-breakpoint
CREATE FUNCTION guard_managed_role_links() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_TABLE_NAME = 'user_role' THEN
IF EXISTS (SELECT 1 FROM role WHERE id = NEW.role_id AND managed) THEN
RAISE EXCEPTION 'Managed roles cannot be assigned';
END IF;
ELSE
IF EXISTS (SELECT 1 FROM role WHERE id = NEW.parent_role_id AND key = 'master-admin') THEN
RAISE EXCEPTION 'Master Admin cannot be inherited';
END IF;
END IF;
RETURN NEW;
END;
$$;
--> statement-breakpoint
CREATE TRIGGER user_role_unmanaged_only BEFORE INSERT OR UPDATE ON user_role
FOR EACH ROW EXECUTE FUNCTION guard_managed_role_links();
--> statement-breakpoint
CREATE TRIGGER role_parent_no_master BEFORE INSERT OR UPDATE ON role_parent
FOR EACH ROW EXECUTE FUNCTION guard_managed_role_links();
--> statement-breakpoint
-- Existing unsafe edges/assignments are quarantined in the audit record before removal.
INSERT INTO rbac_audit_event (id, actor_id, operation, target_id, before, after)
SELECT 'migration-0007-unsafe-links', 'system:migration:0007', 'quarantine', 'managed-role-links',
jsonb_build_object(
'parents', (SELECT coalesce(jsonb_agg(p), '[]') FROM role_parent p JOIN role r ON r.id = p.parent_role_id WHERE r.key = 'master-admin'),
'assignments', (SELECT coalesce(jsonb_agg(a), '[]') FROM user_role a JOIN role r ON r.id = a.role_id WHERE r.managed)
), '{}'::jsonb;
--> statement-breakpoint
DELETE FROM role_parent WHERE parent_role_id IN (SELECT id FROM role WHERE key = 'master-admin');
--> statement-breakpoint
DELETE FROM user_role WHERE role_id IN (SELECT id FROM role WHERE managed);
Loading
Loading