diff --git a/server/internal/tenant/repository/generated/kol_marketplace.sql.go b/server/internal/tenant/repository/generated/kol_marketplace.sql.go index b9ef46b..b5affe2 100644 --- a/server/internal/tenant/repository/generated/kol_marketplace.sql.go +++ b/server/internal/tenant/repository/generated/kol_marketplace.sql.go @@ -122,7 +122,8 @@ SELECT kp.id, kp.tenant_id, kp.kol_profile_id, kp.name, kp.description, (SELECT COUNT(*) FROM kol_prompts p WHERE p.package_id = kp.id AND p.status = 'active' AND p.deleted_at IS NULL) AS prompt_count, (SELECT COUNT(*) FROM kol_subscriptions s - WHERE s.package_id = kp.id AND s.status = 'active') AS subscriber_count + WHERE s.package_id = kp.id AND s.status = 'active' + AND (s.end_at IS NULL OR s.end_at > NOW())) AS subscriber_count FROM kol_packages kp JOIN kol_profiles pf ON pf.id = kp.kol_profile_id AND pf.status = 'active' diff --git a/server/internal/tenant/repository/generated/kol_subscription.sql.go b/server/internal/tenant/repository/generated/kol_subscription.sql.go index fc951aa..5bec6d2 100644 --- a/server/internal/tenant/repository/generated/kol_subscription.sql.go +++ b/server/internal/tenant/repository/generated/kol_subscription.sql.go @@ -20,6 +20,7 @@ SET status = 'active', updated_at = NOW() WHERE id = $3 AND tenant_id = $4::bigint + AND status = 'pending' ` type ApproveKolSubscriptionParams struct { @@ -217,10 +218,12 @@ const listActiveSubscribersForPackage = `-- name: ListActiveSubscribersForPackag SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at FROM kol_subscriptions WHERE package_id = $1 - AND tenant_id IS NOT NULL AND status = 'active' + AND (end_at IS NULL OR end_at > NOW()) ` +// Cross-tenant read: used by platform admin for subscription fan-out. +// Excludes expired subscriptions so new prompts are not granted to them. func (q *Queries) ListActiveSubscribersForPackage(ctx context.Context, packageID int64) ([]KolSubscription, error) { rows, err := q.db.Query(ctx, listActiveSubscribersForPackage, packageID) if err != nil { @@ -292,10 +295,25 @@ const listSubscriptionPromptsByTenant = `-- name: ListSubscriptionPromptsByTenan SELECT sp.id, sp.tenant_id, sp.subscription_id, sp.package_id, sp.prompt_id, sp.status, sp.granted_at, sp.revoked_at, p.name AS prompt_name, p.platform_hint, p.published_revision_no, - k.name AS package_name + k.name AS package_name, + pf.display_name AS kol_display_name FROM kol_subscription_prompts sp -JOIN kol_prompts p ON p.id = sp.prompt_id AND p.deleted_at IS NULL -JOIN kol_packages k ON k.id = sp.package_id AND k.deleted_at IS NULL +JOIN kol_subscriptions s ON s.id = sp.subscription_id + AND s.tenant_id = sp.tenant_id + AND s.package_id = sp.package_id + AND s.status = 'active' + AND (s.end_at IS NULL OR s.end_at > NOW()) +JOIN kol_prompts p ON p.id = sp.prompt_id + AND p.package_id = sp.package_id + AND p.status = 'active' + AND p.published_revision_no IS NOT NULL + AND p.deleted_at IS NULL +JOIN kol_packages k ON k.id = sp.package_id + AND k.status = 'published' + AND k.deleted_at IS NULL +JOIN kol_profiles pf ON pf.id = k.kol_profile_id + AND pf.status = 'active' + AND pf.deleted_at IS NULL WHERE sp.tenant_id = $1::bigint AND sp.status = 'active' ORDER BY sp.granted_at DESC @@ -314,6 +332,7 @@ type ListSubscriptionPromptsByTenantRow struct { PlatformHint pgtype.Text `json:"platform_hint"` PublishedRevisionNo pgtype.Int4 `json:"published_revision_no"` PackageName string `json:"package_name"` + KolDisplayName string `json:"kol_display_name"` } func (q *Queries) ListSubscriptionPromptsByTenant(ctx context.Context, tenantID int64) ([]ListSubscriptionPromptsByTenantRow, error) { @@ -338,6 +357,7 @@ func (q *Queries) ListSubscriptionPromptsByTenant(ctx context.Context, tenantID &i.PlatformHint, &i.PublishedRevisionNo, &i.PackageName, + &i.KolDisplayName, ); err != nil { return nil, err } diff --git a/server/internal/tenant/repository/generated/kol_usage.sql.go b/server/internal/tenant/repository/generated/kol_usage.sql.go index 850eb4d..e287ce1 100644 --- a/server/internal/tenant/repository/generated/kol_usage.sql.go +++ b/server/internal/tenant/repository/generated/kol_usage.sql.go @@ -115,15 +115,12 @@ SELECT AND s.status='active' AND s.created_at >= date_trunc('month', NOW())) AS month_subs, (SELECT COUNT(*) FROM kol_usage_logs u - WHERE u.package_id = ANY($1::bigint[]) - AND u.tenant_id IS NOT NULL) AS total_usage, + WHERE u.package_id = ANY($1::bigint[])) AS total_usage, (SELECT COUNT(*) FROM kol_usage_logs u WHERE u.package_id = ANY($1::bigint[]) - AND u.tenant_id IS NOT NULL AND u.created_at >= date_trunc('month', NOW())) AS month_usage, (SELECT COUNT(*) FROM kol_usage_logs u WHERE u.package_id = ANY($1::bigint[]) - AND u.tenant_id IS NOT NULL AND u.status='failed') AS failure_count ` @@ -135,6 +132,7 @@ type KolDashboardOverviewRow struct { FailureCount int64 `json:"failure_count"` } +// Cross-tenant aggregation: KOL owns packages across subscriber tenants. func (q *Queries) KolDashboardOverview(ctx context.Context, packageIds []int64) (KolDashboardOverviewRow, error) { row := q.db.QueryRow(ctx, kolDashboardOverview, packageIds) var i KolDashboardOverviewRow @@ -149,28 +147,53 @@ func (q *Queries) KolDashboardOverview(ctx context.Context, packageIds []int64) } const kolDashboardTrend = `-- name: KolDashboardTrend :many -SELECT date_trunc('day', u.created_at)::date AS d, - COUNT(*) FILTER (WHERE u.status='completed') AS usages -FROM kol_usage_logs u -WHERE u.package_id = ANY($1::bigint[]) - AND u.tenant_id IS NOT NULL - AND u.created_at >= NOW() - $2::int * INTERVAL '1 day' -GROUP BY d -ORDER BY d ASC +WITH days AS ( + SELECT generate_series( + date_trunc('day', NOW()) - ($1::int - 1) * INTERVAL '1 day', + date_trunc('day', NOW()), + INTERVAL '1 day' + )::date AS d +), +u AS ( + SELECT date_trunc('day', created_at)::date AS d, + COUNT(*) FILTER (WHERE status = 'completed') AS usages + FROM kol_usage_logs + WHERE package_id = ANY($2::bigint[]) + AND created_at >= NOW() - $1::int * INTERVAL '1 day' + GROUP BY 1 +), +s AS ( + SELECT date_trunc('day', created_at)::date AS d, + COUNT(*) AS subscriptions + FROM kol_subscriptions + WHERE package_id = ANY($2::bigint[]) + AND status IN ('active', 'expired', 'revoked') + AND created_at >= NOW() - $1::int * INTERVAL '1 day' + GROUP BY 1 +) +SELECT days.d, + COALESCE(u.usages, 0)::bigint AS usages, + COALESCE(s.subscriptions, 0)::bigint AS subscriptions +FROM days +LEFT JOIN u ON u.d = days.d +LEFT JOIN s ON s.d = days.d +ORDER BY days.d ASC ` type KolDashboardTrendParams struct { - PackageIds []int64 `json:"package_ids"` PeriodDays int32 `json:"period_days"` + PackageIds []int64 `json:"package_ids"` } type KolDashboardTrendRow struct { - D pgtype.Date `json:"d"` - Usages int64 `json:"usages"` + D pgtype.Date `json:"d"` + Usages int64 `json:"usages"` + Subscriptions int64 `json:"subscriptions"` } +// Returns a per-day series combining usage count and new subscription count for KOL dashboard. func (q *Queries) KolDashboardTrend(ctx context.Context, arg KolDashboardTrendParams) ([]KolDashboardTrendRow, error) { - rows, err := q.db.Query(ctx, kolDashboardTrend, arg.PackageIds, arg.PeriodDays) + rows, err := q.db.Query(ctx, kolDashboardTrend, arg.PeriodDays, arg.PackageIds) if err != nil { return nil, err } @@ -178,7 +201,7 @@ func (q *Queries) KolDashboardTrend(ctx context.Context, arg KolDashboardTrendPa var items []KolDashboardTrendRow for rows.Next() { var i KolDashboardTrendRow - if err := rows.Scan(&i.D, &i.Usages); err != nil { + if err := rows.Scan(&i.D, &i.Usages, &i.Subscriptions); err != nil { return nil, err } items = append(items, i) @@ -195,7 +218,6 @@ SELECT package_id, COUNT(*) AS total, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed FROM kol_usage_logs WHERE package_id = ANY($1::bigint[]) - AND tenant_id IS NOT NULL GROUP BY package_id ` @@ -206,6 +228,7 @@ type KolUsageCountByPackageRow struct { Failed int64 `json:"failed"` } +// Cross-tenant aggregation for KOL dashboard. func (q *Queries) KolUsageCountByPackage(ctx context.Context, packageIds []int64) ([]KolUsageCountByPackageRow, error) { rows, err := q.db.Query(ctx, kolUsageCountByPackage, packageIds) if err != nil { diff --git a/server/internal/tenant/repository/generated/querier.go b/server/internal/tenant/repository/generated/querier.go index b8f9adc..ed1d43d 100644 --- a/server/internal/tenant/repository/generated/querier.go +++ b/server/internal/tenant/repository/generated/querier.go @@ -76,11 +76,16 @@ type Querier interface { GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error) InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerParams) (int64, error) + // Cross-tenant aggregation: KOL owns packages across subscriber tenants. KolDashboardOverview(ctx context.Context, packageIds []int64) (KolDashboardOverviewRow, error) + // Returns a per-day series combining usage count and new subscription count for KOL dashboard. KolDashboardTrend(ctx context.Context, arg KolDashboardTrendParams) ([]KolDashboardTrendRow, error) + // Cross-tenant aggregation for KOL dashboard. KolUsageCountByPackage(ctx context.Context, packageIds []int64) ([]KolUsageCountByPackageRow, error) ListActiveImagesByFolder(ctx context.Context, arg ListActiveImagesByFolderParams) ([]ListActiveImagesByFolderRow, error) ListActiveKolPromptsByPackage(ctx context.Context, arg ListActiveKolPromptsByPackageParams) ([]ListActiveKolPromptsByPackageRow, error) + // Cross-tenant read: used by platform admin for subscription fan-out. + // Excludes expired subscriptions so new prompts are not granted to them. ListActiveSubscribersForPackage(ctx context.Context, packageID int64) ([]KolSubscription, error) ListAllPromptRulesSimple(ctx context.Context, tenantID int64) ([]ListAllPromptRulesSimpleRow, error) ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error) diff --git a/server/internal/tenant/repository/kol_subscription_repo.go b/server/internal/tenant/repository/kol_subscription_repo.go index 764bef9..7edf37e 100644 --- a/server/internal/tenant/repository/kol_subscription_repo.go +++ b/server/internal/tenant/repository/kol_subscription_repo.go @@ -35,6 +35,8 @@ type KolSubscriptionPrompt struct { PlatformHint *string PackageName string PublishedRevisionNo *int + // KolDisplayName is populated by ListSubscriptionPromptsByTenant (joins kol_profiles). + KolDisplayName string // Fields below only populated by GetSubscriptionPromptByID. SubscriptionStatus string SubscriptionEndAt *time.Time @@ -201,6 +203,7 @@ func (r *kolSubscriptionRepository) ListSubscriptionPromptsByTenant(ctx context. PlatformHint: nullableText(row.PlatformHint), PackageName: row.PackageName, PublishedRevisionNo: nullableIntFromInt4(row.PublishedRevisionNo), + KolDisplayName: row.KolDisplayName, } } return result, nil diff --git a/server/internal/tenant/repository/kol_usage_repo.go b/server/internal/tenant/repository/kol_usage_repo.go index 125176b..ae6c4a5 100644 --- a/server/internal/tenant/repository/kol_usage_repo.go +++ b/server/internal/tenant/repository/kol_usage_repo.go @@ -46,8 +46,9 @@ type KolDashboardOverview struct { } type KolDashboardTrendPoint struct { - Day time.Time - Usages int64 + Day time.Time + Usages int64 + Subscriptions int64 } type KolUsagePackageStats struct { @@ -170,8 +171,9 @@ func (r *kolUsageRepository) DashboardTrend(ctx context.Context, packageIDs []in day = row.D.Time } result[i] = KolDashboardTrendPoint{ - Day: day, - Usages: row.Usages, + Day: day, + Usages: row.Usages, + Subscriptions: row.Subscriptions, } } return result, nil diff --git a/server/internal/tenant/repository/queries/kol_marketplace.sql b/server/internal/tenant/repository/queries/kol_marketplace.sql index 7799c2d..bce3b9a 100644 --- a/server/internal/tenant/repository/queries/kol_marketplace.sql +++ b/server/internal/tenant/repository/queries/kol_marketplace.sql @@ -8,7 +8,8 @@ SELECT kp.id, kp.tenant_id, kp.kol_profile_id, kp.name, kp.description, (SELECT COUNT(*) FROM kol_prompts p WHERE p.package_id = kp.id AND p.status = 'active' AND p.deleted_at IS NULL) AS prompt_count, (SELECT COUNT(*) FROM kol_subscriptions s - WHERE s.package_id = kp.id AND s.status = 'active') AS subscriber_count + WHERE s.package_id = kp.id AND s.status = 'active' + AND (s.end_at IS NULL OR s.end_at > NOW())) AS subscriber_count FROM kol_packages kp JOIN kol_profiles pf ON pf.id = kp.kol_profile_id AND pf.status = 'active' diff --git a/server/internal/tenant/repository/queries/kol_subscription.sql b/server/internal/tenant/repository/queries/kol_subscription.sql index a21c0fe..c5eab2e 100644 --- a/server/internal/tenant/repository/queries/kol_subscription.sql +++ b/server/internal/tenant/repository/queries/kol_subscription.sql @@ -23,11 +23,13 @@ WHERE tenant_id = sqlc.arg(tenant_id)::bigint ORDER BY created_at DESC; -- name: ListActiveSubscribersForPackage :many +-- Cross-tenant read: used by platform admin for subscription fan-out. +-- Excludes expired subscriptions so new prompts are not granted to them. SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at FROM kol_subscriptions WHERE package_id = sqlc.arg(package_id) - AND tenant_id IS NOT NULL - AND status = 'active'; + AND status = 'active' + AND (end_at IS NULL OR end_at > NOW()); -- name: ApproveKolSubscription :exec UPDATE kol_subscriptions @@ -37,7 +39,8 @@ SET status = 'active', end_at = sqlc.arg(end_at), updated_at = NOW() WHERE id = sqlc.arg(id) - AND tenant_id = sqlc.arg(tenant_id)::bigint; + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND status = 'pending'; -- name: RevokeKolSubscription :exec UPDATE kol_subscriptions @@ -55,10 +58,25 @@ ON CONFLICT (subscription_id, prompt_id) DO NOTHING; SELECT sp.id, sp.tenant_id, sp.subscription_id, sp.package_id, sp.prompt_id, sp.status, sp.granted_at, sp.revoked_at, p.name AS prompt_name, p.platform_hint, p.published_revision_no, - k.name AS package_name + k.name AS package_name, + pf.display_name AS kol_display_name FROM kol_subscription_prompts sp -JOIN kol_prompts p ON p.id = sp.prompt_id AND p.deleted_at IS NULL -JOIN kol_packages k ON k.id = sp.package_id AND k.deleted_at IS NULL +JOIN kol_subscriptions s ON s.id = sp.subscription_id + AND s.tenant_id = sp.tenant_id + AND s.package_id = sp.package_id + AND s.status = 'active' + AND (s.end_at IS NULL OR s.end_at > NOW()) +JOIN kol_prompts p ON p.id = sp.prompt_id + AND p.package_id = sp.package_id + AND p.status = 'active' + AND p.published_revision_no IS NOT NULL + AND p.deleted_at IS NULL +JOIN kol_packages k ON k.id = sp.package_id + AND k.status = 'published' + AND k.deleted_at IS NULL +JOIN kol_profiles pf ON pf.id = k.kol_profile_id + AND pf.status = 'active' + AND pf.deleted_at IS NULL WHERE sp.tenant_id = sqlc.arg(tenant_id)::bigint AND sp.status = 'active' ORDER BY sp.granted_at DESC; diff --git a/server/internal/tenant/repository/queries/kol_usage.sql b/server/internal/tenant/repository/queries/kol_usage.sql index 17d2d4a..1448756 100644 --- a/server/internal/tenant/repository/queries/kol_usage.sql +++ b/server/internal/tenant/repository/queries/kol_usage.sql @@ -44,6 +44,7 @@ WHERE generation_task_id = sqlc.arg(generation_task_id) AND tenant_id = sqlc.arg(tenant_id)::bigint; -- name: KolDashboardOverview :one +-- Cross-tenant aggregation: KOL owns packages across subscriber tenants. SELECT (SELECT COUNT(DISTINCT s.tenant_id) FROM kol_subscriptions s WHERE s.package_id = ANY(sqlc.arg(package_ids)::bigint[]) AND s.status='active' @@ -53,32 +54,53 @@ SELECT AND s.status='active' AND s.created_at >= date_trunc('month', NOW())) AS month_subs, (SELECT COUNT(*) FROM kol_usage_logs u - WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[]) - AND u.tenant_id IS NOT NULL) AS total_usage, + WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[])) AS total_usage, (SELECT COUNT(*) FROM kol_usage_logs u WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[]) - AND u.tenant_id IS NOT NULL AND u.created_at >= date_trunc('month', NOW())) AS month_usage, (SELECT COUNT(*) FROM kol_usage_logs u WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[]) - AND u.tenant_id IS NOT NULL AND u.status='failed') AS failure_count; -- name: KolDashboardTrend :many -SELECT date_trunc('day', u.created_at)::date AS d, - COUNT(*) FILTER (WHERE u.status='completed') AS usages -FROM kol_usage_logs u -WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[]) - AND u.tenant_id IS NOT NULL - AND u.created_at >= NOW() - sqlc.arg(period_days)::int * INTERVAL '1 day' -GROUP BY d -ORDER BY d ASC; +-- Returns a per-day series combining usage count and new subscription count for KOL dashboard. +WITH days AS ( + SELECT generate_series( + date_trunc('day', NOW()) - (sqlc.arg(period_days)::int - 1) * INTERVAL '1 day', + date_trunc('day', NOW()), + INTERVAL '1 day' + )::date AS d +), +u AS ( + SELECT date_trunc('day', created_at)::date AS d, + COUNT(*) FILTER (WHERE status = 'completed') AS usages + FROM kol_usage_logs + WHERE package_id = ANY(sqlc.arg(package_ids)::bigint[]) + AND created_at >= NOW() - sqlc.arg(period_days)::int * INTERVAL '1 day' + GROUP BY 1 +), +s AS ( + SELECT date_trunc('day', created_at)::date AS d, + COUNT(*) AS subscriptions + FROM kol_subscriptions + WHERE package_id = ANY(sqlc.arg(package_ids)::bigint[]) + AND status IN ('active', 'expired', 'revoked') + AND created_at >= NOW() - sqlc.arg(period_days)::int * INTERVAL '1 day' + GROUP BY 1 +) +SELECT days.d, + COALESCE(u.usages, 0)::bigint AS usages, + COALESCE(s.subscriptions, 0)::bigint AS subscriptions +FROM days +LEFT JOIN u ON u.d = days.d +LEFT JOIN s ON s.d = days.d +ORDER BY days.d ASC; -- name: KolUsageCountByPackage :many +-- Cross-tenant aggregation for KOL dashboard. SELECT package_id, COUNT(*) AS total, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed FROM kol_usage_logs WHERE package_id = ANY(sqlc.arg(package_ids)::bigint[]) - AND tenant_id IS NOT NULL GROUP BY package_id; diff --git a/server/migrations/20260417100060_kol_integrity_hardening.down.sql b/server/migrations/20260417100060_kol_integrity_hardening.down.sql new file mode 100644 index 0000000..b7ce836 --- /dev/null +++ b/server/migrations/20260417100060_kol_integrity_hardening.down.sql @@ -0,0 +1,14 @@ +ALTER TABLE kol_usage_logs DROP CONSTRAINT IF EXISTS chk_kol_usage_logs_status; +ALTER TABLE kol_subscription_prompts DROP CONSTRAINT IF EXISTS chk_kol_subscription_prompts_status; +ALTER TABLE kol_subscriptions DROP CONSTRAINT IF EXISTS chk_kol_subscriptions_status; +ALTER TABLE kol_prompts DROP CONSTRAINT IF EXISTS chk_kol_prompts_status; +ALTER TABLE kol_packages DROP CONSTRAINT IF EXISTS chk_kol_packages_status; +ALTER TABLE kol_profiles DROP CONSTRAINT IF EXISTS chk_kol_profiles_status; + +ALTER TABLE kol_subscription_prompts DROP CONSTRAINT IF EXISTS fk_kol_subscription_prompts_subscription_scope; +ALTER TABLE kol_packages DROP CONSTRAINT IF EXISTS fk_kol_packages_profile_tenant; + +DROP INDEX IF EXISTS uk_kol_prompts_id_package_tenant; +DROP INDEX IF EXISTS uk_kol_subscriptions_id_tenant_package; +DROP INDEX IF EXISTS uk_kol_packages_id_tenant; +DROP INDEX IF EXISTS uk_kol_profiles_id_tenant; diff --git a/server/migrations/20260417100060_kol_integrity_hardening.up.sql b/server/migrations/20260417100060_kol_integrity_hardening.up.sql new file mode 100644 index 0000000..72faa74 --- /dev/null +++ b/server/migrations/20260417100060_kol_integrity_hardening.up.sql @@ -0,0 +1,49 @@ +-- KOL integrity hardening (addresses Phase 1 review findings): +-- 1. Composite unique indexes that enable composite FKs for ACL lineage +-- 2. Composite FKs so subscription_prompts / packages can't reference +-- mismatched (tenant_id, package_id, prompt_id) tuples +-- 3. Status CHECK constraints for defense-in-depth + +-- Composite unique indexes (prerequisites for composite FKs) +CREATE UNIQUE INDEX uk_kol_profiles_id_tenant + ON kol_profiles(id, tenant_id); +CREATE UNIQUE INDEX uk_kol_packages_id_tenant + ON kol_packages(id, tenant_id); +CREATE UNIQUE INDEX uk_kol_subscriptions_id_tenant_package + ON kol_subscriptions(id, tenant_id, package_id); +CREATE UNIQUE INDEX uk_kol_prompts_id_package_tenant + ON kol_prompts(id, package_id, tenant_id); + +-- Composite FK: kol_packages.(kol_profile_id, tenant_id) must match a profile +-- (prevents a package from claiming a profile in another tenant) +ALTER TABLE kol_packages + ADD CONSTRAINT fk_kol_packages_profile_tenant + FOREIGN KEY (kol_profile_id, tenant_id) + REFERENCES kol_profiles(id, tenant_id); + +-- Composite FK: subscription_prompts.(subscription_id, tenant_id, package_id) +-- must match a subscription (prevents tenant/package mismatch on access rows) +ALTER TABLE kol_subscription_prompts + ADD CONSTRAINT fk_kol_subscription_prompts_subscription_scope + FOREIGN KEY (subscription_id, tenant_id, package_id) + REFERENCES kol_subscriptions(id, tenant_id, package_id); + +-- Status enum CHECK constraints (defense-in-depth against bad writes) +ALTER TABLE kol_profiles + ADD CONSTRAINT chk_kol_profiles_status + CHECK (status IN ('active', 'suspended', 'closed')); +ALTER TABLE kol_packages + ADD CONSTRAINT chk_kol_packages_status + CHECK (status IN ('draft', 'published', 'archived')); +ALTER TABLE kol_prompts + ADD CONSTRAINT chk_kol_prompts_status + CHECK (status IN ('draft', 'active', 'archived')); +ALTER TABLE kol_subscriptions + ADD CONSTRAINT chk_kol_subscriptions_status + CHECK (status IN ('pending', 'active', 'expired', 'revoked')); +ALTER TABLE kol_subscription_prompts + ADD CONSTRAINT chk_kol_subscription_prompts_status + CHECK (status IN ('active', 'revoked')); +ALTER TABLE kol_usage_logs + ADD CONSTRAINT chk_kol_usage_logs_status + CHECK (status IN ('pending', 'completed', 'failed')); diff --git a/server/scripts/check_tenant_scope.sh b/server/scripts/check_tenant_scope.sh index 13d0062..a9fef3a 100755 --- a/server/scripts/check_tenant_scope.sh +++ b/server/scripts/check_tenant_scope.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# CI guard: reject tenant-scoped SQL query files missing tenant_id filters. +# CI guard: reject tenant-scoped SQL query files missing tenant_id = sqlc.(n?)arg filters. +# Files that are legitimately cross-tenant must be explicitly exempted. # Usage: scripts/check_tenant_scope.sh set -euo pipefail @@ -7,30 +8,44 @@ set -euo pipefail QUERY_DIR="internal/tenant/repository/queries" EXIT_CODE=0 +# Files that are cross-tenant by design. Each must justify itself in a header comment. +EXEMPT=( + "audit.sql" + "kol_marketplace.sql" + "kol_usage.sql" +) + +is_exempt() { + local name="$1" + for e in "${EXEMPT[@]}"; do + if [[ "$name" == "$e" ]]; then + return 0 + fi + done + return 1 +} + for f in "$QUERY_DIR"/*.sql; do - # Skip files that don't need tenant_id (audit.sql has operator_id + tenant_id) basename=$(basename "$f") - if [[ "$basename" == "audit.sql" ]] || [[ "$basename" == "kol_marketplace.sql" ]]; then + if is_exempt "$basename"; then continue fi - # Check each named query block for tenant_id - while IFS= read -r line; do - if [[ "$line" =~ ^--\ name: ]]; then - query_name="${line#-- name: }" - query_name="${query_name%% *}" - fi - done < "$f" + # Must contain at least one tenant_id = sqlc.arg(...) or sqlc.narg(...) filter. + if ! grep -Eq 'tenant_id[[:space:]]*=[[:space:]]*sqlc\.(n?arg)\(' "$f"; then + echo "ERROR: $f must filter on tenant_id = sqlc.(n?arg)(...)" + EXIT_CODE=1 + fi - # Simple check: file should contain tenant_id somewhere - if ! grep -q 'tenant_id' "$f"; then - echo "ERROR: $f is missing tenant_id filter" + # Reject cosmetic "tenant_id IS NOT NULL" that bypasses actor scoping. + if grep -Eq 'tenant_id[[:space:]]+IS[[:space:]]+NOT[[:space:]]+NULL' "$f"; then + echo "ERROR: $f uses 'tenant_id IS NOT NULL' — this does not scope the actor" EXIT_CODE=1 fi done if [ "$EXIT_CODE" -eq 0 ]; then - echo "All query files contain tenant_id filters." + echo "All non-exempt query files contain tenant_id = sqlc.arg filters." fi exit $EXIT_CODE