Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/04-backlog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down
9 changes: 9 additions & 0 deletions docs/06-data-model-delta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions docs/07-security-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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('<profile-id>'::uuid)`). Rollback: toggle `rbac_hardening_v1` off, apply migration `0020_sec_001_rls_policies.down.sql`, restore from PITR if required. Document incident in `/docs/operations/runbooks/rls-denial-spike.md` (to be created).

## 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.
Expand Down
8 changes: 6 additions & 2 deletions docs/08-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 |
| --- | --- | --- |
Expand Down
3 changes: 3 additions & 0 deletions docs/10-release-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions docs/assumptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
14 changes: 14 additions & 0 deletions docs/progress/weekly-2025-10-17.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
90 changes: 18 additions & 72 deletions src/app/api/admin/feature-flags/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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<ProfileRecord>()

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<AdminFeatureFlagRecord[]> => {
Expand Down Expand Up @@ -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<Database>()
Expand Down Expand Up @@ -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(() => ({}))
Expand All @@ -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]

Expand Down Expand Up @@ -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',
Expand All @@ -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(() => ({}))
Expand All @@ -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<Database>()
Expand Down Expand Up @@ -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',
Expand All @@ -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()
Expand Down
Loading
Loading