Skip to content

Commit 98b6377

Browse files
committed
feat(sec): expand rbac enforcement and telemetry
1 parent b9e80e9 commit 98b6377

27 files changed

Lines changed: 1830 additions & 438 deletions

docs/04-backlog.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@
3838
"Integration tests cover allow/deny matrix for critical tables",
3939
"Admin console exposes role assignment behind flag"
4040
],
41+
"subtasks": [
42+
"Schema hardening: add foreign keys, composite keys, and indexes for roles/profile_roles/spaces tables",
43+
"Space model: create spaces, space_members, and space_rules with canonical role slugs",
44+
"Content model: ensure posts, post_versions, comments reference spaces and enforce NOT NULL slugs",
45+
"RLS deny-by-default: implement policies for identity, community, content, safety, platform tables",
46+
"Helper SQL: highest_role(profile_id) + legacy slug mapping function with unit tests",
47+
"App guards: reuse requireAdmin across admin routes, emit telemetry + audit logs",
48+
"Admin UI: role manager behind rbac_hardening_v1 with audit trail",
49+
"Telemetry: authz_denied_count dashboard panel + tagged increments",
50+
"Tests: integration matrix role×action×table + admin route unit tests",
51+
"Accessibility: axe scan for role manager surface",
52+
"Docs: security matrix update, RLS denial spike runbook, data model deltas"
53+
],
4154
"definition_of_done": [
4255
"Green CI, tests added",
4356
"Docs updated (auth matrix in /docs/07-security-privacy.md)",
@@ -63,6 +76,14 @@
6376
"Design tokens defined for color, spacing, typography, elevation",
6477
"Dark/light mode parity maintained"
6578
],
79+
"subtasks": [
80+
"Tokens: extend Tailwind theme for color, spacing, typography, elevation, radius with dark parity",
81+
"Global nav: expose Spaces, Feeds, Events, Funding, Projects, Admin via nav_ia_v1 + RBAC checks",
82+
"Content page application: apply tokens to at least one content layout",
83+
"Accessibility: skip link, focus-visible styles, keyboard trap tests, axe scans",
84+
"Storybook: SpaceHeader, TemplatePickerModal, DonationWidget, EventCard token usage",
85+
"Docs: update /docs/05-ui-ux-delta.md with token tables, IA screenshots, a11y notes"
86+
],
6687
"definition_of_done": [
6788
"Green CI, tests added",
6889
"Docs updated in /docs/05-ui-ux-delta.md",
@@ -313,6 +334,15 @@
313334
"Distributed tracing instrumented for major services",
314335
"Dashboards and alerting policies documented"
315336
],
337+
"subtasks": [
338+
"Metrics: emit content_publish_latency_ms, flag_evaluation_latency_ms, authz_denied_count, crash_free_sessions",
339+
"Tracing: OpenTelemetry spans for publish + admin routes annotated with space_id/post_id/flag_keys",
340+
"Logging: structured JSON with request_id, hashed user_id, feature flag context",
341+
"Dashboards: Executive + Operations panels covering new metrics",
342+
"Alerts: configure thresholds per /docs/08-observability.md routed to on-call",
343+
"Synthetic: Playwright journeys for home/admin/publish with artifact retention",
344+
"Docs: update observability runbook with identifiers and rollout notes"
345+
],
316346
"definition_of_done": [
317347
"Green CI, tests added",
318348
"Docs updated (/docs/08-observability.md)",

docs/06-data-model-delta.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
| `automod_rules` | Per-space automation | `id`, `space_id`, `rule_type`, `config`, `enabled`, `created_at` | `rule_type` enum (rate_limit, first_post, banned_domain, trust_score) |
2121
| `sanctions` | Records enforcement | `id`, `space_id`, `profile_id`, `type`, `reason`, `status`, `expires_at`, `created_by` | `type` enum (removal, quarantine, shadow_ban, space_ban, site_ban) |
2222
| `audit_logs` | Immutable log for staff actions | `id`, `actor_id`, `actor_role`, `entity_type`, `entity_id`, `action`, `metadata`, `created_at` | Store hashed chain for immutability |
23+
24+
> 2025-10-24: Created baseline `audit_logs` table with service-role write policy and admin read access to support SEC-001 guard telemetry.
25+
> 2025-10-31: Hardened SEC-001 scope with community scaffolding. Added `spaces` (slug, name, visibility, created_by, timestamps), `space_members` (space_id, profile_id, role_id, status, joined_at, last_seen_at), `space_rules` (space_id, title, body, created_by, timestamps), `post_versions` (post_id, version_number, content JSONB, metadata JSONB, created_by, created_at), and `reports` (reporter_profile_id, subject_type/id, reason, status, space_id, timestamps). Added helper functions `normalize_role_slug`, `highest_role_slug`, `user_space_role_at_least` to back policies.
26+
27+
### Index & Policy Update — 2025-10-31
28+
29+
- Indexes: `space_members(role_id, status)`, `space_members(space_id, role_id)`, `spaces(visibility)`, `posts(space_id, status)`, `posts(space_id, published_at DESC)`, `comments(thread_root_id, created_at DESC)`, `reports(space_id, status)`, `reports(subject_type, subject_id)`.
30+
- Constraints: canonical slug check on `roles.slug`; composite PK enforced on `space_members`.
31+
- RLS: deny-by-default policies now depend on helper functions to gate CRUD by canonical role ladder across `spaces`, `space_members`, `space_rules`, `posts`, `post_versions`, `comments`, `reports`, `feature_flags`, `audit_logs`, `profile_roles`.
2332
| `donations` | Monetary contributions | `id`, `profile_id`, `target_type`, `target_id`, `amount`, `currency`, `fee_amount`, `donor_covers_fees`, `is_recurring`, `status`, `receipt_url`, `created_at` | Index on (`target_type`, `target_id`) |
2433
| `pledges` | Recurring commitments | `id`, `profile_id`, `target_type`, `target_id`, `interval`, `amount`, `currency`, `status`, `next_charge_at`, `cancelled_at` | |
2534
| `payment_methods` | Tokenized payment references | `id`, `profile_id`, `provider`, `external_id`, `status`, `last4`, `expires_at` | PII encrypted at rest |

docs/07-security-privacy.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727

2828
Full matrix with endpoint mapping maintained alongside Supabase policy definitions. Automated tests validate allow/deny paths per `/tests/security`.
2929

30+
**Admin Guard Hardening:** Unified admin API routes now delegate to `requireAdmin` in `src/lib/auth/require-admin.ts`, which resolves canonical roles, audits denials via `audit_logs`, and emits `authz_denied_count{resource,role,space,reason}`. Guard usage now extends to user management and gamification APIs, ensuring telemetry tags include `resource`, `role`, `space`, `reason` for Operations dashboards. RLS helper functions (`normalize_role_slug`, `user_space_role_at_least`, `highest_role_slug`) back deny-by-default policies across `spaces`, `space_members`, `space_rules`, `posts`, `post_versions`, `comments`, `reports`, `feature_flags`, and `audit_logs`.
31+
32+
**Runbook – RLS Denial Spike (2025-10-31):** If `authz_denied_count` surges, check Operations dashboard panel `op_rbac_denials` for `resource` + `space` tags. Use `/admin/audit` to confirm actor role assignments and `feature_flag_audit` for recent flag toggles. Validate helper functions are returning canonical slugs via Supabase SQL (`select public.highest_role_slug('<profile-id>'::uuid)`). Rollback: toggle `rbac_hardening_v1` off, apply migration `0020_sec_001_rls_policies.down.sql`, restore from PITR if required. Document incident in `/docs/operations/runbooks/rls-denial-spike.md` (to be created).
33+
3034
## 3. Input Validation & Sanitization
3135
- Use Zod schemas for all API inputs, with centralized validation utilities.
3236
- Sanitize rich text/HTML via vetted library (e.g., DOMPurify) server-side before storage.

docs/08-observability.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
| `webhook_delivery_success_rate` | Webhook successes vs. attempts | Gauge | `event_type` |
2323
| `automod_trigger_count` | Automod actions per rule | Counter | `rule_type`, `space` |
2424

25+
> 2025-10-31: Added `admin_publish_duration_ms` internal histogram for staff tooling responsiveness and began emitting `content_publish_latency_ms` from `/api/admin/posts`. Structured logs now include `user_id_hash`, `space_id`, and feature flag context for audit correlation.
26+
2527
## 3. Tracing Strategy
2628
- Instrument Next.js route handlers and server components with OpenTelemetry.
2729
- Propagate trace context through Supabase client calls using custom instrumentation wrappers.
@@ -34,8 +36,8 @@
3436
- Centralize logs via Logflare or OpenTelemetry Collector; set retention 30 days (longer for audit logs stored in DB).
3537

3638
## 5. Dashboards
37-
- **Executive KPI Dashboard:** Aggregates content latency, search performance, donation success, RSVP-to-attendance, crash-free sessions.
38-
- **Operations Dashboard:** Displays moderation queue age, automod triggers, authz failures, feature flag adoption.
39+
- **Executive KPI Dashboard (`dash_exec_kpi_v1`):** Aggregates content latency (panels for `content_publish_latency_ms`, `admin_publish_duration_ms`), crash-free sessions, and donation funnel placeholders.
40+
- **Operations Dashboard (`dash_ops_rbac_v1`):** Displays `authz_denied_count` (tagged by `resource`, `role`, `space`), moderation backlog, feature flag toggles (joining `feature_flag_audit`), and Playwright synthetic status.
3941
- **Commerce Dashboard:** Shows donation funnel, payout queue status, dispute rate.
4042
- **Events Dashboard:** Tracks registrations, attendance, revenue, NPS survey results.
4143
- **Reliability Dashboard:** SLO status, error budgets, incident history.
@@ -51,6 +53,8 @@
5153
| Crash-free drop | `crash_free_sessions` < 97% daily | Warning | Slack #frontend |
5254
| Webhook delivery failures | `webhook_delivery_success_rate` < 95% for 30m | Warning | Slack #integrations |
5355

56+
> Alert wiring (2025-10-31): Added PagerDuty service `pd-sec-ops` for publish latency and RBAC denial spikes (`authz_denied_count` > 25/min tagged `resource=admin_users`), Slack webhook `ops-telemetry` for nav IA checks.
57+
5458
## 7. SLOs & Error Budgets
5559
| Service | SLO | Error Budget |
5660
| --- | --- | --- |

docs/10-release-plan.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
## 1. Feature Flags
44
| Flag Key | Purpose | Default | Owner | Notes |
55
| --- | --- | --- | --- | --- |
6+
| `rbac_hardening_v1` | Locks down canonical role ladder, admin tooling | OFF | Security Lead | Staff-only until Phase-1 gate |
7+
| `nav_ia_v1` | Enables refreshed navigation IA + tokens | OFF | Design Lead | Staged rollout via staff cohort |
8+
| `observability_v1` | Surfaces observability UI surfaces | OFF | SRE Lead | Infra toggle, dashboards verified first |
69
| `spaces_v1` | Enables space creation, rules, membership | OFF | Product Lead | Phase 2 pilot with selected communities |
710
| `content_templates_v1` | Activates new editors/templates | OFF | Content PM | Depends on `spaces_v1` |
811
| `search_unified_v1` | Turns on new taxonomy/search service | OFF | Search PM | Requires index backfill |

docs/assumptions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@
1010
| A-006 | 2025-02-14 | Supabase Storage is sufficient for workshop materials initially; CDN integration optional later. | Keeps complexity low during MVP; monitor bandwidth usage. | Open |
1111
| A-007 | 2025-02-14 | Observability vendor (Grafana Cloud/Honeycomb) budget approved for Phase 1. | Required to meet telemetry commitments. | Open |
1212
| A-008 | 2025-02-14 | Legal/compliance resources available before Phase 3 commerce rollout. | Necessary for payments, KYC, events. | Open |
13+
| A-009 | 2025-10-24 | Design Lead owns `nav_ia_v1` rollout and SRE Lead owns `observability_v1` feature flag. | Owners not specified in release plan source; assigned to align with product area leads for accountability. | Open |
14+
| A-010 | 2025-10-31 | `space_membership_status` enum values (`active`, `invited`, `suspended`) are sufficient for Phase-1 moderation workflows. | SEC-001 scope only requires basic lifecycle states; additional states can be added with reversible migrations later. | Open |
15+
| A-011 | 2025-10-31 | Dashboard identifiers `dash_exec_kpi_v1` and `dash_ops_rbac_v1` will be provisioned by analytics; used as placeholders for documentation until Grafana workspace ready. | No IDs provided in spec; chosen to unblock observability references and can be updated post-provisioning. | Open |

docs/progress/weekly-2025-10-17.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,17 @@
2929
- Supabase-backed RBAC policy tests require dedicated credentials; tracked via `tests/security/rbac-policies.test.ts` until automated environment provisioned.
3030
- Need to wire Playwright admin journey + axe scan once SEC-001 flag graduates to pilot.
3131
- Coordinate with SEC-001 follow-ups for broader role analytics and audit dashboard panels.
32+
33+
## 2025-10-24 Addendum
34+
- **Shipped tickets:** Continued SEC-001 hardening (audit log schema + unified admin guard), backlog planning updates.
35+
- **Flags enabled:** None (all Phase-1 flags remain OFF; guard work validated locally only).
36+
- **KPI deltas:** No production delta; verified `authz_denied_count` increments via unit tests for new guard path.
37+
- **New risks/assumptions:** Documented ownership assumptions for `nav_ia_v1`/`observability_v1`; audit log schema awaiting broader RLS rollout.
38+
- **Next targets:** Extend guard adoption to remaining admin APIs, expand RLS policies for spaces/posts/comments, and begin UX-010 token groundwork once SEC-001 passes.
39+
40+
## 2025-10-31 Addendum
41+
- **Shipped tickets:** SEC-001 vertical slice expanded with Supabase migration `0020_sec_001_rls_policies` (spaces schema, helper functions, deny-by-default policies), admin user management audit logs, and publish latency instrumentation. UX-010 groundwork began by updating backlog subtasks for tokens/nav. OBS-100 seeded with structured logging + tracing helpers.
42+
- **Flags enabled:** `rbac_hardening_v1` — OFF (validated via unit/integration harness only); `nav_ia_v1` and `observability_v1` remain OFF pending UI work.
43+
- **KPI deltas:** Captured baseline `content_publish_latency_ms` (local publish flow ~420ms) and `authz_denied_count` tags for Operations dashboard `dash_ops_rbac_v1` dry run.
44+
- **New risks/assumptions:** Assumed initial `space_membership_status` enum (`active|invited|suspended`) and placeholder dashboard identifiers (`dash_exec_kpi_v1`, `dash_ops_rbac_v1`) pending analytics team confirmation.
45+
- **Next targets:** Ship UX-010 tokenized nav with skip link + axe automation, wire Playwright journeys for OBS-100, and finalize RLS integration tests once Supabase staging creds are provisioned.

src/app/api/admin/feature-flags/route.ts

Lines changed: 18 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,11 @@ import {
1111
invalidateFeatureFlagCache,
1212
upsertFeatureFlagCache,
1313
} from '@/lib/feature-flags/server'
14-
import { recordAuthzDeny } from '@/lib/observability/metrics'
15-
import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client'
14+
import { requireAdmin } from '@/lib/auth/require-admin'
15+
import { createServiceRoleClient } from '@/lib/supabase/server-client'
1616
import type { Database } from '@/lib/supabase/types'
1717
import type { AdminFeatureFlagAuditEntry, AdminFeatureFlagRecord } from '@/utils/types'
1818

19-
interface ProfileRecord {
20-
id: string
21-
is_admin: boolean
22-
}
23-
2419
const createFlagSchema = z.object({
2520
flagKey: z.enum(FEATURE_FLAG_KEYS),
2621
description: z.string().trim().min(1).max(280).optional(),
@@ -73,55 +68,6 @@ const mapAuditRow = (row: Database['public']['Tables']['feature_flag_audit']['Ro
7368
createdAt: row.created_at,
7469
})
7570

76-
export const requireAdminProfile = async (): Promise<{ profile: ProfileRecord } | { response: NextResponse }> => {
77-
const supabase = createServerClient()
78-
const {
79-
data: { user },
80-
error: authError,
81-
} = await supabase.auth.getUser()
82-
83-
if (authError) {
84-
return {
85-
response: NextResponse.json({ error: `Unable to load session: ${authError.message}` }, { status: 500 }),
86-
}
87-
}
88-
89-
if (!user) {
90-
recordAuthzDeny('feature_flag_admin', { reason: 'no_session' })
91-
return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
92-
}
93-
94-
const { data: profile, error } = await supabase
95-
.from('profiles')
96-
.select('id, is_admin')
97-
.eq('user_id', user.id)
98-
.maybeSingle<ProfileRecord>()
99-
100-
if (error) {
101-
return {
102-
response: NextResponse.json(
103-
{ error: `Unable to load profile: ${error.message}` },
104-
{ status: 500 },
105-
),
106-
}
107-
}
108-
109-
if (!profile) {
110-
recordAuthzDeny('feature_flag_admin', { reason: 'missing_profile', user_id: user.id })
111-
return { response: NextResponse.json({ error: 'Forbidden: admin access required.' }, { status: 403 }) }
112-
}
113-
114-
if (!profile.is_admin) {
115-
recordAuthzDeny('feature_flag_admin', {
116-
reason: 'forbidden',
117-
profile_id: profile.id,
118-
})
119-
return { response: NextResponse.json({ error: 'Forbidden: admin access required.' }, { status: 403 }) }
120-
}
121-
122-
return { profile }
123-
}
124-
12571
const buildAdminFlagSet = async (
12672
rows: Database['public']['Tables']['feature_flags']['Row'][] | null,
12773
): Promise<AdminFeatureFlagRecord[]> => {
@@ -158,10 +104,10 @@ const buildAdminFlagSet = async (
158104
}
159105

160106
export async function GET() {
161-
const result = await requireAdminProfile()
107+
const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'list' })
162108

163-
if ('response' in result) {
164-
return result.response
109+
if (!guard.ok) {
110+
return guard.response
165111
}
166112

167113
const serviceClient = createServiceRoleClient<Database>()
@@ -199,10 +145,10 @@ export async function GET() {
199145
}
200146

201147
export async function POST(request: Request) {
202-
const result = await requireAdminProfile()
148+
const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'create' })
203149

204-
if ('response' in result) {
205-
return result.response
150+
if (!guard.ok) {
151+
return guard.response
206152
}
207153

208154
const payload = await request.json().catch(() => ({}))
@@ -212,7 +158,7 @@ export async function POST(request: Request) {
212158
return NextResponse.json({ error: 'Invalid payload', details: parseResult.error.flatten() }, { status: 422 })
213159
}
214160

215-
const { profile } = result
161+
const { profile } = guard
216162
const { flagKey, description, owner, enabled, metadata, reason } = parseResult.data
217163
const defaults = FEATURE_FLAG_DEFAULTS[flagKey]
218164

@@ -270,7 +216,7 @@ export async function POST(request: Request) {
270216
previous_enabled: null,
271217
new_enabled: definition.enabled,
272218
changed_by: profile.id,
273-
changed_by_role: 'admin',
219+
changed_by_role: profile.roleSlug,
274220
reason: reason ?? 'created',
275221
metadata: {
276222
reason: reason ?? 'created',
@@ -295,10 +241,10 @@ export async function POST(request: Request) {
295241
}
296242

297243
export async function PATCH(request: Request) {
298-
const result = await requireAdminProfile()
244+
const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'update' })
299245

300-
if ('response' in result) {
301-
return result.response
246+
if (!guard.ok) {
247+
return guard.response
302248
}
303249

304250
const payload = await request.json().catch(() => ({}))
@@ -308,7 +254,7 @@ export async function PATCH(request: Request) {
308254
return NextResponse.json({ error: 'Invalid payload', details: parseResult.error.flatten() }, { status: 422 })
309255
}
310256

311-
const { profile } = result
257+
const { profile } = guard
312258
const { flagKey, description, owner, enabled, metadata, reason } = parseResult.data
313259

314260
const serviceClient = createServiceRoleClient<Database>()
@@ -371,7 +317,7 @@ export async function PATCH(request: Request) {
371317
previous_enabled: existing.enabled ?? false,
372318
new_enabled: definition.enabled,
373319
changed_by: profile.id,
374-
changed_by_role: 'admin',
320+
changed_by_role: profile.roleSlug,
375321
reason: reason ?? 'updated',
376322
metadata: {
377323
reason: reason ?? 'updated',
@@ -396,10 +342,10 @@ export async function PATCH(request: Request) {
396342
}
397343

398344
export async function PURGE() {
399-
const result = await requireAdminProfile()
345+
const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'purge' })
400346

401-
if ('response' in result) {
402-
return result.response
347+
if (!guard.ok) {
348+
return guard.response
403349
}
404350

405351
invalidateFeatureFlagCache()

0 commit comments

Comments
 (0)