Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 10 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ BETTER_AUTH_SECRET=replace-with-a-random-secret-at-least-32-characters
# PN_ENTRA_TENANT_ID=
# Microsoft Entra security group used by the backend for PN members (the Soci group).
PN_ENTRA_MEMBER_GROUP_ID=1c68dbb8-4ac3-4569-a886-283b5a825cbd
# Membership is checked with Microsoft Graph again after this interval.
# Microsoft Entra security group whose direct members hold the built-in Direttivo role.
# Unset: nobody is inferred as Direttivo.
# PN_ENTRA_DIRETTIVO_GROUP_ID=
# 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 @@ -36,9 +40,10 @@ PN_ENTRA_MEMBER_REFRESH_HOURS=24
AZURE_EMAIL_SENDER=noreply@polinetwork.org
STUDENT_VERIFICATION_TTL_DAYS=365

# Who may manage OIDC clients at /applications. By default every signed-in PN Entra
# account can. 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 through Graph.
# 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 may always manage OIDC clients (break-glass).
# Comma-separated local user IDs that are always Master Admin (break-glass).
IDP_ADMIN_USER_IDS=
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
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
192 changes: 172 additions & 20 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.

80 changes: 80 additions & 0 deletions drizzle/0004_overjoyed_mordo.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
CREATE TABLE "permission" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"name" text NOT NULL,
"description" text,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "permission_implication" (
"permission_id" text NOT NULL,
"implied_permission_id" text NOT NULL,
CONSTRAINT "permission_implication_permission_id_implied_permission_id_pk" PRIMARY KEY("permission_id","implied_permission_id")
);
--> statement-breakpoint
CREATE TABLE "role" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"name" text NOT NULL,
"description" text,
"managed" boolean DEFAULT false NOT NULL,
"source_state" text,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "role_parent" (
"role_id" text NOT NULL,
"parent_role_id" text NOT NULL,
CONSTRAINT "role_parent_role_id_parent_role_id_pk" PRIMARY KEY("role_id","parent_role_id")
);
--> statement-breakpoint
CREATE TABLE "role_permission" (
"role_id" text NOT NULL,
"permission_id" text NOT NULL,
CONSTRAINT "role_permission_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id")
);
--> statement-breakpoint
CREATE TABLE "user_role" (
"user_id" text NOT NULL,
"role_id" text NOT NULL,
"assigned_by" text,
"assignedAt" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_role_user_id_role_id_pk" PRIMARY KEY("user_id","role_id")
);
--> statement-breakpoint
ALTER TABLE "identity_evidence" ADD COLUMN "states" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "permission_implication" ADD CONSTRAINT "permission_implication_permission_id_permission_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "permission_implication" ADD CONSTRAINT "permission_implication_implied_permission_id_permission_id_fk" FOREIGN KEY ("implied_permission_id") REFERENCES "public"."permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_parent" ADD CONSTRAINT "role_parent_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_parent" ADD CONSTRAINT "role_parent_parent_role_id_role_id_fk" FOREIGN KEY ("parent_role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_permission" ADD CONSTRAINT "role_permission_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_permission" ADD CONSTRAINT "role_permission_permission_id_permission_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_role" ADD CONSTRAINT "user_role_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_role" ADD CONSTRAINT "user_role_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "permission_key_uidx" ON "permission" USING btree ("key");--> statement-breakpoint
CREATE INDEX "permissionImplication_implied_idx" ON "permission_implication" USING btree ("implied_permission_id");--> statement-breakpoint
CREATE UNIQUE INDEX "role_key_uidx" ON "role" USING btree ("key");--> statement-breakpoint
CREATE INDEX "roleParent_parent_idx" ON "role_parent" USING btree ("parent_role_id");--> statement-breakpoint
CREATE INDEX "rolePermission_permission_idx" ON "role_permission" USING btree ("permission_id");--> statement-breakpoint
CREATE INDEX "userRole_role_idx" ON "user_role" USING btree ("role_id");--> statement-breakpoint
-- Carry the single state each account proved into the new list before 0005 drops it.
UPDATE "identity_evidence" SET "states" = ARRAY["state"] WHERE "state" IS NOT NULL;--> statement-breakpoint
-- The roles the identity provider defines itself. Membership follows identity evidence:
-- the application never writes user_role rows for these. Names, descriptions, permissions,
-- and hierarchy are administrator-editable from here on, so this seed never runs again.
INSERT INTO "role" ("id", "key", "name", "description", "managed", "source_state") VALUES
('static-role-socio', 'socio', 'Socio', 'Member of PoliNetwork APS.', true, 'socio'),
('static-role-direttivo', 'direttivo', 'Direttivo', 'Member of the PoliNetwork APS board.', true, 'direttivo'),
('static-role-student', 'student', 'Student', 'Verified Politecnico di Milano student.', true, 'student')
ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint
-- The two permissions this service already put in tokens, so existing consumers keep working.
INSERT INTO "permission" ("id", "key", "name", "description") VALUES
('seed-permission-membership-read', 'membership:read', 'Read membership', 'See that someone is a member of PoliNetwork APS.'),
('seed-permission-student-verified', 'student:verified', 'Verified student', 'See that someone verified a Politecnico di Milano student email.')
ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint
INSERT INTO "role_permission" ("role_id", "permission_id") VALUES
('static-role-socio', 'seed-permission-membership-read'),
('static-role-student', 'seed-permission-student-verified')
ON CONFLICT DO NOTHING;
1 change: 1 addition & 0 deletions drizzle/0005_left_lizard.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "identity_evidence" DROP COLUMN "state";
31 changes: 31 additions & 0 deletions drizzle/0006_volatile_pandemic.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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 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
-- The permissions covering this identity provider's own administration. The code checks
-- for these exact keys, so they can never be created or deleted by hand; which roles carry
-- them is entirely up to the administrator.
INSERT INTO "permission" ("id", "key", "name", "description", "managed") VALUES
('managed-permission-idp-people-read', 'idp:people:read', 'Find people', 'Search the people registered with this identity provider.', true),
('managed-permission-idp-permissions-read', 'idp:permissions:read', 'View permissions', 'See the permissions this identity provider defines.', true),
('managed-permission-idp-permissions-write', 'idp:permissions:write', 'Manage permissions', 'Create, change, and delete permissions, and choose what each one also grants.', true),
('managed-permission-idp-roles-read', 'idp:roles:read', 'View roles', 'See roles, what they grant, and who holds them.', true),
('managed-permission-idp-roles-write', 'idp:roles:write', 'Manage roles', 'Create, change, and delete roles, and give them to people.', true),
('managed-permission-idp-applications-read', 'idp:applications:read', 'View applications', 'See the applications that sign people in with PoliNetwork Identity.', true),
('managed-permission-idp-applications-write', 'idp:applications:write', 'Manage applications', 'Register applications, edit their redirect URIs and scopes, rotate secrets, and delete them.', true)
ON CONFLICT ("key") DO NOTHING;--> statement-breakpoint
-- Writing implies reading, managing roles implies finding the people to give them to, and
-- understanding a role means being able to see the permissions it carries.
INSERT INTO "permission_implication" ("permission_id", "implied_permission_id") VALUES
('managed-permission-idp-permissions-write', 'managed-permission-idp-permissions-read'),
('managed-permission-idp-roles-read', 'managed-permission-idp-permissions-read'),
('managed-permission-idp-roles-write', 'managed-permission-idp-roles-read'),
('managed-permission-idp-roles-write', 'managed-permission-idp-people-read'),
('managed-permission-idp-applications-write', 'managed-permission-idp-applications-read')
ON CONFLICT DO NOTHING;
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