Enterprise-grade security for the Forge client/server platform on AWS ECS Fargate, storing sensitive academic data: students, mentors, teachers, reviews, feedback, concerns, and performance metrics. Security is a first-class concern, not a phase. This document is the authoritative reference for authentication, sessions, authorization, data isolation, AWS account isolation, app security, secrets, audit, and threat modeling.
Cross-references:
architecture-v2.md(system design, services, data model) Β·infra-ecs.md(VPC, ECS, RDS, ALB, KMS, Secrets) Β·integration-setup.md(GitHub, Discord, Google Calendar credentials & webhooks).
Internet
β (TLS 1.2+)
βΌ
βββββββββββββββ Cloudflare (WAF, DDoS, TLS termination edge)
β Cloudflare β
βββββββββββββββ
β (TLS, origin cert / mTLS to ALB)
βΌ
βββββββββββββββ Public subnets only
β ALB β (HTTPS:443, HSTS, security groups)
βββββββββββββββ
β (TLS in VPC)
βββββββββββββ΄ββββββββββββ
βΌ βΌ Private subnets (no public IPs)
ββββββββββββββ ββββββββββββββ
β Next.js β ββββΆ β Express + β ALL authz + business logic + integrations
β (ECS) β HTTP β TS API β live here. Frontend never reaches DB/3P.
β SSR/UI β β (ECS) β
ββββββββββββββ βββββββ¬βββββββ
β (TLS, sslmode=require)
βββββββββββ΄ββββββββββ
βΌ βΌ Private/isolated subnets
ββββββββββββ ββββββββββββ
β RDS β β Redis β (optional: sessions, cache, rate limit)
β Postgres β β Elasticache
ββββββββββββ ββββββββββββ
Canonical rule: the frontend (Next.js) never calls the database or external integrations directly. It calls the Express API. All authorization and business logic are server-side. Anything rendered client-side that depends on permissions is a UI hint only.
| Principle | How it shows up here |
|---|---|
| Default deny | No route, action, or row is accessible unless a policy explicitly grants it. Unknown = rejected. |
| Least privilege | IAM, security groups, OAuth scopes, DB grants, and roles all grant the minimum required. |
| Defense in depth | Authz enforced at three layers (route gate β policy β query scope); secrets at rest + in transit; network + app + data controls stack. |
| Server-authoritative | The browser holds only an opaque session id. No JWT, no role, no permission, no token in browser storage is ever trusted. |
| Zero blast radius | This platform shares an AWS account with unrelated services. It is isolated such that a full compromise here cannot reach them (Β§6). |
| Auditable | Every privileged/state-changing action writes an immutable AuditLog row in the service layer (Β§10). |
| Fail closed | On any auth/authz/validation error β 401/403/400, never partial data. Errors never leak internal structure. |
| Sensitive-data minimization | Anonymous concerns, scoped PII access, retention windows, minimized integration scopes/intents. |
Posture assumption: thousands of users, multiple academic domains, sensitive minors' performance data, a shared multi-tenant AWS account. Designed for university-grade scrutiny and external security audit.
Decision: Google OAuth via OIDC is the only authentication method. No email/password, no self-signup, no custom credentials, no magic links. The server runs the OIDC authorization-code flow, validates the ID token, and gates access on two independent conditions.
| Concern | Why Google-OIDC-only wins |
|---|---|
| Password storage | None to store β no hashing, no leaks, no reset flows, no credential-stuffing surface. |
| MFA | Inherited from the user's Google/Workspace account (institution-enforced 2FA). |
| Phishing resistance | Leverages Google's anti-phishing and risk signals. |
| Provisioning control | Access is admin-allowlist only β being a valid Google user is necessary but not sufficient. |
| Account lifecycle | De-provisioning is a DB operation; no orphaned passwords. |
Browser Next.js (ECS) Express API (ECS) Google OIDC RDS Postgres
β GET /login β β β β
βββββββββββββββββββββΆβ β β β
β β start auth (server) β β β
β βββββββββββββββββββββββΆβ β β
β β redirect w/ state, β generate state+ β β
β β nonce, PKCE β nonce+PKCE (stored β β
β 302 to Google ββββΌβββββββββββββββββββββββ€ server-side) β β
ββββββββββββββββββββββ β β β
β authenticate + consent at accounts.google.com ββββββββββββββββββΆβ β
ββββββ 302 back to /api/auth/callback?code=β¦&state=β¦ ββββββββββββββ β
β GET /api/auth/callback?code,state β β β
ββββββββββββββββββββββββββββββββββββββββββββΆβ β β
β β verify state match β β
β β exchange code + β β
β β PKCE verifier βββββΆβ /token β
β βββββ id_token, β β
β β access_token, β β
β β refresh_token β β
β β validate id_token β β
β β (sig via JWKS, β β
β β iss/aud/exp/nonce)β β
β β check hd claim βββββΌβββββββββββββββββββββ€
β β lookup email in ββΌβββΆ SELECT β¦ users β
β β users (allowlist) β WHERE email=? β
β ββββββ role+perms βββββΌβββββββββββββββββββββ€
β β store OAuth tokens β β
β β server-side; createβ β
β β session; set cookieβ β
ββββββ 302 /dashboard Set-Cookie: sid=opaque; HttpOnly; Secure βββ β
| # | Check | Detail |
|---|---|---|
| 1 | Signature | Verify JWS against Google's JWKS (https://www.googleapis.com/oauth2/v3/certs); cache keys, honor kid rotation. |
| 2 | iss |
Equals https://accounts.google.com or accounts.google.com. Reject anything else. |
| 3 | aud |
Equals our Google OAuth client id. Reject tokens minted for other clients. |
| 4 | exp |
Not expired (with small clock-skew leeway, e.g. β€ 60s). |
| 5 | iat / nbf |
Issued in the past (within skew); not yet-to-be-valid. |
| 6 | nonce |
Equals the server-generated nonce bound to this auth request (replay protection). |
| 7 | hd (hosted domain) |
Equals an allowed institution domain (e.g. rishihood.edu.in). Gate (a). |
| 8 | email_verified |
Must be true. |
| 9 | DB allowlist | email exists in the users table (admin-provisioned). Gate (b). |
| 10 | state + PKCE |
state matches the stored value; PKCE code_verifier validates the exchange. |
Access is granted only if BOTH hold:
(a) Google hd claim β { rishihood.edu.in, β¦ } β right institution
AND
(b) email β users table (admin pre-created) β explicitly provisioned
ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β Email β Outcome β
ββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ€
β taj@rishihood.edu.in β β
hd ok + in users β ALLOW β
β guest@rishihood.edu.in β β hd ok but NOT in users β DENY β
β random@gmail.com β β wrong hd (+ not in users) β DENY β
ββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββ
Belonging to the institution's Google domain is necessary but not sufficient. The DB allowlist is authoritative for who may enter and what they may do.
On a successful login, the user's role(s) and scopes are loaded from the users / user_roles tables β never from anything in the OAuth token. The token proves identity; the DB decides authority. A server-side session is then created (Β§3).
Admin β Users β Add user
β enter email (institution domain), role, scope (domain/team), display name
βΌ
INSERT users(email, role, scope, status='active') β allowlist entry
βΌ
User can now log in with Google. No invite email or password is required.
- First login needs no password change β identity is Google's; there is nothing to set. The first successful OIDC login simply activates the session and (optionally) stamps
users.firstLoginAt. - Admin-only:
user:create,user:import(bulk CSV β same allowlist rows),role:assign.
Admin β Users β Deactivate / Delete
βΌ
UPDATE users SET status='disabled' (or hard-delete)
βΌ
- Future logins: DB allowlist check (gate b) fails β DENY.
- Existing sessions: revoked immediately via session-version bump (Β§3.6).
- Server-side OAuth tokens for that user are revoked + purged.
- AuditLog row written (actor=admin, action=user.deactivate).
Sessions are server-side and opaque. The browser holds nothing sensitive.
| Option | Use |
|---|---|
| Redis (preferred) | Primary session store + idle-timeout TTLs + rate-limit counters. (ElastiCache, private subnet.) |
| Postgres (fallback) | sessions table when Redis is not provisioned. Same semantics, TTL via expiry column + sweep. |
The session record holds: sessionId (opaque, β₯128-bit random), userId, sessionVersion, createdAt, lastSeenAt, absoluteExpiry, ip, userAgent, and a server-side reference to the OAuth access_token / refresh_token.
Set-Cookie: sid=<opaque-random>;
HttpOnly; β JS cannot read it (XSS-resistant)
Secure; β HTTPS only
SameSite=Lax; β CSRF mitigation (Strict for admin paths)
Path=/; β scoped
Max-Age=<rolling>; β rolling expiry
- No JWT, no tokens, no role, no PII in cookies or
localStorage/sessionStorage. The cookie value is a meaningless opaque id; all meaning lives server-side. - OAuth
access/refreshtokens are stored server-side only, encrypted at rest (KMS, Β§8). They are never sent to the browser.
| Timeout | Default | Behavior |
|---|---|---|
| Idle | 30 min | No activity β session invalidated. Tracked via lastSeenAt / Redis TTL. |
| Rolling | per request | Each authenticated request refreshes the idle window (and cookie Max-Age). |
| Absolute | 12 h | Hard cap regardless of activity β re-authenticate with Google. |
When the Google access_token nears expiry, the server uses the stored refresh_token to obtain a new one and updates the server-side record. The browser is never involved and never sees a token.
State-changing requests (POST/PUT/PATCH/DELETE) are protected by two mechanisms:
SameSitecookie (Lax/Strict) β blocks most cross-site cookie attachment.- Double-submit / synchronizer token β the API issues a CSRF token; the client echoes it in a header (
X-CSRF-Token); the server compares it to the session-bound value. Origin/Referer is also checked for sensitive endpoints.
Logout: DELETE session(sid) from store; clear cookie; revoke OAuth tokens; audit.
Log out everywhere: UPDATE users.sessionVersion += 1
β every existing session whose stored version < current is invalidated
on its next request. Used on suspension, role change, suspected compromise.
- Clock skew: ID-token
exp/iat/nbfvalidated with β€ 60s leeway; ECS tasks sync via Amazon Time Sync. - Replay: OIDC
nonce+stateare single-use and bound to the originating request; reused/forged values are rejected. Session ids are high-entropy and non-guessable; cookie theft is mitigated byHttpOnly/Secure/short idle window + version bump on suspicion.
Authorization answers two questions independently: what may this role do? (action) and to which records? (scope). They are combined in a single policy function and enforced at three layers, server-side.
| Role | Authority |
|---|---|
| Admin | Global. Everything, including system configuration and audit logs. |
| LCC | Global read + coordination (concerns, emails, analytics). No system config. |
| Teacher | One or more academic domains (teachers can span multiple domains). |
| Mentor | Assigned team + mentees. The Student Mentor leads the team (mentee-management and team-delivery). |
| Mentee | Self only. |
β οΈ Callout β Team Lead is NOT a 6th role. A refactor prompt re-listed "Team Lead" as a sixth role, but a prior product decision merged Team Lead into the Mentor: the Student Mentor leads the team, holding both mentee-management and team-delivery permissions. This system uses 5 roles. Re-adding Team Lead later is a small config change (one role entry + matrix column + ascopeWherebranch) if and only if the product owner explicitly confirms it. Until then, do not implement a 6th role.
GLOBAL (Admin, LCC) all records
β DOMAIN:<id> (Teacher) records under a domain (β₯1 per teacher)
β TEAM:<id> (Mentor) records under an assigned team
β SELF (Mentee) records the user owns
GLOBAL β DOMAIN β TEAM β SELF, matched by id. A user may hold multiple scoped roles; effective access = union of scopes for the action's required permission. (A teacher with two domains β DOMAIN:1 βͺ DOMAIN:2.)
Legend: β full/global Β· π΅ scoped (own assigned domain/team/self) Β· π read-only Β· β none
resource:action |
Admin | LCC | Teacher | Mentor | Mentee |
|---|---|---|---|---|---|
user:create / user:import |
β | β | β | β | β |
user:deactivate |
β | β | β | β | β |
role:assign |
β | β | β | β | β |
domain:manage |
β | π | π΅(own) | β | β |
team:manage |
β | π΅ | π΅(domain) | π(own) | π(own) |
config:edit (phases/gates/cycles/thresholds/rubrics) |
β | β | π΅(domain rubric) | β | β |
menteeUpdate:submit (L1) |
β | β | β | β | π΅ |
mentorStatus:submit (L2) |
β | π | π | π΅ | β |
weeklyReview:l3Submit |
β | π | π | π΅ | β |
weeklyReview:l4Submit |
β | π | π΅ | π | β |
gate:decide |
β | π | π΅ | β | β |
review:read |
β | π | π΅(domain) | π΅(team) | π΅(self) |
feedback:submit (360Β°) |
β | π | π | π΅ | π΅ |
task:assign |
β | π΅ | π΅ | π΅ | β |
deliverable:review |
β | π | π΅ | π΅ | β |
deliverable:submit |
β | β | β | π΅ | π΅ |
concern:raise |
β | β | β | β | β |
concern:triage / concern:resolve |
β | β | π΅(domain) | β | β |
concern:readAnonymous |
β | β | β | β | β |
email:send / email:bulkSend |
β | β | π΅(domain) | π΅(team) | β |
emailTemplate:manage |
β | π΅ | β | β | β |
analytics:global |
β | β | β | β | β |
analytics:domain |
β | β | π΅ | β | β |
analytics:team |
β | β | π΅ | π΅ | β |
performanceMetric:read |
β | π | π΅(domain) | π΅(team) | π΅(self) |
auditLog:read |
β | π(scoped) | β | β | β |
integration:manage |
β | π | β | β | β |
config:system (KMS/secrets/feature flags) |
β | β | β | β | β |
// server/authz/policy.ts β the single decision point
can(user: AuthContext, action: Permission, resource?: Resource): booleanStep 1 β ROLE check : does any of user.roles grant `action`? else β false
Step 2 β SCOPE check : if `resource` given, does the granting role's
scope cover it? GLOBAL β DOMAIN β TEAM β SELF,
matched by id (union across the user's roles) else β false
Step 3 β OWNERSHIP : for SELF actions, resource.ownerId === user.id else β false
for DOMAIN/TEAM, resource.domainId/teamId β user's assigned set
Return true only if all applicable steps pass.
The same can() is called by the server (authoritative) and by the UI (hint only β never trusted; the server re-decides every time).
Request (cookie sid)
β
βΌ
LAYER 1 β Route gate (Express middleware)
β authenticate(sid) β AuthContext; is this route allowed for the role? βββΆ 401/403
βΌ
LAYER 2 β Policy check
β zod.validate(input) βββΆ 400
β can(ctx, action, resource) βββΆ 403
βΌ
LAYER 3 β DB-query scoping
β service.method(ctx, β¦) where every query is wrapped in scopeWhere(ctx) βββΆ rows
β β out-of-scope rows are physically unreachable even if layers 1β2 had a bug
βΌ
audit(ctx, action, before/after) β typed response (no internal leakage)
ββββββββββββββββββββββββ
request βββΆβ valid session? (sid) βββnoβββΆ 401
ββββββββββββ¬ββββββββββββ
β yes
ββββββββββββΌββββββββββββ
β route allowed for βββnoβββΆ 403
β role? (gate) β
ββββββββββββ¬ββββββββββββ
β yes
ββββββββββββΌββββββββββββ
β input valid? (zod) βββnoβββΆ 400
ββββββββββββ¬ββββββββββββ
β yes
ββββββββββββΌββββββββββββ
β can(user,action,res) βββnoβββΆ 403
ββββββββββββ¬ββββββββββββ
β yes
ββββββββββββΌββββββββββββ
β scopeWhere filters ββββΆ only in-scope rows
β the DB query β
ββββββββββββ¬ββββββββββββ
βΌ
audit + response
- Teacher (multi-domain): a teacher assigned to domains
{7, 9}listing students βscopeWhereresolves to{ team: { domainId: { in: [7,9] } } }. Students in domain12are unreachable. Teachers can span multiple domains β the filter isin, not=. - Mentor: listing reviews β
{ menteeId: { in: assignedMenteeIds(me) } }(i.e.TEAM:<assignedTeamId>). Mentees on other teams are unreachable. The Student Mentor also sees team-delivery resources for their team only. - Mentee: listing tasks/reviews/feedback β
{ ownerId: me }(SELF). Cannot see anyone else's records. - Admin:
GLOBALβ no scope filter.
Authorization is not only a route check. Every service method injects the caller's scope into the SQL/ORM query, so a logic bug elsewhere still cannot return out-of-scope rows.
// server/authz/scope.ts β returns the Prisma `where` for the caller's highest applicable scope
function listTeams(ctx: AuthContext) {
return prisma.team.findMany({
where: scopeWhere(ctx, {
global: {}, // Admin / LCC
domain: { domainId: { in: ctx.assignedDomainIds } }, // Teacher (multi-domain)
team: { id: { in: ctx.assignedTeamIds } }, // Mentor
self: { members: { some: { userId: ctx.userId } } }, // Mentee
}),
});
}// Reviews β a Mentor physically cannot read another team's reviews
function listReviews(ctx: AuthContext) {
return prisma.review.findMany({
where: scopeWhere(ctx, {
global: {},
domain: { team: { domainId: { in: ctx.assignedDomainIds } } },
team: { menteeId: { in: ctx.assignedMenteeIds } },
self: { menteeId: ctx.userId },
}),
});
}- Domain isolation:
domainId β assignedDomainIds. Cross-domain reads are impossible at the query layer. - Team isolation:
teamId β assignedTeamIds. Cross-team reads impossible. - Ownership checks: for
SELFactions and for mutations, the service re-verifiesresource.ownerId === ctx.userIdbefore write β preventing IDOR even on direct-id access (GET /reviews/:id).
scopeWhere() selects the highest applicable scope for the caller and always applies a filter (default deny: an unrecognized scope yields an impossible WHERE clause, not an open one).
This account also runs OTHER unrelated services. This platform must have ZERO blast radius into them. "Lateral movement into other tenants' services in the shared account" is treated as an explicit, first-class threat. See
infra-ecs.mdfor the concrete Terraform.
AWS Account (shared)
β
βββ βββ Forge boundary βββ (dedicated, tagged app=forge)
β β
β βββ Dedicated VPC (own CIDR) ββ no peering, no Transit GW to other VPCs
β β βββ Public subnets β ALB only
β β βββ Private subnets β ECS (Next.js, Express) [no public IPs]
β β βββ Isolated subnets β RDS, Redis [not publicly reachable]
β β
β βββ Dedicated Security Groups (least privilege, Β§6.2)
β βββ Dedicated IAM roles (ARN-scoped + tag-conditioned, no wildcards, Β§6.3)
β βββ Dedicated KMS CMK (RDS, Secrets, EBS, S3)
β βββ Secrets Manager namespace /forge/* (Β§6.4, Β§9)
β
βββ βββ OTHER services βββ ββ separate VPCs / IAM / KMS / Secrets
β²
βββββ UNREACHABLE from Forge: no SG rule, no IAM grant, no network path.
| SG | Inbound | Outbound |
|---|---|---|
sg-alb |
443 from Cloudflare IP ranges only | 80/443 β sg-ecs-web only |
sg-ecs-web (Next.js) |
from sg-alb only |
β sg-ecs-api only (+ 443 to NAT for OIDC/integrations) |
sg-ecs-api (Express) |
from sg-ecs-web only |
β sg-rds (5432), sg-redis (6379), 443βNAT (Google/GitHub/Discord) |
sg-rds |
5432 from sg-ecs-api only |
none |
sg-redis |
6379 from sg-ecs-api only |
none |
No 0.0.0.0/0 ingress anywhere except the ALB's Cloudflare-restricted 443. No security group references any SG outside the Forge boundary.
- Task roles can read only
/forge/*secrets and use only the Forge CMK. - No
iam:*, nosts:AssumeRoleinto other roles, no cross-services3:*/ec2:*wildcards. - Conditions pin every grant to
aws:ResourceTag/app = forge.
- Secrets Manager: all secrets under
/forge/*; task roles cannot read other namespaces. - Dedicated KMS CMK encrypts RDS, Secrets, EBS volumes, and any S3 buckets β its key policy grants decrypt only to Forge roles.
- Private subnets / no public IPs for ECS; RDS is not publicly accessible (no public route, isolated subnet group).
- No VPC peering / Transit Gateway to other services' VPCs. Egress only via NAT to named external endpoints (Google, GitHub, Discord).
Explicit guarantee: a full compromise of the Forge application (RCE in the Express container) yields, at most, the Forge CMK-decryptable data and
/forge/*secrets within the Forge VPC. It cannot read other services' secrets, decrypt their data, assume their IAM roles, or reach their networks. Blast radius is contained to Forge.
| Threat | Mitigation |
|---|---|
| CSRF | SameSite=Lax/Strict cookie + double-submit/synchronizer CSRF token on state-changing requests; Origin/Referer check on sensitive endpoints (Β§3.5). |
| XSS | React auto-escaping; sanitize rich text (emails/announcements/concern bodies) with an allowlist sanitizer; never dangerouslySetInnerHTML on user input. |
| SQL injection | Prisma parameterized queries only; no raw string-interpolated SQL with user input. |
| Input validation | Zod schema at every boundary (route handlers, webhooks, integration callbacks); reject unknown fields; coerce + bound types. |
| Output encoding | Encode on render; strip/sanitize HTML from untrusted sources; JSON responses are typed, no internal field leakage. |
| Secure file uploads | Type + size allowlist, AV scan before acceptance, store in S3 (KMS-encrypted), serve via signed time-limited URLs, never executable, never served from the app origin. |
| Rate limiting | Per-IP and per-user token-bucket (Redis) on auth callback + all write endpoints + webhooks; exponential backoff on abuse. |
| Security headers | Content-Security-Policy (strict, nonce-based), Strict-Transport-Security (HSTS, preload), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy. |
| Webhook signature verification | GitHub: verify X-Hub-Signature-256 HMAC against GITHUB_WEBHOOK_SECRET. Discord: verify Ed25519 signature against DISCORD_PUBLIC_KEY. Reject on mismatch + reject replays (timestamp/id dedupe). See integration-setup.md. |
| SSRF | Integration calls go to fixed, allowlisted provider hosts only; no user-supplied URLs are fetched server-side; egress restricted by SG/NAT to named endpoints (Β§6.2). |
User ββTLSβββΆ Cloudflare ββTLS (origin cert / mTLS)βββΆ ALB ββTLSβββΆ ECS (Next.js)
ββTLSββΆ ECS (Express)
Express ββTLS (sslmode=require)βββΆ RDS Postgres
Express ββTLSβββΆ Redis (in-transit encryption enabled)
TLS 1.2+ everywhere. RDS connection string enforces sslmode=require (verify-full where the CA is pinned).
- RDS: encrypted with the Forge KMS CMK.
- Secrets Manager: encrypted with the same CMK.
- EBS (ECS task ephemeral/volumes) and S3 (uploads): CMK-encrypted.
- OAuth
access/refreshtokens and integration credentials: encrypted at rest server-side.
- PII access (student/mentor identities, performance metrics, feedback) is gated by RBAC scope (Β§4βΒ§5).
- Anonymous concerns:
Concern.anonymous = truestrips reporter identity from all reads except Admin/LCC underconcern:readAnonymous; raiser identity is not stored against the visible record. - Retention: configurable windows per data class; expired records purged/anonymized by a scheduled job; audit logs retained per policy.
- AWS Secrets Manager, namespaced under
/forge/*(e.g./forge/db,/forge/google-oidc,/forge/github,/forge/discord,/forge/session). - Rotation enabled (DB credentials, integration secrets) via Secrets Manager rotation.
- Injected into ECS tasks at runtime (task definition
secrets:β env), read via the ARN-scoped task role (Β§6.3). Never baked into images or committed env files in prod. - Local dev uses git-ignored
.env; onlyNEXT_PUBLIC_*variables ever reach the browser bundle β server secrets (OIDC client secret, webhook secrets, bot tokens, DB URL) are server-only. Seeintegration-setup.mdfor which keys are required.
AuditLog is append-only / immutable (insert-only table; no UPDATE/DELETE grant to the app role).
| Field | Meaning |
|---|---|
actorId |
who (user) β or system |
action |
e.g. auth.login, user.create, role.assign, concern.resolve |
entityType / entityId |
what was affected |
before / after (JSON) |
state delta |
ip / userAgent |
request origin |
ts |
server timestamp |
Tracked events: login / logout (and OIDC denials), role & permission/scope changes, user CRUD + de-provisioning, concern actions (raise/triage/resolve, incl. anonymous), email sends / bulk sends / template changes, integration connect/disconnect, config changes, and all admin actions.
Audit writes happen in the service layer (the last step of the request lifecycle, Β§4.5), so no privileged action can bypass logging. Admin β Audit Logs reads with filters (actor, action, entity, date).
| # | Threat (STRIDE) | Vector | Mitigation |
|---|---|---|---|
| 1 | Stolen session cookie (Spoofing) | XSS / device theft / network sniff | HttpOnly + Secure + SameSite; opaque high-entropy id; idle + absolute timeouts; sessionVersion bump to mass-revoke; TLS everywhere. |
| 2 | OAuth replay / code interception (Spoofing/Tampering) | Reused code/id_token, forged callback |
PKCE; single-use state + nonce; full ID-token validation (sig/iss/aud/exp/nonce); HTTPS callback only. |
| 3 | Privilege escalation / IDOR across domains/teams (Elevation) | Tampered ids, forced browsing GET /reviews/:id |
Three-layer authz; can() + ownership re-check; scopeWhere on every query β out-of-scope rows physically unreachable. |
| 4 | Forged login / unknown user (Spoofing) | Valid Google user not provisioned, wrong domain | Dual gate: hd claim AND DB allowlist; random@gmail.com and unprovisioned institution emails both rejected. |
| 5 | Webhook spoofing (Spoofing/Tampering) | Fake GitHub/Discord payloads | GitHub HMAC + Discord Ed25519 signature verification; replay dedupe; Zod validation. |
| 6 | Secret leakage (Information disclosure) | Secrets in images / client bundle / logs | Secrets Manager /forge/* injected at runtime; only NEXT_PUBLIC_* to browser; KMS at rest; secrets scrubbed from logs; rotation. |
| 7 | SSRF from integrations (Tampering/Info disclosure) | User-supplied URL fetched server-side | No user-controlled outbound fetch; allowlisted provider hosts only; egress restricted via SG/NAT to named endpoints. |
| 8 | Supply chain (Tampering) | Malicious/compromised dependency | Pinned lockfiles, SCA scanning, image scanning, minimal base images, least-privilege task role limits impact. |
| 9 | Lateral movement into OTHER AWS services in the shared account (Elevation) | RCE in container β pivot to co-tenant services | Dedicated VPC/SG/IAM/KMS/Secrets, ARN-scoped + tag-conditioned IAM (no wildcards), no VPC peering, RDS not public β zero blast radius (Β§6). |
| 10 | DoS / brute force (Denial of service) | Flood auth / write / webhook endpoints | Cloudflare WAF + DDoS; per-IP/user rate limiting (Redis); ALB; autoscaling. |
| 11 | Repudiation (Repudiation) | "I didn't do that" | Immutable AuditLog with actor/action/before/after/ip/ts written in the service layer. |
| 12 | Data exfiltration at rest (Info disclosure) | Stolen snapshot / volume | KMS CMK encryption (RDS/Secrets/EBS/S3); CMK key policy limited to Forge roles. |
- MFA: inherent β Google/Workspace accounts enforce the institution's 2FA. No separate MFA to build.
- SSO: already Google (OIDC). Additional providers (Microsoft/other Workspace orgs) would be additive, behind the same dual-gate allowlist.
- Fine-grained permissions (future): the permission set is data-extensible (
resource:actionstrings); new permissions and scopes slot into the matrix +can()without rewrites. - Re-adding Team Lead (future): small config change (Β§4.1) β only if the product owner confirms; default remains 5 roles.
- Security audits / compliance (future): immutable audit log, scoped access, encryption, and account isolation are already in place to support external review and penetration testing.
See also:
architecture-v2.mdΒ·infra-ecs.mdΒ·integration-setup.md.