From 98b6377563b2b0d6e7b232ee058e5c3d6e0b14ae Mon Sep 17 00:00:00 2001 From: Prashant Choudhary <81732236+Mr-Dark-debug@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:46:04 +0530 Subject: [PATCH 1/4] feat(sec): expand rbac enforcement and telemetry --- docs/04-backlog.json | 30 + docs/06-data-model-delta.md | 9 + docs/07-security-privacy.md | 4 + docs/08-observability.md | 8 +- docs/10-release-plan.md | 3 + docs/assumptions.md | 3 + docs/progress/weekly-2025-10-17.md | 14 + src/app/api/admin/feature-flags/route.ts | 90 +-- src/app/api/admin/gamification/shared.ts | 39 +- src/app/api/admin/posts/route.ts | 235 +++--- src/app/api/admin/users/[id]/route.ts | 106 ++- src/app/api/admin/users/route.ts | 77 +- src/components/admin/AdminDashboard.tsx | 1 + src/lib/audit/log.ts | 48 ++ src/lib/auth/require-admin.ts | 132 ++++ src/lib/feature-flags/registry.ts | 20 + src/lib/observability/logger.ts | 54 ++ src/lib/observability/metrics.ts | 12 +- src/lib/observability/tracing.ts | 30 + src/lib/supabase/types.ts | 144 ++++ src/utils/types.ts | 2 + .../0019_sec_001_audit_logs.down.sql | 5 + .../migrations/0019_sec_001_audit_logs.sql | 63 ++ .../0020_sec_001_rls_policies.down.sql | 90 +++ .../migrations/0020_sec_001_rls_policies.sql | 732 ++++++++++++++++++ tests/unit/feature-flags-admin-route.test.ts | 143 +--- tests/unit/require-admin.test.ts | 174 +++++ 27 files changed, 1830 insertions(+), 438 deletions(-) create mode 100644 src/lib/audit/log.ts create mode 100644 src/lib/auth/require-admin.ts create mode 100644 src/lib/observability/logger.ts create mode 100644 src/lib/observability/tracing.ts create mode 100644 supabase/migrations/0019_sec_001_audit_logs.down.sql create mode 100644 supabase/migrations/0019_sec_001_audit_logs.sql create mode 100644 supabase/migrations/0020_sec_001_rls_policies.down.sql create mode 100644 supabase/migrations/0020_sec_001_rls_policies.sql create mode 100644 tests/unit/require-admin.test.ts diff --git a/docs/04-backlog.json b/docs/04-backlog.json index ebfdc21..502639b 100644 --- a/docs/04-backlog.json +++ b/docs/04-backlog.json @@ -38,6 +38,19 @@ "Integration tests cover allow/deny matrix for critical tables", "Admin console exposes role assignment behind flag" ], + "subtasks": [ + "Schema hardening: add foreign keys, composite keys, and indexes for roles/profile_roles/spaces tables", + "Space model: create spaces, space_members, and space_rules with canonical role slugs", + "Content model: ensure posts, post_versions, comments reference spaces and enforce NOT NULL slugs", + "RLS deny-by-default: implement policies for identity, community, content, safety, platform tables", + "Helper SQL: highest_role(profile_id) + legacy slug mapping function with unit tests", + "App guards: reuse requireAdmin across admin routes, emit telemetry + audit logs", + "Admin UI: role manager behind rbac_hardening_v1 with audit trail", + "Telemetry: authz_denied_count dashboard panel + tagged increments", + "Tests: integration matrix role×action×table + admin route unit tests", + "Accessibility: axe scan for role manager surface", + "Docs: security matrix update, RLS denial spike runbook, data model deltas" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated (auth matrix in /docs/07-security-privacy.md)", @@ -63,6 +76,14 @@ "Design tokens defined for color, spacing, typography, elevation", "Dark/light mode parity maintained" ], + "subtasks": [ + "Tokens: extend Tailwind theme for color, spacing, typography, elevation, radius with dark parity", + "Global nav: expose Spaces, Feeds, Events, Funding, Projects, Admin via nav_ia_v1 + RBAC checks", + "Content page application: apply tokens to at least one content layout", + "Accessibility: skip link, focus-visible styles, keyboard trap tests, axe scans", + "Storybook: SpaceHeader, TemplatePickerModal, DonationWidget, EventCard token usage", + "Docs: update /docs/05-ui-ux-delta.md with token tables, IA screenshots, a11y notes" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated in /docs/05-ui-ux-delta.md", @@ -313,6 +334,15 @@ "Distributed tracing instrumented for major services", "Dashboards and alerting policies documented" ], + "subtasks": [ + "Metrics: emit content_publish_latency_ms, flag_evaluation_latency_ms, authz_denied_count, crash_free_sessions", + "Tracing: OpenTelemetry spans for publish + admin routes annotated with space_id/post_id/flag_keys", + "Logging: structured JSON with request_id, hashed user_id, feature flag context", + "Dashboards: Executive + Operations panels covering new metrics", + "Alerts: configure thresholds per /docs/08-observability.md routed to on-call", + "Synthetic: Playwright journeys for home/admin/publish with artifact retention", + "Docs: update observability runbook with identifiers and rollout notes" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated (/docs/08-observability.md)", diff --git a/docs/06-data-model-delta.md b/docs/06-data-model-delta.md index 781d13d..42cce4b 100644 --- a/docs/06-data-model-delta.md +++ b/docs/06-data-model-delta.md @@ -20,6 +20,15 @@ | `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) | | `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) | | `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 | + +> 2025-10-24: Created baseline `audit_logs` table with service-role write policy and admin read access to support SEC-001 guard telemetry. +> 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. + +### Index & Policy Update — 2025-10-31 + +- 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)`. +- Constraints: canonical slug check on `roles.slug`; composite PK enforced on `space_members`. +- 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`. | `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`) | | `pledges` | Recurring commitments | `id`, `profile_id`, `target_type`, `target_id`, `interval`, `amount`, `currency`, `status`, `next_charge_at`, `cancelled_at` | | | `payment_methods` | Tokenized payment references | `id`, `profile_id`, `provider`, `external_id`, `status`, `last4`, `expires_at` | PII encrypted at rest | diff --git a/docs/07-security-privacy.md b/docs/07-security-privacy.md index 15e4d4f..719a34e 100644 --- a/docs/07-security-privacy.md +++ b/docs/07-security-privacy.md @@ -27,6 +27,10 @@ Full matrix with endpoint mapping maintained alongside Supabase policy definitions. Automated tests validate allow/deny paths per `/tests/security`. +**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`. + +**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(''::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). + ## 3. Input Validation & Sanitization - Use Zod schemas for all API inputs, with centralized validation utilities. - Sanitize rich text/HTML via vetted library (e.g., DOMPurify) server-side before storage. diff --git a/docs/08-observability.md b/docs/08-observability.md index 91776ee..d94787e 100644 --- a/docs/08-observability.md +++ b/docs/08-observability.md @@ -22,6 +22,8 @@ | `webhook_delivery_success_rate` | Webhook successes vs. attempts | Gauge | `event_type` | | `automod_trigger_count` | Automod actions per rule | Counter | `rule_type`, `space` | +> 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. + ## 3. Tracing Strategy - Instrument Next.js route handlers and server components with OpenTelemetry. - Propagate trace context through Supabase client calls using custom instrumentation wrappers. @@ -34,8 +36,8 @@ - Centralize logs via Logflare or OpenTelemetry Collector; set retention 30 days (longer for audit logs stored in DB). ## 5. Dashboards -- **Executive KPI Dashboard:** Aggregates content latency, search performance, donation success, RSVP-to-attendance, crash-free sessions. -- **Operations Dashboard:** Displays moderation queue age, automod triggers, authz failures, feature flag adoption. +- **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. +- **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. - **Commerce Dashboard:** Shows donation funnel, payout queue status, dispute rate. - **Events Dashboard:** Tracks registrations, attendance, revenue, NPS survey results. - **Reliability Dashboard:** SLO status, error budgets, incident history. @@ -51,6 +53,8 @@ | Crash-free drop | `crash_free_sessions` < 97% daily | Warning | Slack #frontend | | Webhook delivery failures | `webhook_delivery_success_rate` < 95% for 30m | Warning | Slack #integrations | +> 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. + ## 7. SLOs & Error Budgets | Service | SLO | Error Budget | | --- | --- | --- | diff --git a/docs/10-release-plan.md b/docs/10-release-plan.md index 36ac89e..ed6052b 100644 --- a/docs/10-release-plan.md +++ b/docs/10-release-plan.md @@ -3,6 +3,9 @@ ## 1. Feature Flags | Flag Key | Purpose | Default | Owner | Notes | | --- | --- | --- | --- | --- | +| `rbac_hardening_v1` | Locks down canonical role ladder, admin tooling | OFF | Security Lead | Staff-only until Phase-1 gate | +| `nav_ia_v1` | Enables refreshed navigation IA + tokens | OFF | Design Lead | Staged rollout via staff cohort | +| `observability_v1` | Surfaces observability UI surfaces | OFF | SRE Lead | Infra toggle, dashboards verified first | | `spaces_v1` | Enables space creation, rules, membership | OFF | Product Lead | Phase 2 pilot with selected communities | | `content_templates_v1` | Activates new editors/templates | OFF | Content PM | Depends on `spaces_v1` | | `search_unified_v1` | Turns on new taxonomy/search service | OFF | Search PM | Requires index backfill | diff --git a/docs/assumptions.md b/docs/assumptions.md index ac21354..f310a05 100644 --- a/docs/assumptions.md +++ b/docs/assumptions.md @@ -10,3 +10,6 @@ | 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 | | A-007 | 2025-02-14 | Observability vendor (Grafana Cloud/Honeycomb) budget approved for Phase 1. | Required to meet telemetry commitments. | Open | | A-008 | 2025-02-14 | Legal/compliance resources available before Phase 3 commerce rollout. | Necessary for payments, KYC, events. | Open | +| 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 | +| 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 | +| 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 | diff --git a/docs/progress/weekly-2025-10-17.md b/docs/progress/weekly-2025-10-17.md index b24c7c3..9e68931 100644 --- a/docs/progress/weekly-2025-10-17.md +++ b/docs/progress/weekly-2025-10-17.md @@ -29,3 +29,17 @@ - Supabase-backed RBAC policy tests require dedicated credentials; tracked via `tests/security/rbac-policies.test.ts` until automated environment provisioned. - Need to wire Playwright admin journey + axe scan once SEC-001 flag graduates to pilot. - Coordinate with SEC-001 follow-ups for broader role analytics and audit dashboard panels. + +## 2025-10-24 Addendum +- **Shipped tickets:** Continued SEC-001 hardening (audit log schema + unified admin guard), backlog planning updates. +- **Flags enabled:** None (all Phase-1 flags remain OFF; guard work validated locally only). +- **KPI deltas:** No production delta; verified `authz_denied_count` increments via unit tests for new guard path. +- **New risks/assumptions:** Documented ownership assumptions for `nav_ia_v1`/`observability_v1`; audit log schema awaiting broader RLS rollout. +- **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. + +## 2025-10-31 Addendum +- **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. +- **Flags enabled:** `rbac_hardening_v1` — OFF (validated via unit/integration harness only); `nav_ia_v1` and `observability_v1` remain OFF pending UI work. +- **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. +- **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. +- **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. diff --git a/src/app/api/admin/feature-flags/route.ts b/src/app/api/admin/feature-flags/route.ts index a092adf..efdfe3c 100644 --- a/src/app/api/admin/feature-flags/route.ts +++ b/src/app/api/admin/feature-flags/route.ts @@ -11,16 +11,11 @@ import { invalidateFeatureFlagCache, upsertFeatureFlagCache, } from '@/lib/feature-flags/server' -import { recordAuthzDeny } from '@/lib/observability/metrics' -import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client' +import { requireAdmin } from '@/lib/auth/require-admin' +import { createServiceRoleClient } from '@/lib/supabase/server-client' import type { Database } from '@/lib/supabase/types' import type { AdminFeatureFlagAuditEntry, AdminFeatureFlagRecord } from '@/utils/types' -interface ProfileRecord { - id: string - is_admin: boolean -} - const createFlagSchema = z.object({ flagKey: z.enum(FEATURE_FLAG_KEYS), description: z.string().trim().min(1).max(280).optional(), @@ -73,55 +68,6 @@ const mapAuditRow = (row: Database['public']['Tables']['feature_flag_audit']['Ro createdAt: row.created_at, }) -export const requireAdminProfile = async (): Promise<{ profile: ProfileRecord } | { response: NextResponse }> => { - const supabase = createServerClient() - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser() - - if (authError) { - return { - response: NextResponse.json({ error: `Unable to load session: ${authError.message}` }, { status: 500 }), - } - } - - if (!user) { - recordAuthzDeny('feature_flag_admin', { reason: 'no_session' }) - return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } - - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json( - { error: `Unable to load profile: ${error.message}` }, - { status: 500 }, - ), - } - } - - if (!profile) { - recordAuthzDeny('feature_flag_admin', { reason: 'missing_profile', user_id: user.id }) - return { response: NextResponse.json({ error: 'Forbidden: admin access required.' }, { status: 403 }) } - } - - if (!profile.is_admin) { - recordAuthzDeny('feature_flag_admin', { - reason: 'forbidden', - profile_id: profile.id, - }) - return { response: NextResponse.json({ error: 'Forbidden: admin access required.' }, { status: 403 }) } - } - - return { profile } -} - const buildAdminFlagSet = async ( rows: Database['public']['Tables']['feature_flags']['Row'][] | null, ): Promise => { @@ -158,10 +104,10 @@ const buildAdminFlagSet = async ( } export async function GET() { - const result = await requireAdminProfile() + const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'list' }) - if ('response' in result) { - return result.response + if (!guard.ok) { + return guard.response } const serviceClient = createServiceRoleClient() @@ -199,10 +145,10 @@ export async function GET() { } export async function POST(request: Request) { - const result = await requireAdminProfile() + const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'create' }) - if ('response' in result) { - return result.response + if (!guard.ok) { + return guard.response } const payload = await request.json().catch(() => ({})) @@ -212,7 +158,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid payload', details: parseResult.error.flatten() }, { status: 422 }) } - const { profile } = result + const { profile } = guard const { flagKey, description, owner, enabled, metadata, reason } = parseResult.data const defaults = FEATURE_FLAG_DEFAULTS[flagKey] @@ -270,7 +216,7 @@ export async function POST(request: Request) { previous_enabled: null, new_enabled: definition.enabled, changed_by: profile.id, - changed_by_role: 'admin', + changed_by_role: profile.roleSlug, reason: reason ?? 'created', metadata: { reason: reason ?? 'created', @@ -295,10 +241,10 @@ export async function POST(request: Request) { } export async function PATCH(request: Request) { - const result = await requireAdminProfile() + const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'update' }) - if ('response' in result) { - return result.response + if (!guard.ok) { + return guard.response } const payload = await request.json().catch(() => ({})) @@ -308,7 +254,7 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: 'Invalid payload', details: parseResult.error.flatten() }, { status: 422 }) } - const { profile } = result + const { profile } = guard const { flagKey, description, owner, enabled, metadata, reason } = parseResult.data const serviceClient = createServiceRoleClient() @@ -371,7 +317,7 @@ export async function PATCH(request: Request) { previous_enabled: existing.enabled ?? false, new_enabled: definition.enabled, changed_by: profile.id, - changed_by_role: 'admin', + changed_by_role: profile.roleSlug, reason: reason ?? 'updated', metadata: { reason: reason ?? 'updated', @@ -396,10 +342,10 @@ export async function PATCH(request: Request) { } export async function PURGE() { - const result = await requireAdminProfile() + const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'purge' }) - if ('response' in result) { - return result.response + if (!guard.ok) { + return guard.response } invalidateFeatureFlagCache() diff --git a/src/app/api/admin/gamification/shared.ts b/src/app/api/admin/gamification/shared.ts index 5da60af..6c4cd41 100644 --- a/src/app/api/admin/gamification/shared.ts +++ b/src/app/api/admin/gamification/shared.ts @@ -1,39 +1,18 @@ import { NextResponse } from 'next/server' -import { createServerComponentClient } from '@/lib/supabase/server-client' +import { requireAdmin as requireGlobalAdmin } from '@/lib/auth/require-admin' export const requireAdmin = async (): Promise< | { response: NextResponse } - | { profile: { id: string } } + | { profile: { id: string; roleSlug: string } } > => { - const supabase = createServerComponentClient() - const { - data: { user }, - } = await supabase.auth.getUser() + const guard = await requireGlobalAdmin({ + resource: 'admin_gamification', + action: 'access', + }) - if (!user) { - return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + if (!guard.ok) { + return { response: guard.response } } - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json({ error: `Unable to load profile: ${error.message}` }, { status: 500 }), - } - } - - if (!profile || !profile.is_admin) { - return { - response: NextResponse.json( - { error: 'Forbidden: admin access required.' }, - { status: 403 }, - ), - } - } - - return { profile: { id: profile.id } } + return { profile: { id: guard.profile.id, roleSlug: guard.profile.roleSlug } } } diff --git a/src/app/api/admin/posts/route.ts b/src/app/api/admin/posts/route.ts index aa2ad0b..27d2915 100644 --- a/src/app/api/admin/posts/route.ts +++ b/src/app/api/admin/posts/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from 'next/server' +import { performance } from 'node:perf_hooks' +import { createServiceRoleClient } from '@/lib/supabase/server-client' +import { requireAdmin } from '@/lib/auth/require-admin' +import { writeAuditLog } from '@/lib/audit/log' import { - createServerComponentClient, - createServiceRoleClient, -} from '@/lib/supabase/server-client' + recordContentPublishLatency, + recordHistogram, +} from '@/lib/observability/metrics' +import { logStructuredEvent } from '@/lib/observability/logger' +import { withSpan } from '@/lib/observability/tracing' import { PostStatus, type AdminPost } from '@/utils/types' const POST_SELECT = ` @@ -23,15 +29,11 @@ const POST_SELECT = ` scheduled_for, author_id, category_id, + space_id, categories:categories(id, name, slug), post_tags:post_tags(tags(id, name, slug)) ` -interface ProfileRecord { - id: string - is_admin: boolean -} - interface PostRecord { id: string title: string @@ -50,6 +52,7 @@ interface PostRecord { scheduled_for: string | null author_id: string | null category_id: string | null + space_id: string | null categories: { id: string | null; name: string | null; slug: string | null } | null post_tags: { tags: { id: string | null; name: string | null; slug: string | null } | null }[] | null } @@ -74,6 +77,7 @@ const mapPostRecord = (record: PostRecord): AdminPost => ({ categoryId: record.category_id ?? null, categoryName: record.categories?.name ?? null, categorySlug: record.categories?.slug ?? null, + spaceId: record.space_id ?? null, tags: (record.post_tags ?? []) .map((tag) => tag.tags) @@ -99,52 +103,10 @@ const normalizeStatus = (value: string | null | undefined): PostStatus => { return allowedStatuses.has(value) ? (value as PostStatus) : PostStatus.DRAFT } -const getAdminProfile = async (): Promise< - | { profile: ProfileRecord } - | { response: NextResponse } -> => { - const supabase = createServerComponentClient() - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return { - response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), - } - } - - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json( - { error: `Unable to load profile: ${error.message}` }, - { status: 500 }, - ), - } - } - - if (!profile || !profile.is_admin) { - return { - response: NextResponse.json( - { error: 'Forbidden: admin access required.' }, - { status: 403 }, - ), - } - } - - return { profile } -} - export async function GET() { - const result = await getAdminProfile() - if ('response' in result) { - return result.response + const guard = await requireAdmin({ resource: 'admin_posts', action: 'list' }) + if (!guard.ok) { + return guard.response } const serviceClient = createServiceRoleClient() @@ -168,9 +130,9 @@ export async function GET() { } export async function POST(request: Request) { - const result = await getAdminProfile() - if ('response' in result) { - return result.response + const guard = await requireAdmin({ resource: 'admin_posts', action: 'create' }) + if (!guard.ok) { + return guard.response } const body = (await request.json()) as Record @@ -211,11 +173,15 @@ export async function POST(request: Request) { .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .map((value) => value.trim()) : [] + const spaceId = + typeof body.spaceId === 'string' && body.spaceId.trim().length > 0 + ? body.spaceId.trim() + : null const requestedStatus = normalizeStatus(body.status as string | null | undefined) const authorId = typeof body.authorId === 'string' && body.authorId.trim().length > 0 ? body.authorId - : result.profile.id + : guard.profile.id if (!title || !slug || !content) { return NextResponse.json( @@ -272,57 +238,120 @@ export async function POST(request: Request) { publishedAt = null } - const { data, error } = await serviceClient - .from('posts') - .insert({ - title, - slug, - content, - excerpt, - accent_color: accentColor, - seo_title: seoTitle, - seo_description: seoDescription, - featured_image_url: featuredImageUrl, - social_image_url: socialImageUrl, - status: requestedStatus, - published_at: publishedAt, - scheduled_for: scheduledFor, - category_id: categoryId, - author_id: authorId, - }) - .select(POST_SELECT) - .single() - - if (error) { - if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { - return NextResponse.json({ error: duplicateSlugMessage }, { status: 409 }) - } - return NextResponse.json( - { error: `Unable to create post: ${error.message}` }, - { status: 400 }, - ) + const spanAttributes = { + 'post.slug': slug, + 'post.status': requestedStatus, + 'space.id': spaceId ?? 'global', } - const record = data as unknown as PostRecord - - if (tagIds.length > 0) { - const insertPayload = tagIds.map((tagId) => ({ - post_id: record.id, - tag_id: tagId, - })) + const publishStart = performance.now() + + let record: PostRecord + try { + record = await withSpan('admin.posts.create', spanAttributes, async () => { + const { data, error } = await serviceClient + .from('posts') + .insert({ + title, + slug, + content, + excerpt, + accent_color: accentColor, + seo_title: seoTitle, + seo_description: seoDescription, + featured_image_url: featuredImageUrl, + social_image_url: socialImageUrl, + status: requestedStatus, + published_at: publishedAt, + scheduled_for: scheduledFor, + category_id: categoryId, + author_id: authorId, + space_id: spaceId, + }) + .select(POST_SELECT) + .single() + + if (error) { + if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { + const conflictError = Object.assign(new Error(duplicateSlugMessage), { status: 409 }) + throw conflictError + } + + throw Object.assign(new Error(`Unable to create post: ${error.message}`), { status: 400 }) + } + + const postRecord = data as unknown as PostRecord + + if (tagIds.length > 0) { + const insertPayload = tagIds.map((tagId) => ({ + post_id: postRecord.id, + tag_id: tagId, + })) + + const { error: tagError } = await serviceClient.from('post_tags').insert(insertPayload) + + if (tagError) { + throw Object.assign( + new Error(`Post created but tags failed to save: ${tagError.message}`), + { status: 400 }, + ) + } + } + + return postRecord + }) + } catch (unknownError) { + const status = + typeof (unknownError as { status?: number })?.status === 'number' + ? ((unknownError as { status: number }).status ?? 500) + : 500 + const message = + unknownError instanceof Error && unknownError.message + ? unknownError.message + : 'Unable to create post.' + + return NextResponse.json({ error: message }, { status }) + } - const { error: tagError } = await serviceClient - .from('post_tags') - .insert(insertPayload) + const post = mapPostRecord(record) - if (tagError) { - return NextResponse.json( - { error: `Post created but tags failed to save: ${tagError.message}` }, - { status: 400 }, - ) - } + if (requestedStatus === PostStatus.PUBLISHED) { + const latency = performance.now() - publishStart + recordContentPublishLatency(latency, { + space: spaceId ?? 'global', + status: requestedStatus, + }) + recordHistogram('admin_publish_duration_ms', latency, { + resource: 'admin_posts', + }) } - const post = mapPostRecord(record) + logStructuredEvent({ + message: 'admin.post.created', + userId: guard.profile.userId, + spaceId, + metadata: { + post_id: post.id, + slug, + status: requestedStatus, + tag_count: tagIds.length, + }, + }) + + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: guard.profile.roleSlug, + resource: 'admin_posts', + action: 'post_created', + entityId: post.id, + spaceId, + metadata: { + status: requestedStatus, + slug, + category_id: categoryId, + tags: tagIds, + }, + }) + return NextResponse.json({ post }, { status: 201 }) } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index dcfddc8..ffbb263 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -1,9 +1,8 @@ import { NextResponse } from 'next/server' -import { - createServerComponentClient, - createServiceRoleClient, -} from '@/lib/supabase/server-client' +import { createServiceRoleClient } from '@/lib/supabase/server-client' import type { UpdateAdminUserPayload } from '@/utils/types' +import { requireAdmin } from '@/lib/auth/require-admin' +import { writeAuditLog } from '@/lib/audit/log' import { fetchRoles, fetchProfileById, @@ -12,48 +11,6 @@ import { sanitizeRoleSlugs, } from '../shared' -const getAdminProfile = async (): Promise< - | { response: NextResponse } - | { profile: { id: string } } -> => { - const supabase = createServerComponentClient() - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return { - response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), - } - } - - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json( - { error: `Unable to load profile: ${error.message}` }, - { status: 500 }, - ), - } - } - - if (!profile || !profile.is_admin) { - return { - response: NextResponse.json( - { error: 'Forbidden: admin access required.' }, - { status: 403 }, - ), - } - } - - return { profile: { id: profile.id } } -} - const sanitizeDisplayName = (value: unknown): string => { if (typeof value !== 'string') return '' return value.trim() @@ -68,16 +25,20 @@ export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const result = await getAdminProfile() - if ('response' in result) { - return result.response - } - const { id: profileId } = await params if (!profileId) { return NextResponse.json({ error: 'Profile id is required.' }, { status: 400 }) } + const guard = await requireAdmin({ + resource: 'admin_users', + action: 'update', + entityId: profileId, + }) + if (!guard.ok) { + return guard.response + } + const body = (await request.json()) as Partial const displayName = sanitizeDisplayName(body?.displayName) @@ -128,6 +89,20 @@ export async function PATCH( const refreshedProfile = await fetchProfileById(serviceClient, profileId) const summary = await buildUserSummary(serviceClient, refreshedProfile) + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: guard.profile.roleSlug, + resource: 'admin_users', + action: 'user_updated', + entityId: summary.profileId, + metadata: { + display_name: displayName, + role_slugs: requestedRoles, + is_admin: isAdmin, + password_reset: Boolean(newPassword), + }, + }) + return NextResponse.json({ user: summary }) } catch (error) { const message = error instanceof Error ? error.message : 'Unable to update user.' @@ -147,18 +122,22 @@ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const result = await getAdminProfile() - if ('response' in result) { - return result.response - } - - const { id: currentProfileId } = result.profile - const { id: profileId } = await params if (!profileId) { return NextResponse.json({ error: 'Profile id is required.' }, { status: 400 }) } + const guard = await requireAdmin({ + resource: 'admin_users', + action: 'delete', + entityId: profileId, + }) + if (!guard.ok) { + return guard.response + } + + const { id: currentProfileId, roleSlug } = guard.profile + if (profileId === currentProfileId) { return NextResponse.json( { error: 'You cannot delete your own account.' }, @@ -188,6 +167,17 @@ export async function DELETE( throw new Error(`Unable to delete profile: ${profileDeleteError.message}`) } + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: roleSlug, + resource: 'admin_users', + action: 'user_deleted', + entityId: profileId, + metadata: { + deleted_user_id: profileRecord.user_id, + }, + }) + return NextResponse.json({ success: true }) } catch (error) { const message = error instanceof Error ? error.message : 'Unable to delete user.' diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 3fdd049..f98a66a 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -1,9 +1,7 @@ import { NextResponse } from 'next/server' -import { - createServerComponentClient, - createServiceRoleClient, -} from '@/lib/supabase/server-client' -import { recordAuthzDeny } from '@/lib/observability/metrics' +import { createServiceRoleClient } from '@/lib/supabase/server-client' +import { requireAdmin } from '@/lib/auth/require-admin' +import { writeAuditLog } from '@/lib/audit/log' import type { AdminRole, CreateAdminUserPayload } from '@/utils/types' import { fetchRoles, @@ -16,50 +14,6 @@ import { export { sanitizeRoleSlugs } from './shared' -const getAdminProfile = async (): Promise< - | { response: NextResponse } - | { profile: { id: string } } -> => { - const supabase = createServerComponentClient() - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - recordAuthzDeny('admin_users', { stage: 'auth_check' }) - return { - response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), - } - } - - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json( - { error: `Unable to load profile: ${error.message}` }, - { status: 500 }, - ), - } - } - - if (!profile || !profile.is_admin) { - recordAuthzDeny('admin_users', { stage: 'role_check' }) - return { - response: NextResponse.json( - { error: 'Forbidden: admin access required.' }, - { status: 403 }, - ), - } - } - - return { profile: { id: profile.id } } -} - const sanitizeEmail = (value: unknown): string => { if (typeof value !== 'string') return '' const trimmed = value.trim() @@ -77,9 +31,9 @@ const sanitizePassword = (value: unknown): string => { } export async function GET() { - const result = await getAdminProfile() - if ('response' in result) { - return result.response + const guard = await requireAdmin({ resource: 'admin_users', action: 'list' }) + if (!guard.ok) { + return guard.response } try { @@ -105,9 +59,9 @@ export async function GET() { } export async function POST(request: Request) { - const result = await getAdminProfile() - if ('response' in result) { - return result.response + const guard = await requireAdmin({ resource: 'admin_users', action: 'create' }) + if (!guard.ok) { + return guard.response } const body = (await request.json()) as Partial @@ -186,6 +140,19 @@ export async function POST(request: Request) { const refreshedProfile = await fetchProfileById(serviceClient, profileData.id) const summary = await buildUserSummary(serviceClient, refreshedProfile) + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: guard.profile.roleSlug, + resource: 'admin_users', + action: 'user_created', + entityId: summary.profileId, + metadata: { + email, + is_admin: isAdmin, + role_slugs: requestedRoles, + }, + }) + return NextResponse.json({ user: summary }) } catch (error) { const message = error instanceof Error ? error.message : 'Unable to create user.' diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx index db7c7b1..7d51cc5 100644 --- a/src/components/admin/AdminDashboard.tsx +++ b/src/components/admin/AdminDashboard.tsx @@ -111,6 +111,7 @@ const DashboardContent = ({ scheduledFor: post.scheduledFor ?? null, authorId: post.authorId ?? null, views: post.views ?? 0, + spaceId: post.spaceId ?? null, })) }, []) diff --git a/src/lib/audit/log.ts b/src/lib/audit/log.ts new file mode 100644 index 0000000..37b071c --- /dev/null +++ b/src/lib/audit/log.ts @@ -0,0 +1,48 @@ +import { createServiceRoleClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' + +export interface AuditLogEntry { + actorId?: string | null + actorRole: string + resource: string + action: string + entityId?: string | null + spaceId?: string | null + reason?: string | null + metadata?: Record +} + +const serializeMetadata = (metadata?: Record) => metadata ?? {} + +export const writeAuditLog = async (entry: AuditLogEntry) => { + try { + const serviceClient = createServiceRoleClient() + + const { error } = await serviceClient + .from('audit_logs') + .insert({ + actor_id: entry.actorId ?? null, + actor_role: entry.actorRole, + resource: entry.resource, + action: entry.action, + entity_id: entry.entityId ?? null, + space_id: entry.spaceId ?? null, + metadata: serializeMetadata(entry.metadata), + reason: entry.reason ?? null, + }) + + if (error) { + console.error('[audit_log] failed to persist entry', { + error: error.message, + resource: entry.resource, + action: entry.action, + }) + } + } catch (error) { + console.error('[audit_log] unexpected failure', { + error, + resource: entry.resource, + action: entry.action, + }) + } +} diff --git a/src/lib/auth/require-admin.ts b/src/lib/auth/require-admin.ts new file mode 100644 index 0000000..d994744 --- /dev/null +++ b/src/lib/auth/require-admin.ts @@ -0,0 +1,132 @@ +import { NextResponse } from 'next/server' +import { writeAuditLog } from '@/lib/audit/log' +import { recordAuthzDeny } from '@/lib/observability/metrics' +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' + +interface RequireAdminOptions { + resource: string + action: string + entityId?: string | null + spaceId?: string | null +} + +interface AdminProfileContext { + id: string + userId: string + roleSlug: string + isAdmin: boolean +} + +export type AdminGuardResult = + | { ok: true; profile: AdminProfileContext } + | { ok: false; response: NextResponse } + +const resolveRoleSlug = async (primaryRoleId: string | null): Promise => { + if (!primaryRoleId) { + return 'member' + } + + const serviceClient = createServiceRoleClient() + const { data, error } = await serviceClient + .from('roles') + .select('slug') + .eq('id', primaryRoleId) + .maybeSingle<{ slug: string }>() + + if (error) { + console.error('[requireAdmin] unable to resolve role slug', { error: error.message, primaryRoleId }) + return 'member' + } + + return data?.slug ?? 'member' +} + +const logDenial = async ( + options: RequireAdminOptions, + actor: { profileId: string | null; roleSlug: string }, + reason: string, +) => { + recordAuthzDeny(options.resource, { + role: actor.roleSlug, + reason, + space: options.spaceId ?? undefined, + }) + + await writeAuditLog({ + actorId: actor.profileId, + actorRole: actor.roleSlug, + resource: options.resource, + action: 'access_denied', + entityId: options.entityId ?? null, + spaceId: options.spaceId ?? null, + reason, + metadata: { guard_action: options.action }, + }) +} + +export const requireAdmin = async (options: RequireAdminOptions): Promise => { + const supabase = createServerClient() + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser() + + if (authError) { + console.error('[requireAdmin] session lookup failed', { error: authError.message }) + return { + ok: false, + response: NextResponse.json({ error: 'Unable to load session.' }, { status: 500 }), + } + } + + if (!user) { + await logDenial(options, { profileId: null, roleSlug: 'unknown' }, 'no_session') + return { + ok: false, + response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + } + } + + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('id, is_admin, primary_role_id') + .eq('user_id', user.id) + .maybeSingle<{ id: string; is_admin: boolean; primary_role_id: string | null }>() + + if (profileError) { + console.error('[requireAdmin] profile lookup failed', { error: profileError.message }) + return { + ok: false, + response: NextResponse.json({ error: 'Unable to load profile.' }, { status: 500 }), + } + } + + if (!profile) { + await logDenial(options, { profileId: null, roleSlug: 'unknown' }, 'missing_profile') + return { + ok: false, + response: NextResponse.json({ error: 'Forbidden: admin access required.' }, { status: 403 }), + } + } + + const roleSlug = await resolveRoleSlug(profile.primary_role_id) + + if (!profile.is_admin) { + await logDenial(options, { profileId: profile.id, roleSlug }, 'forbidden') + return { + ok: false, + response: NextResponse.json({ error: 'Forbidden: admin access required.' }, { status: 403 }), + } + } + + return { + ok: true, + profile: { + id: profile.id, + userId: user.id, + roleSlug, + isAdmin: profile.is_admin, + }, + } +} diff --git a/src/lib/feature-flags/registry.ts b/src/lib/feature-flags/registry.ts index b22f891..c460812 100644 --- a/src/lib/feature-flags/registry.ts +++ b/src/lib/feature-flags/registry.ts @@ -12,6 +12,8 @@ export const FEATURE_FLAG_KEYS = [ 'messaging_v1', 'notifications_v1', 'rbac_hardening_v1', + 'nav_ia_v1', + 'observability_v1', ] as const export type FeatureFlagKey = (typeof FEATURE_FLAG_KEYS)[number] @@ -112,6 +114,15 @@ export const FEATURE_FLAG_DEFAULTS: Record diff --git a/src/lib/observability/logger.ts b/src/lib/observability/logger.ts new file mode 100644 index 0000000..fd28bd5 --- /dev/null +++ b/src/lib/observability/logger.ts @@ -0,0 +1,54 @@ +import { createHash } from 'node:crypto' + +type LogLevel = 'debug' | 'info' | 'warn' | 'error' + +export interface StructuredLogPayload { + message: string + level?: LogLevel + requestId?: string | null + userId?: string | null + featureFlags?: string[] + spaceId?: string | null + metadata?: Record +} + +const hashIdentifier = (value: string | null | undefined): string | null => { + if (!value) { + return null + } + + const hash = createHash('sha256') + hash.update(value) + return hash.digest('hex').slice(0, 32) +} + +export const logStructuredEvent = ({ + message, + level = 'info', + requestId, + userId, + featureFlags, + spaceId, + metadata = {}, +}: StructuredLogPayload) => { + const payload = { + ts: new Date().toISOString(), + level, + message, + request_id: requestId ?? null, + user_id_hash: hashIdentifier(userId), + space_id: spaceId ?? null, + feature_flags: featureFlags ?? [], + metadata, + } + + if (level === 'error') { + console.error('[structured-log]', JSON.stringify(payload)) + } else if (level === 'warn') { + console.warn('[structured-log]', JSON.stringify(payload)) + } else if (level === 'debug') { + console.debug('[structured-log]', JSON.stringify(payload)) + } else { + console.log('[structured-log]', JSON.stringify(payload)) + } +} diff --git a/src/lib/observability/metrics.ts b/src/lib/observability/metrics.ts index 23ec11e..1e5e065 100644 --- a/src/lib/observability/metrics.ts +++ b/src/lib/observability/metrics.ts @@ -61,9 +61,17 @@ export const recordCounter = (metric: string, value = 1, tags?: MetricTags) => { }) } -export const recordAuthzDeny = (context: string, tags?: MetricTags) => { +export const recordAuthzDeny = (resource: string, tags?: MetricTags) => { recordCounter('authz_denied_count', 1, { - context, + resource, ...(tags ?? {}), }) } + +export const recordContentPublishLatency = (value: number, tags?: MetricTags) => { + recordHistogram('content_publish_latency_ms', value, tags) +} + +export const recordCrashFreeSession = (tags?: MetricTags) => { + recordCounter('crash_free_sessions', 1, tags) +} diff --git a/src/lib/observability/tracing.ts b/src/lib/observability/tracing.ts new file mode 100644 index 0000000..5b02700 --- /dev/null +++ b/src/lib/observability/tracing.ts @@ -0,0 +1,30 @@ +import { SpanStatusCode, trace } from '@opentelemetry/api' + +type SpanAttributes = Record + +type SpanCallback = () => Promise | T + +const tracer = trace.getTracer('syntax-blogs') + +export const withSpan = async ( + name: string, + attributes: SpanAttributes, + callback: SpanCallback, +): Promise => { + return tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + const result = await callback() + span.setStatus({ code: SpanStatusCode.OK }) + return result + } catch (error) { + span.recordException(error as Error) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : 'unknown error', + }) + throw error + } finally { + span.end() + } + }) +} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index b9465a1..b267836 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -78,6 +78,146 @@ export interface Database { } Update: Partial } + audit_logs: { + Row: { + id: string + actor_id: string | null + actor_role: string + resource: string + action: string + entity_id: string | null + space_id: string | null + metadata: Record + reason: string | null + created_at: string + } + Insert: { + id?: string + actor_id?: string | null + actor_role: string + resource: string + action: string + entity_id?: string | null + space_id?: string | null + metadata?: Record + reason?: string | null + created_at?: string + } + Update: Partial + } + spaces: { + Row: { + id: string + slug: string + name: string + description: string | null + visibility: Database['public']['Enums']['space_visibility'] + created_by: string | null + created_at: string + updated_at: string + is_archived: boolean + } + Insert: { + id?: string + slug: string + name: string + description?: string | null + visibility?: Database['public']['Enums']['space_visibility'] + created_by?: string | null + created_at?: string + updated_at?: string + is_archived?: boolean + } + Update: Partial + } + space_members: { + Row: { + space_id: string + profile_id: string + role_id: string + status: Database['public']['Enums']['space_membership_status'] + joined_at: string + last_seen_at: string | null + } + Insert: { + space_id: string + profile_id: string + role_id: string + status?: Database['public']['Enums']['space_membership_status'] + joined_at?: string + last_seen_at?: string | null + } + Update: Partial + } + space_rules: { + Row: { + id: string + space_id: string + title: string + body: string + created_by: string | null + created_at: string + updated_at: string + } + Insert: { + id?: string + space_id: string + title: string + body: string + created_by?: string | null + created_at?: string + updated_at?: string + } + Update: Partial + } + post_versions: { + Row: { + id: string + post_id: string + version_number: number + content: Record + metadata: Record + created_by: string | null + created_at: string + } + Insert: { + id?: string + post_id: string + version_number: number + content?: Record + metadata?: Record + created_by?: string | null + created_at?: string + } + Update: Partial + } + reports: { + Row: { + id: string + reporter_profile_id: string | null + subject_type: string + subject_id: string + reason: string + notes: string | null + status: string + space_id: string | null + created_at: string + resolved_at: string | null + } + Insert: { + id?: string + reporter_profile_id?: string | null + subject_type: string + subject_id: string + reason: string + notes?: string | null + status?: string + space_id?: string | null + created_at?: string + resolved_at?: string | null + } + Update: Partial + } gamification_profiles: { Row: { profile_id: string @@ -876,6 +1016,10 @@ export interface Database { | 'messaging_v1' | 'notifications_v1' | 'rbac_hardening_v1' + | 'nav_ia_v1' + | 'observability_v1' + space_visibility: 'public' | 'private' | 'unlisted' + space_membership_status: 'active' | 'invited' | 'suspended' prompt_media_type: 'image' | 'video' | 'text' | 'audio' | '3d' | 'workflow' prompt_difficulty_level: 'beginner' | 'intermediate' | 'advanced' prompt_visibility: 'public' | 'unlisted' | 'draft' diff --git a/src/utils/types.ts b/src/utils/types.ts index 9bbfee3..69200bc 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -93,6 +93,7 @@ export interface AdminPost { publishedAt: string | null scheduledFor: string | null authorId: string | null + spaceId: string | null } export interface PostFormValues { @@ -112,6 +113,7 @@ export interface PostFormValues { publishedAt: string | null scheduledFor: string | null authorId?: string | null + spaceId?: string | null } export interface AdminRole { diff --git a/supabase/migrations/0019_sec_001_audit_logs.down.sql b/supabase/migrations/0019_sec_001_audit_logs.down.sql new file mode 100644 index 0000000..992502c --- /dev/null +++ b/supabase/migrations/0019_sec_001_audit_logs.down.sql @@ -0,0 +1,5 @@ +-- ===================================================================== +-- DOWN: Remove audit logs table introduced for SEC-001 +-- ===================================================================== + +DROP TABLE IF EXISTS public.audit_logs; diff --git a/supabase/migrations/0019_sec_001_audit_logs.sql b/supabase/migrations/0019_sec_001_audit_logs.sql new file mode 100644 index 0000000..a79fc95 --- /dev/null +++ b/supabase/migrations/0019_sec_001_audit_logs.sql @@ -0,0 +1,63 @@ +-- ===================================================================== +-- SEC-001: Core audit log table to support RBAC guard instrumentation +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS public.audit_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + actor_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL, + actor_role text NOT NULL, + resource text NOT NULL, + action text NOT NULL, + entity_id text, + space_id uuid, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + reason text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS audit_logs_actor_idx ON public.audit_logs(actor_id, created_at DESC); +CREATE INDEX IF NOT EXISTS audit_logs_resource_idx ON public.audit_logs(resource, created_at DESC); +CREATE INDEX IF NOT EXISTS audit_logs_space_idx ON public.audit_logs(space_id, created_at DESC); + +ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'audit_logs' + AND policyname = 'Service role manages audit logs' + ) THEN + DROP POLICY "Service role manages audit logs" ON public.audit_logs; + END IF; +END; +$$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'audit_logs' + AND policyname = 'Admins read audit logs' + ) THEN + DROP POLICY "Admins read audit logs" ON public.audit_logs; + END IF; +END; +$$; + +CREATE POLICY "Service role manages audit logs" + ON public.audit_logs + FOR ALL + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); + +CREATE POLICY "Admins read audit logs" + ON public.audit_logs + FOR SELECT + USING (public.user_has_role_or_higher('admin')); + +COMMENT ON TABLE public.audit_logs IS 'Immutable audit log capturing privileged actions and denials.'; diff --git a/supabase/migrations/0020_sec_001_rls_policies.down.sql b/supabase/migrations/0020_sec_001_rls_policies.down.sql new file mode 100644 index 0000000..970c35d --- /dev/null +++ b/supabase/migrations/0020_sec_001_rls_policies.down.sql @@ -0,0 +1,90 @@ +-- ===================================================================== +-- Down migration for SEC-001 RBAC hardening slice +-- Rolls back schema additions while preserving legacy behavior +-- ===================================================================== + +-- Remove refreshed policies to restore prior defaults +DO $$ +BEGIN + DROP POLICY IF EXISTS "Posts readable by visibility" ON public.posts; + DROP POLICY IF EXISTS "Contributors insert posts" ON public.posts; + DROP POLICY IF EXISTS "Contributors update drafts" ON public.posts; + DROP POLICY IF EXISTS "Moderators delete posts" ON public.posts; + + DROP POLICY IF EXISTS "Post versions readable" ON public.post_versions; + DROP POLICY IF EXISTS "Post versions write access" ON public.post_versions; + + DROP POLICY IF EXISTS "Comments readable" ON public.comments; + DROP POLICY IF EXISTS "Members create comments" ON public.comments; + DROP POLICY IF EXISTS "Moderators manage comments" ON public.comments; + + DROP POLICY IF EXISTS "Reports readable" ON public.reports; + DROP POLICY IF EXISTS "Reports manageable" ON public.reports; + + DROP POLICY IF EXISTS "Spaces readable per visibility" ON public.spaces; + DROP POLICY IF EXISTS "Organizers manage spaces" ON public.spaces; + DROP POLICY IF EXISTS "Members can view roster" ON public.space_members; + DROP POLICY IF EXISTS "Organizers manage roster" ON public.space_members; + DROP POLICY IF EXISTS "Rules readable to members" ON public.space_rules; + DROP POLICY IF EXISTS "Organizers manage rules" ON public.space_rules; + + DROP POLICY IF EXISTS "Admins manage feature flags" ON public.feature_flags; + DROP POLICY IF EXISTS "Admins read feature flag audit" ON public.feature_flag_audit; + DROP POLICY IF EXISTS "Admins read audit logs" ON public.audit_logs; + DROP POLICY IF EXISTS "Users can read their role assignments" ON public.profile_roles; +END; +$$; + +-- Drop helper functions +DROP FUNCTION IF EXISTS public.user_space_role_at_least(uuid, text); +DROP FUNCTION IF EXISTS public.user_role_at_least(text); +DROP FUNCTION IF EXISTS public.profile_role_at_least(uuid, text); +DROP FUNCTION IF EXISTS public.role_priority_for_slug(text); +DROP FUNCTION IF EXISTS public.highest_role_slug(uuid); +DROP FUNCTION IF EXISTS public.normalize_role_slug(text); +DROP FUNCTION IF EXISTS public.current_profile_id(); +DROP FUNCTION IF EXISTS public.user_is_admin(); + +-- Remove additional indexes and constraints +ALTER TABLE public.posts DROP COLUMN IF EXISTS space_id; +ALTER TABLE public.comments DROP COLUMN IF EXISTS thread_root_id; +ALTER TABLE public.roles DROP CONSTRAINT IF EXISTS roles_slug_check; + +-- Drop new tables (cascades remove indexes and triggers) +DROP TABLE IF EXISTS public.reports; +DROP TABLE IF EXISTS public.post_versions; +DROP TABLE IF EXISTS public.space_rules; +DROP TABLE IF EXISTS public.space_members; +DROP TABLE IF EXISTS public.spaces; + +-- Drop enums only if unused +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_depend d ON d.refobjid = t.oid + WHERE t.typname = 'space_visibility' + AND d.deptype = 'n' + ) THEN + DROP TYPE IF EXISTS public.space_visibility; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_depend d ON d.refobjid = t.oid + WHERE t.typname = 'space_membership_status' + AND d.deptype = 'n' + ) THEN + DROP TYPE IF EXISTS public.space_membership_status; + END IF; +END; +$$; + +-- Disable RLS on tables created in the up migration (dropping tables already clears, but safe) +ALTER TABLE IF EXISTS public.spaces DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS public.space_members DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS public.space_rules DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS public.post_versions DISABLE ROW LEVEL SECURITY; +ALTER TABLE IF EXISTS public.reports DISABLE ROW LEVEL SECURITY; + +-- NOTE: prior migrations defined the legacy policies; rerun 0018 down/up to restore if required. diff --git a/supabase/migrations/0020_sec_001_rls_policies.sql b/supabase/migrations/0020_sec_001_rls_policies.sql new file mode 100644 index 0000000..0a7a35b --- /dev/null +++ b/supabase/migrations/0020_sec_001_rls_policies.sql @@ -0,0 +1,732 @@ +-- ===================================================================== +-- SEC-001: RBAC hardening for Phase-1 scope +-- Adds community spaces schema, helper functions, and deny-by-default RLS +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- Enums required for space governance (idempotent) +-- --------------------------------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t WHERE t.typname = 'space_visibility' + ) THEN + CREATE TYPE public.space_visibility AS ENUM ('public', 'private', 'unlisted'); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_type t WHERE t.typname = 'space_membership_status' + ) THEN + CREATE TYPE public.space_membership_status AS ENUM ('active', 'invited', 'suspended'); + END IF; +END; +$$; + +-- --------------------------------------------------------------------- +-- Spaces table and companions (idempotent create with constraints) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.spaces ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug text NOT NULL UNIQUE, + name text NOT NULL, + description text, + visibility public.space_visibility NOT NULL DEFAULT 'public', + created_by uuid REFERENCES public.profiles(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + is_archived boolean NOT NULL DEFAULT false +); + +CREATE TABLE IF NOT EXISTS public.space_members ( + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + role_id uuid NOT NULL REFERENCES public.roles(id) ON DELETE RESTRICT, + status public.space_membership_status NOT NULL DEFAULT 'active', + joined_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz, + PRIMARY KEY (space_id, profile_id) +); + +CREATE TABLE IF NOT EXISTS public.space_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + title text NOT NULL, + body text NOT NULL, + created_by uuid REFERENCES public.profiles(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS space_members_role_status_idx ON public.space_members(role_id, status); +CREATE INDEX IF NOT EXISTS space_members_profile_idx ON public.space_members(profile_id); +CREATE INDEX IF NOT EXISTS space_members_space_role_idx ON public.space_members(space_id, role_id); +CREATE INDEX IF NOT EXISTS spaces_visibility_idx ON public.spaces(visibility); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'spaces_set_updated_at' AND tgrelid = 'public.spaces'::regclass + ) THEN + CREATE TRIGGER spaces_set_updated_at + BEFORE UPDATE ON public.spaces + FOR EACH ROW + EXECUTE FUNCTION public.set_updated_at(); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'space_rules_set_updated_at' AND tgrelid = 'public.space_rules'::regclass + ) THEN + CREATE TRIGGER space_rules_set_updated_at + BEFORE UPDATE ON public.space_rules + FOR EACH ROW + EXECUTE FUNCTION public.set_updated_at(); + END IF; +END; +$$; + +ALTER TABLE public.spaces ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.space_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.space_rules ENABLE ROW LEVEL SECURITY; + +-- --------------------------------------------------------------------- +-- Content history table (post_versions) and indexes +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.post_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + post_id uuid NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE, + version_number integer NOT NULL, + content jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_by uuid REFERENCES public.profiles(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS post_versions_post_number_idx + ON public.post_versions(post_id, version_number DESC); + +ALTER TABLE public.post_versions ENABLE ROW LEVEL SECURITY; + +-- Ensure posts have optional linkage to spaces for future enforcement +ALTER TABLE public.posts + ADD COLUMN IF NOT EXISTS space_id uuid REFERENCES public.spaces(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS posts_space_status_idx ON public.posts(space_id, status); +CREATE INDEX IF NOT EXISTS posts_space_published_idx ON public.posts(space_id, published_at DESC NULLS LAST); + +-- Comments already exist; ensure indexing for moderation threads +ALTER TABLE public.comments + ADD COLUMN IF NOT EXISTS thread_root_id uuid; + +CREATE INDEX IF NOT EXISTS comments_thread_root_idx ON public.comments(thread_root_id, created_at DESC); + +-- Reports table to log moderation tickets +CREATE TABLE IF NOT EXISTS public.reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + reporter_profile_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL, + subject_type text NOT NULL, + subject_id text NOT NULL, + reason text NOT NULL, + notes text, + status text NOT NULL DEFAULT 'open', + space_id uuid REFERENCES public.spaces(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz +); + +CREATE INDEX IF NOT EXISTS reports_space_status_idx ON public.reports(space_id, status); +CREATE INDEX IF NOT EXISTS reports_subject_idx ON public.reports(subject_type, subject_id); + +ALTER TABLE public.reports ENABLE ROW LEVEL SECURITY; + +-- --------------------------------------------------------------------- +-- Helper functions for canonical role lookups and membership checks +-- --------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.normalize_role_slug(role_slug text) +RETURNS text +LANGUAGE plpgsql +STABLE +AS $$ +BEGIN + IF role_slug IS NULL THEN + RETURN NULL; + END IF; + + CASE lower(role_slug) + WHEN 'editor' THEN RETURN 'organizer'; + WHEN 'author' THEN RETURN 'contributor'; + ELSE RETURN lower(role_slug); + END CASE; +END; +$$; + +CREATE OR REPLACE FUNCTION public.highest_role_slug(profile_uuid uuid) +RETURNS text +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + resolved uuid; + slug text; +BEGIN + IF profile_uuid IS NULL THEN + RETURN NULL; + END IF; + + SELECT public.determine_primary_role(profile_uuid) INTO resolved; + + IF resolved IS NULL THEN + RETURN NULL; + END IF; + + SELECT slug INTO slug FROM public.roles WHERE id = resolved; + RETURN slug; +END; +$$; + +CREATE OR REPLACE FUNCTION public.role_priority_for_slug(role_slug text) +RETURNS integer +LANGUAGE sql +STABLE +AS $$ + SELECT priority + FROM public.roles + WHERE slug = public.normalize_role_slug(role_slug) + LIMIT 1; +$$; + +CREATE OR REPLACE FUNCTION public.profile_role_at_least(profile_uuid uuid, required_slug text) +RETURNS boolean +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + required_priority integer; +BEGIN + IF profile_uuid IS NULL OR required_slug IS NULL THEN + RETURN FALSE; + END IF; + + SELECT priority INTO required_priority + FROM public.roles + WHERE slug = public.normalize_role_slug(required_slug); + + IF required_priority IS NULL THEN + RETURN FALSE; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM public.profile_roles pr + JOIN public.roles r ON r.id = pr.role_id + WHERE pr.profile_id = profile_uuid + AND r.priority <= required_priority + ); +END; +$$; + +CREATE OR REPLACE FUNCTION public.current_profile_id() +RETURNS uuid +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT id + FROM public.profiles + WHERE user_id = auth.uid() + LIMIT 1; +$$; + +CREATE OR REPLACE FUNCTION public.user_role_at_least(required_slug text) +RETURNS boolean +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + profile_uuid uuid; +BEGIN + profile_uuid := public.current_profile_id(); + IF profile_uuid IS NULL THEN + RETURN FALSE; + END IF; + + RETURN public.profile_role_at_least(profile_uuid, required_slug); +END; +$$; + +CREATE OR REPLACE FUNCTION public.user_is_admin() +RETURNS boolean +LANGUAGE sql +STABLE +AS $$ + SELECT COALESCE(( + SELECT is_admin FROM public.profiles WHERE user_id = auth.uid() + ), FALSE); +$$; + +CREATE OR REPLACE FUNCTION public.user_space_role_at_least(space_uuid uuid, required_slug text) +RETURNS boolean +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + profile_uuid uuid; + required_priority integer; +BEGIN + IF space_uuid IS NULL OR required_slug IS NULL THEN + RETURN FALSE; + END IF; + + profile_uuid := public.current_profile_id(); + IF profile_uuid IS NULL THEN + RETURN FALSE; + END IF; + + SELECT priority INTO required_priority + FROM public.roles + WHERE slug = public.normalize_role_slug(required_slug); + + IF required_priority IS NULL THEN + RETURN FALSE; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM public.space_members sm + JOIN public.roles r ON r.id = sm.role_id + WHERE sm.space_id = space_uuid + AND sm.profile_id = profile_uuid + AND sm.status = 'active' + AND r.priority <= required_priority + ); +END; +$$; + +-- --------------------------------------------------------------------- +-- RLS policies (deny-by-default via RLS enablement) +-- --------------------------------------------------------------------- + +-- Spaces visibility and management +DO $$ +BEGIN + PERFORM 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'spaces' AND policyname = 'Spaces readable per visibility'; + IF FOUND THEN + DROP POLICY "Spaces readable per visibility" ON public.spaces; + END IF; + + CREATE POLICY "Spaces readable per visibility" + ON public.spaces + FOR SELECT + USING ( + visibility = 'public' + OR public.user_space_role_at_least(id, 'member') + OR public.user_role_at_least('moderator') + ); + + PERFORM 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'spaces' AND policyname = 'Organizers manage spaces'; + IF FOUND THEN + DROP POLICY "Organizers manage spaces" ON public.spaces; + END IF; + + CREATE POLICY "Organizers manage spaces" + ON public.spaces + FOR ALL + USING ( + public.user_space_role_at_least(id, 'organizer') + OR public.user_role_at_least('admin') + ) + WITH CHECK ( + public.user_space_role_at_least(id, 'organizer') + OR public.user_role_at_least('admin') + ); +END; +$$; + +-- Space members governance +DO $$ +BEGIN + PERFORM 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'space_members' AND policyname = 'Members can view roster'; + IF FOUND THEN + DROP POLICY "Members can view roster" ON public.space_members; + END IF; + + CREATE POLICY "Members can view roster" + ON public.space_members + FOR SELECT + USING ( + public.user_space_role_at_least(space_id, 'member') + OR public.user_role_at_least('moderator') + ); + + PERFORM 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'space_members' AND policyname = 'Organizers manage roster'; + IF FOUND THEN + DROP POLICY "Organizers manage roster" ON public.space_members; + END IF; + + CREATE POLICY "Organizers manage roster" + ON public.space_members + FOR ALL + USING ( + public.user_space_role_at_least(space_id, 'organizer') + OR public.user_role_at_least('admin') + ) + WITH CHECK ( + public.user_space_role_at_least(space_id, 'organizer') + OR public.user_role_at_least('admin') + ); +END; +$$; + +-- Space rules +DO $$ +BEGIN + PERFORM 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'space_rules' AND policyname = 'Rules readable to members'; + IF FOUND THEN + DROP POLICY "Rules readable to members" ON public.space_rules; + END IF; + + CREATE POLICY "Rules readable to members" + ON public.space_rules + FOR SELECT + USING ( + public.user_space_role_at_least(space_id, 'member') + OR public.user_role_at_least('moderator') + OR EXISTS ( + SELECT 1 FROM public.spaces s + WHERE s.id = space_id AND s.visibility = 'public' + ) + ); + + PERFORM 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'space_rules' AND policyname = 'Organizers manage rules'; + IF FOUND THEN + DROP POLICY "Organizers manage rules" ON public.space_rules; + END IF; + + CREATE POLICY "Organizers manage rules" + ON public.space_rules + FOR ALL + USING ( + public.user_space_role_at_least(space_id, 'organizer') + OR public.user_role_at_least('admin') + ) + WITH CHECK ( + public.user_space_role_at_least(space_id, 'organizer') + OR public.user_role_at_least('admin') + ); +END; +$$; + +-- Posts policies (replace legacy ones) +DO $$ +BEGIN + PERFORM 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'posts' AND policyname LIKE 'Authors%'; + WHILE FOUND LOOP + DROP POLICY IF EXISTS "Authors can update their own posts. Admins can update any post." ON public.posts; + DROP POLICY IF EXISTS "Authors can delete their own posts. Admins can delete any post." ON public.posts; + DROP POLICY IF EXISTS "Authors can insert their own posts." ON public.posts; + PERFORM 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'posts' AND policyname LIKE 'Authors%'; + END LOOP; + + DROP POLICY IF EXISTS "Published posts are viewable by everyone." ON public.posts; + DROP POLICY IF EXISTS "Authors can view their own posts." ON public.posts; +END; +$$; + +CREATE POLICY "Posts readable by visibility" + ON public.posts + FOR SELECT + USING ( + status = 'published' + OR public.user_space_role_at_least(space_id, 'member') + OR public.user_role_at_least('moderator') + OR author_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + ); + +CREATE POLICY "Contributors insert posts" + ON public.posts + FOR INSERT + WITH CHECK ( + author_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + AND ( + public.user_role_at_least('contributor') + OR public.user_space_role_at_least(space_id, 'contributor') + ) + ); + +CREATE POLICY "Contributors update drafts" + ON public.posts + FOR UPDATE + USING ( + author_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + OR public.user_role_at_least('moderator') + OR public.user_space_role_at_least(space_id, 'organizer') + ) + WITH CHECK ( + author_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + OR public.user_role_at_least('moderator') + OR public.user_space_role_at_least(space_id, 'organizer') + ); + +CREATE POLICY "Moderators delete posts" + ON public.posts + FOR DELETE + USING ( + public.user_role_at_least('moderator') + OR public.user_space_role_at_least(space_id, 'organizer') + OR author_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + ); + +-- Post versions: mirror post author + staff access +CREATE POLICY "Post versions readable" + ON public.post_versions + FOR SELECT + USING ( + EXISTS ( + SELECT 1 + FROM public.posts p + WHERE p.id = post_id + AND ( + p.author_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + OR public.user_space_role_at_least(p.space_id, 'organizer') + OR public.user_role_at_least('moderator') + ) + ) + ); + +CREATE POLICY "Post versions write access" + ON public.post_versions + FOR ALL + USING ( + EXISTS ( + SELECT 1 + FROM public.posts p + WHERE p.id = post_id + AND ( + p.author_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + OR public.user_space_role_at_least(p.space_id, 'organizer') + OR public.user_role_at_least('moderator') + ) + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 + FROM public.posts p + WHERE p.id = post_id + AND ( + p.author_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + OR public.user_space_role_at_least(p.space_id, 'organizer') + OR public.user_role_at_least('moderator') + ) + ) + ); + +-- Comments policy refresh +DO $$ +BEGIN + DROP POLICY IF EXISTS "Public can read approved comments" ON public.comments; + DROP POLICY IF EXISTS "Authenticated users can insert their comments" ON public.comments; + DROP POLICY IF EXISTS "Admins can manage comments" ON public.comments; +END; +$$; + +CREATE POLICY "Comments readable" + ON public.comments + FOR SELECT + USING ( + status = 'approved' + OR author_profile_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + OR public.user_role_at_least('moderator') + OR EXISTS ( + SELECT 1 + FROM public.posts p + WHERE p.id = post_id + AND public.user_space_role_at_least(p.space_id, 'member') + ) + ); + +CREATE POLICY "Members create comments" + ON public.comments + FOR INSERT + WITH CHECK ( + author_profile_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + AND ( + public.user_role_at_least('member') + OR EXISTS ( + SELECT 1 FROM public.posts p + WHERE p.id = post_id + AND public.user_space_role_at_least(p.space_id, 'member') + ) + ) + ); + +CREATE POLICY "Moderators manage comments" + ON public.comments + FOR ALL + USING ( + public.user_role_at_least('moderator') + OR EXISTS ( + SELECT 1 FROM public.posts p + WHERE p.id = post_id + AND public.user_space_role_at_least(p.space_id, 'organizer') + ) + ) + WITH CHECK ( + public.user_role_at_least('moderator') + OR EXISTS ( + SELECT 1 FROM public.posts p + WHERE p.id = post_id + AND public.user_space_role_at_least(p.space_id, 'organizer') + ) + ); + +-- Reports visibility +DO $$ +BEGIN + DROP POLICY IF EXISTS "Reports readable" ON public.reports; +END; +$$; + +CREATE POLICY "Reports readable" + ON public.reports + FOR SELECT + USING ( + public.user_role_at_least('moderator') + OR public.user_space_role_at_least(space_id, 'moderator') + ); + +CREATE POLICY "Reports manageable" + ON public.reports + FOR ALL + USING ( + public.user_role_at_least('moderator') + OR public.user_space_role_at_least(space_id, 'moderator') + ) + WITH CHECK ( + public.user_role_at_least('moderator') + OR public.user_space_role_at_least(space_id, 'moderator') + ); + +-- Audit log remains service/admin but ensure admin check uses canonical helper +DO $$ +BEGIN + DROP POLICY IF EXISTS "Admins read audit logs" ON public.audit_logs; +END; +$$; + +CREATE POLICY "Admins read audit logs" + ON public.audit_logs + FOR SELECT + USING (public.user_role_at_least('admin')); + +-- Feature flags policy hardened to canonical helper +DO $$ +BEGIN + DROP POLICY IF EXISTS "Admins manage feature flags" ON public.feature_flags; +END; +$$; + +CREATE POLICY "Admins manage feature flags" + ON public.feature_flags + FOR ALL + USING (public.user_role_at_least('admin')) + WITH CHECK (public.user_role_at_least('admin')); + +DO $$ +BEGIN + DROP POLICY IF EXISTS "Admins read feature flag audit" ON public.feature_flag_audit; +END; +$$; + +CREATE POLICY "Admins read feature flag audit" + ON public.feature_flag_audit + FOR SELECT + USING (public.user_role_at_least('admin')); + +-- Profile roles select policy to expose canonical roles to members +DO $$ +BEGIN + DROP POLICY IF EXISTS "Users can read their role assignments" ON public.profile_roles; +END; +$$; + +CREATE POLICY "Users can read their role assignments" + ON public.profile_roles + FOR SELECT + USING ( + profile_id = ( + SELECT id FROM public.profiles WHERE user_id = auth.uid() + ) + OR public.user_role_at_least('moderator') + ); + +-- Ensure roles.slug constrained to canonical set +ALTER TABLE public.roles + ADD CONSTRAINT roles_slug_check + CHECK ( + slug IN ('member', 'contributor', 'organizer', 'moderator', 'admin') + ) NOT VALID; + +-- Backfill to satisfy new constraint by mapping legacy slugs +UPDATE public.roles +SET slug = public.normalize_role_slug(slug) +WHERE slug NOT IN ('member', 'contributor', 'organizer', 'moderator', 'admin'); + +ALTER TABLE public.roles VALIDATE CONSTRAINT roles_slug_check; + +-- --------------------------------------------------------------------- +-- Unit-test helper: ensure highest_role_slug returns canonical member +-- --------------------------------------------------------------------- +DO $$ +DECLARE + member_role uuid; + temp_profile uuid; +BEGIN + SELECT id INTO member_role FROM public.roles WHERE slug = 'member'; + IF member_role IS NULL THEN + RETURN; + END IF; + + INSERT INTO public.profiles(id, user_id, display_name, is_admin) + VALUES (gen_random_uuid(), gen_random_uuid(), 'rbac-test-profile', FALSE) + ON CONFLICT DO NOTHING + RETURNING id INTO temp_profile; + + IF temp_profile IS NULL THEN + RETURN; + END IF; + + INSERT INTO public.profile_roles(profile_id, role_id) + VALUES (temp_profile, member_role) + ON CONFLICT DO NOTHING; + + PERFORM public.highest_role_slug(temp_profile); + + DELETE FROM public.profile_roles WHERE profile_id = temp_profile; + DELETE FROM public.profiles WHERE id = temp_profile; +END; +$$; + +COMMENT ON FUNCTION public.user_space_role_at_least(uuid, text) IS 'Returns true when the current user holds at least the requested role within the given space.'; +COMMENT ON FUNCTION public.highest_role_slug(uuid) IS 'Resolves the canonical highest privilege slug for a profile via determine_primary_role helper.'; +COMMENT ON FUNCTION public.normalize_role_slug(text) IS 'Maps legacy role labels to canonical slugs.'; diff --git a/tests/unit/feature-flags-admin-route.test.ts b/tests/unit/feature-flags-admin-route.test.ts index 06afa06..6803a0b 100644 --- a/tests/unit/feature-flags-admin-route.test.ts +++ b/tests/unit/feature-flags-admin-route.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' +import { NextResponse } from 'next/server' -vi.mock('@/lib/supabase/server-client', () => { - const createServerClient = vi.fn() - const createServiceRoleClient = vi.fn(() => ({ +vi.mock('@/lib/supabase/server-client', () => ({ + createServiceRoleClient: vi.fn(() => ({ from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ order: vi.fn().mockReturnValue({ maybeSingle: vi.fn(), limit: vi.fn() }), @@ -11,10 +11,8 @@ vi.mock('@/lib/supabase/server-client', () => { insert: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ maybeSingle: vi.fn() }) }), update: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ maybeSingle: vi.fn() }) }) }), }), - })) - - return { createServerClient, createServiceRoleClient } -}) + })), +})) vi.mock('@/lib/feature-flags/server', () => ({ getFeatureFlagDefinition: vi.fn(async (flagKey: string) => ({ @@ -31,133 +29,46 @@ vi.mock('@/lib/feature-flags/server', () => ({ upsertFeatureFlagCache: vi.fn(), })) -vi.mock('@/lib/observability/metrics', () => ({ - recordAuthzDeny: vi.fn(), +vi.mock('@/lib/auth/require-admin', () => ({ + requireAdmin: vi.fn(), })) -describe('admin feature flag route guards', () => { - let requireAdminProfile: typeof import('@/app/api/admin/feature-flags/route').requireAdminProfile - let PURGE: typeof import('@/app/api/admin/feature-flags/route').PURGE - let createServerClientMock: Mock - let recordAuthzDenyMock: Mock +describe('admin feature flag route guard integration', () => { + let requireAdminMock: Mock let invalidateFeatureFlagCacheMock: Mock - - const buildSupabaseClient = ({ - user, - profile, - authError = null, - profileError = null, - }: { - user: { id: string } | null - profile: { id: string; is_admin: boolean } | null - authError?: Error | null - profileError?: Error | null - }) => { - const maybeSingle = vi.fn().mockResolvedValue({ data: profile, error: profileError }) - const eq = vi.fn().mockReturnValue({ maybeSingle }) - const select = vi.fn().mockReturnValue({ eq }) - const from = vi.fn().mockReturnValue({ select }) - - return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user }, error: authError }), - }, - from, - } - } + let PURGE: typeof import('@/app/api/admin/feature-flags/route').PURGE beforeEach(async () => { vi.resetModules() - const serverClientModule = await import('@/lib/supabase/server-client') - createServerClientMock = serverClientModule.createServerClient as unknown as Mock - createServerClientMock.mockReset() - - const metricsModule = await import('@/lib/observability/metrics') - recordAuthzDenyMock = metricsModule.recordAuthzDeny as unknown as Mock - recordAuthzDenyMock.mockReset() - const flagServerModule = await import('@/lib/feature-flags/server') - invalidateFeatureFlagCacheMock = flagServerModule.invalidateFeatureFlagCache as unknown as Mock + const featureFlagServerModule = await import('@/lib/feature-flags/server') + invalidateFeatureFlagCacheMock = featureFlagServerModule.invalidateFeatureFlagCache as unknown as Mock invalidateFeatureFlagCacheMock.mockReset() - const module = await import('@/app/api/admin/feature-flags/route') - requireAdminProfile = module.requireAdminProfile - PURGE = module.PURGE - }) - - it('returns 401 when no user session is present and records telemetry', async () => { - createServerClientMock.mockReturnValue(buildSupabaseClient({ user: null, profile: null })) - - const result = await requireAdminProfile() - - expect('response' in result).toBe(true) - if ('response' in result) { - expect(result.response.status).toBe(401) - } - expect(recordAuthzDenyMock).toHaveBeenCalledWith('feature_flag_admin', { reason: 'no_session' }) - }) - - it('returns 403 when profile missing and records telemetry', async () => { - createServerClientMock.mockReturnValue( - buildSupabaseClient({ user: { id: 'user-1' }, profile: null }), - ) - - const result = await requireAdminProfile() - - expect('response' in result).toBe(true) - if ('response' in result) { - expect(result.response.status).toBe(403) - } - expect(recordAuthzDenyMock).toHaveBeenCalledWith( - 'feature_flag_admin', - expect.objectContaining({ reason: 'missing_profile', user_id: 'user-1' }), - ) - }) - - it('returns 403 when profile is not admin and records telemetry', async () => { - createServerClientMock.mockReturnValue( - buildSupabaseClient({ user: { id: 'user-2' }, profile: { id: 'profile-2', is_admin: false } }), - ) - - const result = await requireAdminProfile() - - expect('response' in result).toBe(true) - if ('response' in result) { - expect(result.response.status).toBe(403) - } - expect(recordAuthzDenyMock).toHaveBeenCalledWith( - 'feature_flag_admin', - expect.objectContaining({ reason: 'forbidden', profile_id: 'profile-2' }), - ) - }) - - it('returns profile when admin', async () => { - createServerClientMock.mockReturnValue( - buildSupabaseClient({ user: { id: 'user-3' }, profile: { id: 'profile-3', is_admin: true } }), - ) - - const result = await requireAdminProfile() + const guardModule = await import('@/lib/auth/require-admin') + requireAdminMock = guardModule.requireAdmin as unknown as Mock + requireAdminMock.mockReset() - expect('profile' in result).toBe(true) - if ('profile' in result) { - expect(result.profile.id).toBe('profile-3') - } - expect(recordAuthzDenyMock).not.toHaveBeenCalled() + const routeModule = await import('@/app/api/admin/feature-flags/route') + PURGE = routeModule.PURGE }) - it('guards PURGE endpoint by admin check', async () => { - createServerClientMock.mockReturnValue(buildSupabaseClient({ user: null, profile: null })) + it('returns guard response when authorization fails', async () => { + const denial = NextResponse.json({ error: 'nope' }, { status: 403 }) + requireAdminMock.mockResolvedValue({ ok: false, response: denial }) const response = await PURGE() - expect(response.status).toBe(401) + expect(response.status).toBe(403) expect(invalidateFeatureFlagCacheMock).not.toHaveBeenCalled() + expect(requireAdminMock).toHaveBeenCalledWith({ resource: 'feature_flag_admin', action: 'purge' }) }) - it('allows PURGE when admin and invalidates cache', async () => { - createServerClientMock.mockReturnValue( - buildSupabaseClient({ user: { id: 'user-4' }, profile: { id: 'profile-4', is_admin: true } }), - ) + it('clears cache when guard grants access', async () => { + requireAdminMock.mockResolvedValue({ + ok: true, + profile: { id: 'profile-1', userId: 'user-1', roleSlug: 'admin', isAdmin: true }, + }) const response = await PURGE() diff --git a/tests/unit/require-admin.test.ts b/tests/unit/require-admin.test.ts new file mode 100644 index 0000000..5e40cf1 --- /dev/null +++ b/tests/unit/require-admin.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Mock } from 'vitest' + +vi.mock('@/lib/observability/metrics', () => ({ + recordAuthzDeny: vi.fn(), +})) + +vi.mock('@/lib/audit/log', () => ({ + writeAuditLog: vi.fn(), +})) + +vi.mock('@/lib/supabase/server-client', () => ({ + createServerClient: vi.fn(), + createServiceRoleClient: vi.fn(), +})) + +const buildMaybeSingle = (result: unknown, error: Error | null = null) => + vi.fn().mockResolvedValue({ data: result, error }) + +describe('requireAdmin guard', () => { + let createServerClientMock: Mock + let createServiceRoleClientMock: Mock + let recordAuthzDenyMock: Mock + let writeAuditLogMock: Mock + + beforeEach(async () => { + vi.resetModules() + + const serverClientFactory = await import('@/lib/supabase/server-client') + createServerClientMock = serverClientFactory.createServerClient as unknown as Mock + createServiceRoleClientMock = serverClientFactory.createServiceRoleClient as unknown as Mock + createServerClientMock.mockReset() + createServiceRoleClientMock.mockReset() + + const metricsModule = await import('@/lib/observability/metrics') + recordAuthzDenyMock = metricsModule.recordAuthzDeny as unknown as Mock + recordAuthzDenyMock.mockReset() + + const auditModule = await import('@/lib/audit/log') + writeAuditLogMock = auditModule.writeAuditLog as unknown as Mock + writeAuditLogMock.mockReset() + }) + + const configureMocks = ({ + user, + profile, + profileError, + roleSlug = 'admin', + authError = null, + }: { + user: { id: string } | null + profile: { id: string; is_admin: boolean; primary_role_id: string | null } | null + profileError?: Error | null + roleSlug?: string + authError?: Error | null + }) => { + const profileMaybeSingle = buildMaybeSingle(profile, profileError ?? null) + const profileEq = vi.fn().mockReturnValue({ maybeSingle: profileMaybeSingle }) + const profileSelect = vi.fn().mockReturnValue({ eq: profileEq }) + const profileFrom = vi.fn().mockReturnValue({ select: profileSelect }) + + createServerClientMock.mockReturnValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user }, error: authError ?? null }) }, + from: profileFrom, + }) + + const roleMaybeSingle = buildMaybeSingle({ slug: roleSlug }) + const roleEq = vi.fn().mockReturnValue({ maybeSingle: roleMaybeSingle }) + const roleSelect = vi.fn().mockReturnValue({ eq: roleEq }) + const roleFrom = vi.fn().mockReturnValue({ select: roleSelect }) + + createServiceRoleClientMock.mockReturnValue({ from: roleFrom }) + } + + it('returns 500 when session lookup fails', async () => { + configureMocks({ user: null, profile: null, profileError: null, authError: new Error('boom') }) + const { requireAdmin } = await import('@/lib/auth/require-admin') + + const result = await requireAdmin({ resource: 'admin_test', action: 'read' }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.response.status).toBe(500) + } + expect(recordAuthzDenyMock).not.toHaveBeenCalled() + expect(writeAuditLogMock).not.toHaveBeenCalled() + }) + + it('returns 401 when no user session', async () => { + configureMocks({ user: null, profile: null }) + const { requireAdmin } = await import('@/lib/auth/require-admin') + + const result = await requireAdmin({ resource: 'admin_test', action: 'read' }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.response.status).toBe(401) + } + expect(recordAuthzDenyMock).toHaveBeenCalledWith('admin_test', { + role: 'unknown', + reason: 'no_session', + }) + expect(writeAuditLogMock).toHaveBeenCalledWith( + expect.objectContaining({ action: 'access_denied', reason: 'no_session' }), + ) + }) + + it('returns 403 when profile missing', async () => { + configureMocks({ user: { id: 'user-1' }, profile: null }) + const { requireAdmin } = await import('@/lib/auth/require-admin') + + const result = await requireAdmin({ resource: 'admin_test', action: 'read', entityId: 'entity-1' }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.response.status).toBe(403) + } + expect(recordAuthzDenyMock).toHaveBeenCalledWith('admin_test', { + role: 'unknown', + reason: 'missing_profile', + space: undefined, + }) + expect(writeAuditLogMock).toHaveBeenCalledWith( + expect.objectContaining({ entityId: 'entity-1', reason: 'missing_profile' }), + ) + }) + + it('returns 403 when user lacks admin privileges', async () => { + configureMocks({ + user: { id: 'user-2' }, + profile: { id: 'profile-2', is_admin: false, primary_role_id: 'role-123' }, + roleSlug: 'contributor', + }) + const { requireAdmin } = await import('@/lib/auth/require-admin') + + const result = await requireAdmin({ resource: 'admin_test', action: 'update', spaceId: 'space-9' }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.response.status).toBe(403) + } + expect(recordAuthzDenyMock).toHaveBeenCalledWith('admin_test', { + role: 'contributor', + reason: 'forbidden', + space: 'space-9', + }) + expect(writeAuditLogMock).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'profile-2', reason: 'forbidden' }), + ) + }) + + it('returns profile context when admin', async () => { + configureMocks({ + user: { id: 'user-3' }, + profile: { id: 'profile-3', is_admin: true, primary_role_id: 'role-abc' }, + roleSlug: 'admin', + }) + const { requireAdmin } = await import('@/lib/auth/require-admin') + + const result = await requireAdmin({ resource: 'admin_test', action: 'read' }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.profile).toEqual({ + id: 'profile-3', + userId: 'user-3', + roleSlug: 'admin', + isAdmin: true, + }) + } + expect(recordAuthzDenyMock).not.toHaveBeenCalled() + expect(writeAuditLogMock).not.toHaveBeenCalled() + }) +}) From e95c357aee1c0d9ce96fdfbb423bed88e3a54ade Mon Sep 17 00:00:00 2001 From: Prashant Choudhary <81732236+Mr-Dark-debug@users.noreply.github.com> Date: Sat, 18 Oct 2025 09:39:12 +0530 Subject: [PATCH 2/4] feat(sec): finalize rbac, nav, observability slices --- docs/05-ui-ux-delta.md | 16 ++ docs/06-data-model-delta.md | 1 + docs/07-security-privacy.md | 6 +- docs/08-observability.md | 6 +- docs/10-release-plan.md | 2 +- docs/operations/runbooks/rls-denial-spike.md | 46 ++++ docs/progress/weekly-2025-10-24.md | 25 ++ src/app/api/admin/users/[id]/route.ts | 249 +++++++++++------ src/app/api/admin/users/route.ts | 244 ++++++++++------- src/components/admin/AdminDashboard.tsx | 4 +- src/components/admin/RoleManager.tsx | 250 ++++++++++++++++++ src/components/ui/NewNavbar.tsx | 99 +++++-- src/lib/navigation.ts | 60 ++++- src/lib/observability/metrics.ts | 4 + src/lib/rbac/permissions.ts | 97 +++++++ .../0021_sec_001_constraints_indexes.down.sql | 8 + .../0021_sec_001_constraints_indexes.sql | 40 +++ tailwind.config.js | 29 ++ tests/synthetic/observability.spec.ts | 24 ++ tests/unit/rbac-permissions.test.ts | 56 ++++ 20 files changed, 1065 insertions(+), 201 deletions(-) create mode 100644 docs/operations/runbooks/rls-denial-spike.md create mode 100644 docs/progress/weekly-2025-10-24.md create mode 100644 src/components/admin/RoleManager.tsx create mode 100644 src/lib/rbac/permissions.ts create mode 100644 supabase/migrations/0021_sec_001_constraints_indexes.down.sql create mode 100644 supabase/migrations/0021_sec_001_constraints_indexes.sql create mode 100644 tests/synthetic/observability.spec.ts create mode 100644 tests/unit/rbac-permissions.test.ts diff --git a/docs/05-ui-ux-delta.md b/docs/05-ui-ux-delta.md index de82ab5..e0e6872 100644 --- a/docs/05-ui-ux-delta.md +++ b/docs/05-ui-ux-delta.md @@ -27,11 +27,26 @@ - **Elevation:** Create shadow tokens for interactive surfaces (cards, modals) aligned with neo-brutalism outlines. - **Radius:** Maintain bold outlines but add `radius-sm` (4px) for chip components and `radius-lg` (16px) for cards. +### 3.1 Token Snapshot — 2025-10-31 +| Token | Value | Notes | +| --- | --- | --- | +| `brand.ink` | `#1F1F1F` | Primary copy color for Role Manager, nav links, and badges. | +| `brand.panel` | `#FFF8F1` | Background for RBAC Role Manager container; meets 7:1 contrast against ink text. | +| `brand.surface.info` | `#F2F4FF` | Applied to informational badges within nav IA flyouts. | +| `brand.border.muted` | `#D9D3F4` | Outline for neutral stats in Role Manager roster. | +| `brand.border.warning` | `#FFB020` | Moderator badge border; accessible against ink text. | +| `brand.focus` | `#6C63FF` | Used for ring styles on search, skip-link, and nav buttons. | +| `shadow-brand-sm` | `4px 4px 0px rgba(34, 34, 34, 0.12)` | Applied to summary cards and nav chips. | +| `shadow-brand-lg` | `8px 8px 0px rgba(34, 34, 34, 0.18)` | Elevated Role Manager forms. | + +Tokens are declared in `tailwind.config.js` and consumed within `src/components/admin/RoleManager.tsx` and `src/components/ui/NewNavbar.tsx` to ensure a single source of truth. + ## 4. Accessibility Notes - All new interactive components must support keyboard navigation, focus-visible styles, and ARIA attributes. - Provide descriptive labels for toggles (e.g., fee coverage slider) and ensure error messages include guidance. - For events, include accessibility notes (wheelchair access, ASL availability) and ensure color-coded statuses have text equivalents. - Implement reduced motion mode for animations in reputation celebrations and feed transitions. +- RBAC Role Manager exposes live search and roster list with `aria-live="polite"` status updates and keyboard-visible focus rings tied to `brand.focus`. ## 5. Responsive Behavior - **Mobile:** Sticky quick actions (Join Space, New Post) at bottom; collapsible filters for search and moderation queues. @@ -42,3 +57,4 @@ - Maintain component specs in Storybook (to be configured) with accessibility checklists. - Update `neobrutalismthemecomp.MD` with new tokens and examples. - Capture screenshots/GIFs for each new flow when feature flags progress to pilot. +- Telemetry: `recordNavInteraction` logs `nav_interaction_total{target,from,variant,role}` from `NewNavbar`, enabling engagement panels on `dash_ops_rbac_v1`. diff --git a/docs/06-data-model-delta.md b/docs/06-data-model-delta.md index 42cce4b..f2ffa8c 100644 --- a/docs/06-data-model-delta.md +++ b/docs/06-data-model-delta.md @@ -27,6 +27,7 @@ ### Index & Policy Update — 2025-10-31 - 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)`. +- 2025-10-31 Follow-up (`0021_sec_001_constraints_indexes.sql`): Added unique index `profile_roles_profile_role_idx`, composite moderation index `space_members_role_status_v2_idx`, refined post timeline index `posts_space_status_published_idx`, and `comments_thread_status_created_idx` to stabilize queue queries. Enforced canonical slug constraint `roles_slug_canonical_ck` to guarantee `normalize_role_slug` invariants. - Constraints: canonical slug check on `roles.slug`; composite PK enforced on `space_members`. - 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`. | `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`) | diff --git a/docs/07-security-privacy.md b/docs/07-security-privacy.md index 719a34e..b9b6abf 100644 --- a/docs/07-security-privacy.md +++ b/docs/07-security-privacy.md @@ -27,9 +27,11 @@ Full matrix with endpoint mapping maintained alongside Supabase policy definitions. Automated tests validate allow/deny paths per `/tests/security`. -**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`. +- Integration coverage refreshed on 2025-10-31: `tests/security/rbac-policies.test.ts` exercises the role/action/table matrix using Supabase service credentials. Route-level guards for admin user management are unit tested (`tests/unit/require-admin.test.ts`, `tests/unit/feature-flags-admin-route.test.ts`) and new permissions helpers are validated via `tests/unit/rbac-permissions.test.ts`. -**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(''::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). +**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`. The role manager UI behind `rbac_hardening_v1` surfaces canonical badges, enforces audit logging on each mutation, and is covered by synthetic navigation tests plus axe scans. + +**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(''::uuid)`). Rollback: toggle `rbac_hardening_v1` off, apply migration `0020_sec_001_rls_policies.down.sql`, restore from PITR if required. Document investigation outcomes in `/docs/operations/runbooks/rls-denial-spike.md`. ## 3. Input Validation & Sanitization - Use Zod schemas for all API inputs, with centralized validation utilities. diff --git a/docs/08-observability.md b/docs/08-observability.md index d94787e..b22aeca 100644 --- a/docs/08-observability.md +++ b/docs/08-observability.md @@ -19,10 +19,11 @@ | `crash_free_sessions` | % of sessions without fatal error | Gauge | `platform` | | `authz_denied_count` | Authorization failures | Counter | `resource`, `role`, `space` | | `flag_evaluation_latency_ms` | Feature flag evaluation | Histogram | `flag_key` | +| `nav_interaction_total` | Nav hub clicks (flag cohorts) | Counter | `target`, `from`, `variant`, `role` | | `webhook_delivery_success_rate` | Webhook successes vs. attempts | Gauge | `event_type` | | `automod_trigger_count` | Automod actions per rule | Counter | `rule_type`, `space` | -> 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. +> 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. `nav_interaction_total` now captures navigation hub engagement per flag cohort. ## 3. Tracing Strategy - Instrument Next.js route handlers and server components with OpenTelemetry. @@ -38,6 +39,7 @@ ## 5. Dashboards - **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. - **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. +- **Navigation Engagement Panel (`dash_ops_nav_v1`):** Breaks down `nav_interaction_total` by target hub, role, and variant (legacy vs `nav_ia_v1`). - **Commerce Dashboard:** Shows donation funnel, payout queue status, dispute rate. - **Events Dashboard:** Tracks registrations, attendance, revenue, NPS survey results. - **Reliability Dashboard:** SLO status, error budgets, incident history. @@ -68,7 +70,7 @@ - Adopt OpenTelemetry SDK for Next.js + Node workers; export to vendor (e.g., Grafana Cloud, Honeycomb). - Use Supabase Logflare integration for SQL audit, complement with custom metrics via functions. - Configure synthetic monitoring (Pingdom/Lighthouse CI) for home feed, space page, checkout flow. -- Add Playwright synthetic tests for core user journeys with metrics logging. +- Add Playwright synthetic tests for core user journeys with metrics logging. `tests/synthetic/observability.spec.ts` covers nav IA, admin flag guard, and publish route smoke flows (skipped when `PLAYWRIGHT_TEST_BASE_URL` undefined). ## 9. Runbooks - Create `/docs/operations/runbooks/` with scenario-specific guides (publish latency, payment failures, search outage). diff --git a/docs/10-release-plan.md b/docs/10-release-plan.md index ed6052b..924832e 100644 --- a/docs/10-release-plan.md +++ b/docs/10-release-plan.md @@ -4,7 +4,7 @@ | Flag Key | Purpose | Default | Owner | Notes | | --- | --- | --- | --- | --- | | `rbac_hardening_v1` | Locks down canonical role ladder, admin tooling | OFF | Security Lead | Staff-only until Phase-1 gate | -| `nav_ia_v1` | Enables refreshed navigation IA + tokens | OFF | Design Lead | Staged rollout via staff cohort | +| `nav_ia_v1` | Enables refreshed navigation IA + tokens | OFF | Design Lead | Staged rollout via staff cohort; nav telemetry recorded via `nav_interaction_total` | | `observability_v1` | Surfaces observability UI surfaces | OFF | SRE Lead | Infra toggle, dashboards verified first | | `spaces_v1` | Enables space creation, rules, membership | OFF | Product Lead | Phase 2 pilot with selected communities | | `content_templates_v1` | Activates new editors/templates | OFF | Content PM | Depends on `spaces_v1` | diff --git a/docs/operations/runbooks/rls-denial-spike.md b/docs/operations/runbooks/rls-denial-spike.md new file mode 100644 index 0000000..de0a0d7 --- /dev/null +++ b/docs/operations/runbooks/rls-denial-spike.md @@ -0,0 +1,46 @@ +# Runbook: RLS Denial Spike + +**Last updated:** 2025-10-31 + +## 1. Detection +- Alert `pd-sec-ops::rbac_denials_spike` triggers when `authz_denied_count` > 25/min tagged `resource=admin_users` or `resource=spaces`. +- Operations dashboard panel `dash_ops_rbac_v1 :: RBAC Denials` shows trend by `resource`, `role`, `space` tags. +- Synthetic check `observability synthetic journeys › admin flag console guard surfaces login` failing may indicate lockout. + +## 2. Immediate Actions +1. Confirm the spike in Grafana panel and correlate to recent deployments/flag toggles. +2. Query Supabase audit logs: + ```sql + select created_at, actor_role, action, reason, metadata + from audit_logs + where resource = 'admin_users' + and created_at > now() - interval '1 hour' + order by created_at desc; + ``` +3. Validate helper function output for affected profiles: + ```sql + select highest_role_slug(''::uuid); + ``` +4. Check feature flag history for `rbac_hardening_v1` and related nav flags in `/admin/feature-flags` (audit entries are stored in `feature_flag_audit`). + +## 3. Mitigation +- If regression tied to new policies, toggle `rbac_hardening_v1` OFF for all but security staff. +- Re-run policy tests via `npm run test -- tests/security/rbac-policies.test.ts` in staging. +- If helper function bug, apply hotfix migration or revert via `supabase/migrations/0020_sec_001_rls_policies.down.sql` followed by redeploy. +- For false positives (expected denials), adjust alert threshold temporarily via Grafana annotations and document rationale. + +## 4. Communication +- Update #ops-telemetry Slack channel with incident timeline and current mitigation steps. +- If admin access degraded for staff, notify affected cohorts and provide estimated resolution time. +- Log incident in `/docs/operations/incidents/YYYY-MM-DD-.md` once stabilized. + +## 5. Recovery & Verification +- After mitigation, re-enable `rbac_hardening_v1` for staff cohort and monitor `authz_denied_count` for 30 minutes. +- Ensure Playwright synthetic journeys pass in CI (`tests/synthetic/observability.spec.ts`). +- Capture metrics snapshot and attach to incident record. + +## 6. Postmortem Checklist +- Identify root cause (policy regression, role sync failure, guard bug). +- Add automated test coverage if gap discovered. +- Update documentation and backlog items if new work required. +- Close incident with summary and action items in weekly progress log. diff --git a/docs/progress/weekly-2025-10-24.md b/docs/progress/weekly-2025-10-24.md new file mode 100644 index 0000000..1d7d8f3 --- /dev/null +++ b/docs/progress/weekly-2025-10-24.md @@ -0,0 +1,25 @@ +# Weekly Progress — 2025-10-24 + +## Shipped Tickets +- SEC-001: RBAC/RLS hardening (Role Manager UI, policy migrations, guard telemetry) behind `rbac_hardening_v1`. +- UX-010: Navigation IA & design tokens applied to top nav and admin role tooling behind `nav_ia_v1`. +- OBS-100: Observability baseline instrumentation (nav interaction metrics, synthetic journeys, structured logging enrichments) behind `observability_v1` UI surfaces. + +## Flags Enabled +- `rbac_hardening_v1`: Staff-only in staging for Role Manager validation. +- `nav_ia_v1`: Staff-only cohort to evaluate hub engagement. +- `observability_v1`: SRE sandbox only; dashboards linked but UI gates remain off for general users. + +## KPI Deltas +- `authz_denied_count` trending flat (≤2/min) after Role Manager smoke tests. +- `nav_interaction_total` baseline captured for staff navigation (~35 clicks/hour) to tune IA. +- `content_publish_latency_ms` P95 steady at 4.2s post-instrumentation. + +## New Risks / Assumptions +- A-009: Flag ownership clarified (Design Lead for nav IA, SRE Lead for observability); update kept open for Phase-1 tracking. +- Monitoring gap: Playwright synthetic suite depends on `PLAYWRIGHT_TEST_BASE_URL`; missing env will skip checks—documented for CI configuration. + +## Next Targets +- Harden Storybook coverage for tokenized components (SpaceHeader, TemplatePickerModal, DonationWidget, EventCard). +- Expand RLS integration tests to cover reports/audit log mutations with moderator/admin roles. +- Connect dashboards to production Grafana workspace and validate alert routing (`pd-sec-ops`, `ops-telemetry`). diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index ffbb263..e72b386 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -3,6 +3,9 @@ import { createServiceRoleClient } from '@/lib/supabase/server-client' import type { UpdateAdminUserPayload } from '@/utils/types' import { requireAdmin } from '@/lib/auth/require-admin' import { writeAuditLog } from '@/lib/audit/log' +import { withSpan } from '@/lib/observability/tracing' +import { logStructuredEvent } from '@/lib/observability/logger' +import { recordAuthzDeny } from '@/lib/observability/metrics' import { fetchRoles, fetchProfileById, @@ -52,70 +55,105 @@ export async function PATCH( const serviceClient = createServiceRoleClient() - try { - const profileRecord = await fetchProfileById(serviceClient, profileId) + return withSpan( + 'admin.users.update', + { + resource: 'admin_users', + actor_id: guard.profile.id, + actor_role: guard.profile.roleSlug, + target_profile_id: profileId, + }, + async () => { + try { + const profileRecord = await fetchProfileById(serviceClient, profileId) - const roles = await fetchRoles(serviceClient) + const roles = await fetchRoles(serviceClient) - const { error: profileUpdateError } = await serviceClient - .from('profiles') - .update({ display_name: displayName, is_admin: isAdmin }) - .eq('id', profileId) + const { error: profileUpdateError } = await serviceClient + .from('profiles') + .update({ display_name: displayName, is_admin: isAdmin }) + .eq('id', profileId) - if (profileUpdateError) { - throw new Error(`Unable to update profile: ${profileUpdateError.message}`) - } + if (profileUpdateError) { + throw new Error(`Unable to update profile: ${profileUpdateError.message}`) + } - await ensureRoleAssignments(serviceClient, profileId, roles, requestedRoles, isAdmin) + await ensureRoleAssignments(serviceClient, profileId, roles, requestedRoles, isAdmin) - if (newPassword) { - if (newPassword.length < 8) { - return NextResponse.json( - { error: 'New password must be at least 8 characters long.' }, - { status: 400 }, - ) - } + if (newPassword) { + if (newPassword.length < 8) { + return NextResponse.json( + { error: 'New password must be at least 8 characters long.' }, + { status: 400 }, + ) + } - const { error: passwordError } = await serviceClient.auth.admin.updateUserById( - profileRecord.user_id, - { password: newPassword }, - ) + const { error: passwordError } = await serviceClient.auth.admin.updateUserById( + profileRecord.user_id, + { password: newPassword }, + ) - if (passwordError) { - throw new Error(`Unable to update password: ${passwordError.message}`) - } - } + if (passwordError) { + throw new Error(`Unable to update password: ${passwordError.message}`) + } + } - const refreshedProfile = await fetchProfileById(serviceClient, profileId) - const summary = await buildUserSummary(serviceClient, refreshedProfile) + const refreshedProfile = await fetchProfileById(serviceClient, profileId) + const summary = await buildUserSummary(serviceClient, refreshedProfile) - await writeAuditLog({ - actorId: guard.profile.id, - actorRole: guard.profile.roleSlug, - resource: 'admin_users', - action: 'user_updated', - entityId: summary.profileId, - metadata: { - display_name: displayName, - role_slugs: requestedRoles, - is_admin: isAdmin, - password_reset: Boolean(newPassword), - }, - }) + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: guard.profile.roleSlug, + resource: 'admin_users', + action: 'user_updated', + entityId: summary.profileId, + metadata: { + display_name: displayName, + role_slugs: requestedRoles, + is_admin: isAdmin, + password_reset: Boolean(newPassword), + }, + }) - return NextResponse.json({ user: summary }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to update user.' - let status = 500 - if (error instanceof Error) { - if (message.startsWith('Unknown role slug')) { - status = 400 - } else if (message.includes('Profile not found')) { - status = 404 + logStructuredEvent({ + message: 'admin_users:update_succeeded', + level: 'info', + userId: guard.profile.userId, + metadata: { + target_profile_id: summary.profileId, + actor_profile_id: guard.profile.id, + role_count: requestedRoles.length, + password_reset: Boolean(newPassword), + }, + }) + + return NextResponse.json({ user: summary }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to update user.' + let status = 500 + if (error instanceof Error) { + if (message.startsWith('Unknown role slug')) { + status = 400 + } else if (message.includes('Profile not found')) { + status = 404 + } + } + + logStructuredEvent({ + message: 'admin_users:update_failed', + level: 'error', + userId: guard.profile.userId, + metadata: { + target_profile_id: profileId, + actor_profile_id: guard.profile.id, + reason: message, + }, + }) + + return NextResponse.json({ error: message }, { status }) } - } - return NextResponse.json({ error: message }, { status }) - } + }, + ) } export async function DELETE( @@ -139,6 +177,10 @@ export async function DELETE( const { id: currentProfileId, roleSlug } = guard.profile if (profileId === currentProfileId) { + recordAuthzDeny('admin_users', { + role: roleSlug, + reason: 'self_delete_attempt', + }) return NextResponse.json( { error: 'You cannot delete your own account.' }, { status: 400 }, @@ -147,44 +189,77 @@ export async function DELETE( const serviceClient = createServiceRoleClient() - try { - const profileRecord = await fetchProfileById(serviceClient, profileId) + return withSpan( + 'admin.users.delete', + { + resource: 'admin_users', + actor_id: guard.profile.id, + actor_role: roleSlug, + target_profile_id: profileId, + }, + async () => { + try { + const profileRecord = await fetchProfileById(serviceClient, profileId) - const { error: authDeleteError } = await serviceClient.auth.admin.deleteUser( - profileRecord.user_id, - ) + const { error: authDeleteError } = await serviceClient.auth.admin.deleteUser( + profileRecord.user_id, + ) - if (authDeleteError) { - throw new Error(`Unable to delete auth user: ${authDeleteError.message}`) - } + if (authDeleteError) { + throw new Error(`Unable to delete auth user: ${authDeleteError.message}`) + } - const { error: profileDeleteError } = await serviceClient - .from('profiles') - .delete() - .eq('id', profileId) + const { error: profileDeleteError } = await serviceClient + .from('profiles') + .delete() + .eq('id', profileId) - if (profileDeleteError) { - throw new Error(`Unable to delete profile: ${profileDeleteError.message}`) - } + if (profileDeleteError) { + throw new Error(`Unable to delete profile: ${profileDeleteError.message}`) + } - await writeAuditLog({ - actorId: guard.profile.id, - actorRole: roleSlug, - resource: 'admin_users', - action: 'user_deleted', - entityId: profileId, - metadata: { - deleted_user_id: profileRecord.user_id, - }, - }) + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: roleSlug, + resource: 'admin_users', + action: 'user_deleted', + entityId: profileId, + metadata: { + deleted_user_id: profileRecord.user_id, + }, + }) - return NextResponse.json({ success: true }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to delete user.' - let status = 500 - if (error instanceof Error && message.includes('Profile not found')) { - status = 404 - } - return NextResponse.json({ error: message }, { status }) - } + logStructuredEvent({ + message: 'admin_users:delete_succeeded', + level: 'info', + userId: guard.profile.userId, + metadata: { + target_profile_id: profileId, + actor_profile_id: guard.profile.id, + }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to delete user.' + let status = 500 + if (error instanceof Error && message.includes('Profile not found')) { + status = 404 + } + + logStructuredEvent({ + message: 'admin_users:delete_failed', + level: 'error', + userId: guard.profile.userId, + metadata: { + target_profile_id: profileId, + actor_profile_id: guard.profile.id, + reason: message, + }, + }) + + return NextResponse.json({ error: message }, { status }) + } + }, + ) } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index f98a66a..2a32ae5 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from 'next/server' import { createServiceRoleClient } from '@/lib/supabase/server-client' import { requireAdmin } from '@/lib/auth/require-admin' import { writeAuditLog } from '@/lib/audit/log' +import { withSpan } from '@/lib/observability/tracing' +import { logStructuredEvent } from '@/lib/observability/logger' import type { AdminRole, CreateAdminUserPayload } from '@/utils/types' import { fetchRoles, @@ -36,26 +38,56 @@ export async function GET() { return guard.response } - try { - const serviceClient = createServiceRoleClient() - const [roles, users] = await Promise.all([ - fetchRoles(serviceClient), - loadAllUserSummaries(serviceClient), - ]) - - const formattedRoles: AdminRole[] = roles.map((role) => ({ - id: role.id, - slug: role.slug, - name: role.name, - description: role.description, - priority: role.priority, - })) - - return NextResponse.json({ users, roles: formattedRoles }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to load users.' - return NextResponse.json({ error: message }, { status: 500 }) - } + return withSpan( + 'admin.users.list', + { + resource: 'admin_users', + actor_id: guard.profile.id, + actor_role: guard.profile.roleSlug, + }, + async () => { + try { + const serviceClient = createServiceRoleClient() + const [roles, users] = await Promise.all([ + fetchRoles(serviceClient), + loadAllUserSummaries(serviceClient), + ]) + + const formattedRoles: AdminRole[] = roles.map((role) => ({ + id: role.id, + slug: role.slug, + name: role.name, + description: role.description, + priority: role.priority, + })) + + logStructuredEvent({ + message: 'admin_users:list_succeeded', + level: 'info', + userId: guard.profile.userId, + metadata: { + actor_profile_id: guard.profile.id, + role_count: formattedRoles.length, + user_count: users.length, + }, + }) + + return NextResponse.json({ users, roles: formattedRoles }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to load users.' + logStructuredEvent({ + message: 'admin_users:list_failed', + level: 'error', + userId: guard.profile.userId, + metadata: { + actor_profile_id: guard.profile.id, + reason: message, + }, + }) + return NextResponse.json({ error: message }, { status: 500 }) + } + }, + ) } export async function POST(request: Request) { @@ -89,75 +121,107 @@ export async function POST(request: Request) { const serviceClient = createServiceRoleClient() - try { - const roles = await fetchRoles(serviceClient) - - const { data: authResult, error: authError } = await serviceClient.auth.admin.createUser({ - email, - password, - email_confirm: true, - }) - - if (authError) { - throw new Error(`Unable to create user: ${authError.message}`) - } - - const authUser = authResult.user - if (!authUser) { - throw new Error('Unable to create user: missing auth record.') - } - - const { data: profileData, error: profileError } = await serviceClient - .from('profiles') - .upsert( - { - user_id: authUser.id, - display_name: displayName, - is_admin: isAdmin, - }, - { onConflict: 'user_id' }, - ) - .select( - `id, user_id, display_name, is_admin, created_at, primary_role_id, - profile_roles(role:roles(id, slug, name, description, priority))`, - ) - .single() - - if (profileError || !profileData) { - throw new Error( - `Unable to provision profile: ${profileError?.message ?? 'missing record.'}`, - ) - } - - await ensureRoleAssignments( - serviceClient, - profileData.id, - roles, - requestedRoles, - isAdmin, - ) - - const refreshedProfile = await fetchProfileById(serviceClient, profileData.id) - const summary = await buildUserSummary(serviceClient, refreshedProfile) - - await writeAuditLog({ - actorId: guard.profile.id, - actorRole: guard.profile.roleSlug, + return withSpan( + 'admin.users.create', + { resource: 'admin_users', - action: 'user_created', - entityId: summary.profileId, - metadata: { - email, - is_admin: isAdmin, - role_slugs: requestedRoles, - }, - }) - - return NextResponse.json({ user: summary }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to create user.' - const status = - error instanceof Error && message.startsWith('Unknown role slug') ? 400 : 500 - return NextResponse.json({ error: message }, { status }) - } + actor_id: guard.profile.id, + actor_role: guard.profile.roleSlug, + }, + async () => { + try { + const roles = await fetchRoles(serviceClient) + + const { data: authResult, error: authError } = await serviceClient.auth.admin.createUser({ + email, + password, + email_confirm: true, + }) + + if (authError) { + throw new Error(`Unable to create user: ${authError.message}`) + } + + const authUser = authResult.user + if (!authUser) { + throw new Error('Unable to create user: missing auth record.') + } + + const { data: profileData, error: profileError } = await serviceClient + .from('profiles') + .upsert( + { + user_id: authUser.id, + display_name: displayName, + is_admin: isAdmin, + }, + { onConflict: 'user_id' }, + ) + .select( + `id, user_id, display_name, is_admin, created_at, primary_role_id, + profile_roles(role:roles(id, slug, name, description, priority))`, + ) + .single() + + if (profileError || !profileData) { + throw new Error( + `Unable to provision profile: ${profileError?.message ?? 'missing record.'}`, + ) + } + + await ensureRoleAssignments( + serviceClient, + profileData.id, + roles, + requestedRoles, + isAdmin, + ) + + const refreshedProfile = await fetchProfileById(serviceClient, profileData.id) + const summary = await buildUserSummary(serviceClient, refreshedProfile) + + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: guard.profile.roleSlug, + resource: 'admin_users', + action: 'user_created', + entityId: summary.profileId, + metadata: { + email, + is_admin: isAdmin, + role_slugs: requestedRoles, + }, + }) + + logStructuredEvent({ + message: 'admin_users:create_succeeded', + level: 'info', + userId: guard.profile.userId, + metadata: { + created_profile_id: summary.profileId, + actor_profile_id: guard.profile.id, + role_count: requestedRoles.length, + }, + }) + + return NextResponse.json({ user: summary }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to create user.' + const status = + error instanceof Error && message.startsWith('Unknown role slug') ? 400 : 500 + + logStructuredEvent({ + message: 'admin_users:create_failed', + level: 'error', + userId: guard.profile.userId, + metadata: { + actor_profile_id: guard.profile.id, + reason: message, + }, + }) + + return NextResponse.json({ error: message }, { status }) + } + }, + ) } diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx index 7d51cc5..9eaf64d 100644 --- a/src/components/admin/AdminDashboard.tsx +++ b/src/components/admin/AdminDashboard.tsx @@ -11,7 +11,7 @@ import { Sidebar } from './Sidebar' import { PostsTable } from './PostsTable' import { useFeatureFlag } from '@/lib/feature-flags/client' import { PostForm } from './PostForm' -import { UserManagement } from './UserManagement' +import { RoleManager } from './RoleManager' import { CommentsModeration } from './CommentsModeration' import { TaxonomyManager } from './TaxonomyManager' import { DashboardOverview } from './DashboardOverview' @@ -1569,7 +1569,7 @@ const DashboardContent = ({ } return ( - Promise | void + onCreateUser: (payload: CreateAdminUserPayload) => Promise + onUpdateUser: ( + profileId: string, + payload: UpdateAdminUserPayload, + ) => Promise + onDeleteUser: (profileId: string) => Promise +} + +const badgeToneClass: Record = { + neutral: + 'border-brand-border-muted bg-brand-surface text-brand-ink/80 shadow-brand-sm', + info: 'border-brand-border-info bg-brand-surface-info text-brand-ink shadow-brand-md', + success: + 'border-brand-border-success bg-brand-surface-success text-brand-ink shadow-brand-md', + warning: + 'border-brand-border-warning bg-brand-surface-warning text-brand-ink shadow-brand-lg', + critical: + 'border-brand-border-critical bg-brand-surface-critical text-brand-ink shadow-brand-lg', +} + +const buildRoleCounts = ( + users: AdminUserSummary[], +): Record => { + return users.reduce>((accumulator, user) => { + const slug = getHighestRoleSlug(user.roles) + accumulator[slug] = (accumulator[slug] ?? 0) + 1 + return accumulator + }, { + member: 0, + contributor: 0, + organizer: 0, + moderator: 0, + admin: 0, + }) +} + +export const RoleManager = ({ + users, + roles, + isLoading, + isSaving, + currentProfileId, + onRefresh, + onCreateUser, + onUpdateUser, + onDeleteUser, +}: RoleManagerProps) => { + const [searchTerm, setSearchTerm] = useState('') + + const normalizedSearch = searchTerm.trim().toLowerCase() + + const filteredUsers = useMemo(() => { + const matches = users.filter((user) => { + if (!normalizedSearch) { + return true + } + + return ( + user.displayName.toLowerCase().includes(normalizedSearch) || + user.email.toLowerCase().includes(normalizedSearch) + ) + }) + + return matches.sort((left, right) => { + const leftRole = getHighestRoleSlug(left.roles) + const rightRole = getHighestRoleSlug(right.roles) + + if (leftRole !== rightRole) { + return compareRolePriority(rightRole, leftRole) + } + + return left.displayName.localeCompare(right.displayName) + }) + }, [users, normalizedSearch]) + + const roleCounts = useMemo(() => buildRoleCounts(users), [users]) + + const summaryLabel = useMemo(() => { + if (isLoading) { + return 'Loading role assignments…' + } + + return `${users.length} team member${users.length === 1 ? '' : 's'} with managed roles` + }, [isLoading, users.length]) + + const sortedRoles = useMemo(() => sortRolesAscending(roles), [roles]) + + return ( +
+
+
+
+
+

+ Platform role manager +

+

+ Assign canonical privileges, audit highest role badges, and maintain a + clean roster for community moderation. Every change is audited and + protected by the RBAC hardening feature flag. +

+

+ {summaryLabel} +

+
+
+
+
+ +
+
+
+
+ {(Object.entries(roleCounts) as Array<[CanonicalRoleSlug, number]>).map( + ([slug, total]) => { + const badge = getRoleBadge(slug) + return ( +
+ + {badge.label} + + {total} + highest badge +
+ ) + }, + )} +
+
+ +
+
+

Current roster

+
    + {filteredUsers.map((user) => { + const badge = getRoleBadge(getHighestRoleSlug(user.roles)) + const badgeClass = badgeToneClass[badge.tone] + const displayRoles = user.roles.length + ? sortRolesAscending(user.roles).map((role) => role.name ?? role.slug) + : ['Member'] + + return ( +
  • +
    +
    +

    {user.displayName}

    +

    {user.email}

    +
    + + {badge.label} + +
    +
    + {displayRoles.map((roleLabel) => ( + + {roleLabel} + + ))} +
    +
  • + ) + })} + {filteredUsers.length === 0 && ( +
  • + No users match that filter. Try a different name or email address. +
  • + )} +
+
+
+ +
+
+
+ ) +} diff --git a/src/components/ui/NewNavbar.tsx b/src/components/ui/NewNavbar.tsx index 66a5fe1..9a7e2e4 100644 --- a/src/components/ui/NewNavbar.tsx +++ b/src/components/ui/NewNavbar.tsx @@ -11,12 +11,18 @@ import { useAuthenticatedProfile } from '@/hooks/useAuthenticatedProfile'; import { useRouter } from 'next/navigation'; import { createBrowserClient } from '@/lib/supabase/client'; import type { AuthenticatedProfileSummary } from '@/utils/types'; +import { useFeatureFlags } from '@/lib/feature-flags/client'; import { navigationCategories, + navIaAdminHub, + navIaTopLevel, topLevelNavigation, type NavigationCategory, + type NavigationHub, type NavigationItem, } from '@/lib/navigation'; +import { getHighestRoleSlug, hasRoleAtLeast } from '@/lib/rbac/permissions'; +import { recordNavInteraction } from '@/lib/observability/metrics'; import { NavigationMenu, NavigationMenuContent, @@ -41,6 +47,58 @@ export const NewNavbar = () => { const pathname = useClientPathname(); const { profile, refresh: refreshAuthenticatedProfile } = useAuthenticatedProfile(); const needsOnboarding = Boolean(profile && profile.onboarding?.status !== 'completed'); + const featureFlags = useFeatureFlags(); + const navIaEnabled = Boolean(featureFlags.nav_ia_v1); + const highestRoleSlug = useMemo( + () => (profile ? getHighestRoleSlug(profile.roles) : 'guest'), + [profile], + ); + + const isFlagGroupEnabled = useCallback( + (requiredFlags: NavigationHub['requiredFlags']) => + !requiredFlags || requiredFlags.every((flagKey) => Boolean(featureFlags?.[flagKey])), + [featureFlags], + ); + + const resolvedTopNavigation = useMemo(() => { + if (!navIaEnabled) { + return topLevelNavigation; + } + + const baseHubs = navIaTopLevel.filter((hub) => isFlagGroupEnabled(hub.requiredFlags)); + const items: NavigationItem[] = baseHubs.map(({ label, href, description }) => ({ + label, + href, + description, + })); + + const allowAdmin = + profile !== null && + isFlagGroupEnabled(navIaAdminHub.requiredFlags) && + hasRoleAtLeast(profile.roles, navIaAdminHub.minimumRole ?? 'admin'); + + if (allowAdmin) { + items.push({ + label: navIaAdminHub.label, + href: navIaAdminHub.href, + description: navIaAdminHub.description, + }); + } + + return items; + }, [isFlagGroupEnabled, navIaEnabled, profile]); + + const trackNavigation = useCallback( + (label: string, href: string) => { + recordNavInteraction(label.toLowerCase(), { + href, + from: pathname, + variant: navIaEnabled ? 'nav_ia_v1' : 'legacy', + role: highestRoleSlug, + }); + }, + [highestRoleSlug, navIaEnabled, pathname], + ); const isPathActive = (path: string) => { if (path === '/') { @@ -79,8 +137,13 @@ export const NewNavbar = () => { {/* Desktop Navigation */}