fix(kol): Phase 1 review — ACL lineage, expiry filters, tighter tenant scope

Addresses findings from consolidated Phase 1 code review:

- Add composite unique indexes + composite FKs so subscription_prompts and
  packages cannot reference mismatched (tenant_id, package_id, prompt_id)
  tuples, enforcing ACL lineage at the schema level.
- Add status CHECK constraints on all KOL tables as defense-in-depth.
- ListActiveSubscribersForPackage: drop meaningless tenant_id IS NOT NULL,
  add end_at expiry filter so fan-out skips expired subscribers.
- ApproveKolSubscription: add status='pending' guard to prevent double-approval.
- ListSubscriptionPromptsByTenant: join subscription/prompt/package/profile
  lifecycle so hidden/archived content no longer leaks; surface
  kol_display_name for UI.
- ListPublishedKolPackages: exclude expired subscribers from marketplace count.
- KolDashboardTrend: rewrite as CTE with day-series, returning both daily
  usage and new subscription counts.
- Remove cosmetic tenant_id IS NOT NULL from dashboard overview queries
  (the column is NOT NULL by schema).
- check_tenant_scope.sh: require tenant_id = sqlc.(n?)arg filter, reject
  tenant_id IS NOT NULL as a fake scope, maintain explicit exemption list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 08:34:12 +08:00
parent 3d4842e983
commit 70725193eb
12 changed files with 234 additions and 61 deletions
@@ -122,7 +122,8 @@ SELECT kp.id, kp.tenant_id, kp.kol_profile_id, kp.name, kp.description,
(SELECT COUNT(*) FROM kol_prompts p (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, 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 (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 FROM kol_packages kp
JOIN kol_profiles pf ON pf.id = kp.kol_profile_id JOIN kol_profiles pf ON pf.id = kp.kol_profile_id
AND pf.status = 'active' AND pf.status = 'active'
@@ -20,6 +20,7 @@ SET status = 'active',
updated_at = NOW() updated_at = NOW()
WHERE id = $3 WHERE id = $3
AND tenant_id = $4::bigint AND tenant_id = $4::bigint
AND status = 'pending'
` `
type ApproveKolSubscriptionParams struct { 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 SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at
FROM kol_subscriptions FROM kol_subscriptions
WHERE package_id = $1 WHERE package_id = $1
AND tenant_id IS NOT NULL
AND status = 'active' 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) { func (q *Queries) ListActiveSubscribersForPackage(ctx context.Context, packageID int64) ([]KolSubscription, error) {
rows, err := q.db.Query(ctx, listActiveSubscribersForPackage, packageID) rows, err := q.db.Query(ctx, listActiveSubscribersForPackage, packageID)
if err != nil { 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, SELECT sp.id, sp.tenant_id, sp.subscription_id, sp.package_id, sp.prompt_id,
sp.status, sp.granted_at, sp.revoked_at, sp.status, sp.granted_at, sp.revoked_at,
p.name AS prompt_name, p.platform_hint, p.published_revision_no, 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 FROM kol_subscription_prompts sp
JOIN kol_prompts p ON p.id = sp.prompt_id AND p.deleted_at IS NULL JOIN kol_subscriptions s ON s.id = sp.subscription_id
JOIN kol_packages k ON k.id = sp.package_id AND k.deleted_at IS NULL 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 WHERE sp.tenant_id = $1::bigint
AND sp.status = 'active' AND sp.status = 'active'
ORDER BY sp.granted_at DESC ORDER BY sp.granted_at DESC
@@ -314,6 +332,7 @@ type ListSubscriptionPromptsByTenantRow struct {
PlatformHint pgtype.Text `json:"platform_hint"` PlatformHint pgtype.Text `json:"platform_hint"`
PublishedRevisionNo pgtype.Int4 `json:"published_revision_no"` PublishedRevisionNo pgtype.Int4 `json:"published_revision_no"`
PackageName string `json:"package_name"` PackageName string `json:"package_name"`
KolDisplayName string `json:"kol_display_name"`
} }
func (q *Queries) ListSubscriptionPromptsByTenant(ctx context.Context, tenantID int64) ([]ListSubscriptionPromptsByTenantRow, error) { 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.PlatformHint,
&i.PublishedRevisionNo, &i.PublishedRevisionNo,
&i.PackageName, &i.PackageName,
&i.KolDisplayName,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -115,15 +115,12 @@ SELECT
AND s.status='active' AND s.status='active'
AND s.created_at >= date_trunc('month', NOW())) AS month_subs, AND s.created_at >= date_trunc('month', NOW())) AS month_subs,
(SELECT COUNT(*) FROM kol_usage_logs u (SELECT COUNT(*) FROM kol_usage_logs u
WHERE u.package_id = ANY($1::bigint[]) WHERE u.package_id = ANY($1::bigint[])) AS total_usage,
AND u.tenant_id IS NOT NULL) AS total_usage,
(SELECT COUNT(*) FROM kol_usage_logs u (SELECT COUNT(*) FROM kol_usage_logs u
WHERE u.package_id = ANY($1::bigint[]) 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, AND u.created_at >= date_trunc('month', NOW())) AS month_usage,
(SELECT COUNT(*) FROM kol_usage_logs u (SELECT COUNT(*) FROM kol_usage_logs u
WHERE u.package_id = ANY($1::bigint[]) WHERE u.package_id = ANY($1::bigint[])
AND u.tenant_id IS NOT NULL
AND u.status='failed') AS failure_count AND u.status='failed') AS failure_count
` `
@@ -135,6 +132,7 @@ type KolDashboardOverviewRow struct {
FailureCount int64 `json:"failure_count"` 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) { func (q *Queries) KolDashboardOverview(ctx context.Context, packageIds []int64) (KolDashboardOverviewRow, error) {
row := q.db.QueryRow(ctx, kolDashboardOverview, packageIds) row := q.db.QueryRow(ctx, kolDashboardOverview, packageIds)
var i KolDashboardOverviewRow var i KolDashboardOverviewRow
@@ -149,28 +147,53 @@ func (q *Queries) KolDashboardOverview(ctx context.Context, packageIds []int64)
} }
const kolDashboardTrend = `-- name: KolDashboardTrend :many const kolDashboardTrend = `-- name: KolDashboardTrend :many
SELECT date_trunc('day', u.created_at)::date AS d, WITH days AS (
COUNT(*) FILTER (WHERE u.status='completed') AS usages SELECT generate_series(
FROM kol_usage_logs u date_trunc('day', NOW()) - ($1::int - 1) * INTERVAL '1 day',
WHERE u.package_id = ANY($1::bigint[]) date_trunc('day', NOW()),
AND u.tenant_id IS NOT NULL INTERVAL '1 day'
AND u.created_at >= NOW() - $2::int * INTERVAL '1 day' )::date AS d
GROUP BY d ),
ORDER BY d ASC 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 { type KolDashboardTrendParams struct {
PackageIds []int64 `json:"package_ids"`
PeriodDays int32 `json:"period_days"` PeriodDays int32 `json:"period_days"`
PackageIds []int64 `json:"package_ids"`
} }
type KolDashboardTrendRow struct { type KolDashboardTrendRow struct {
D pgtype.Date `json:"d"` D pgtype.Date `json:"d"`
Usages int64 `json:"usages"` 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -178,7 +201,7 @@ func (q *Queries) KolDashboardTrend(ctx context.Context, arg KolDashboardTrendPa
var items []KolDashboardTrendRow var items []KolDashboardTrendRow
for rows.Next() { for rows.Next() {
var i KolDashboardTrendRow 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 return nil, err
} }
items = append(items, i) 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 SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed
FROM kol_usage_logs FROM kol_usage_logs
WHERE package_id = ANY($1::bigint[]) WHERE package_id = ANY($1::bigint[])
AND tenant_id IS NOT NULL
GROUP BY package_id GROUP BY package_id
` `
@@ -206,6 +228,7 @@ type KolUsageCountByPackageRow struct {
Failed int64 `json:"failed"` Failed int64 `json:"failed"`
} }
// Cross-tenant aggregation for KOL dashboard.
func (q *Queries) KolUsageCountByPackage(ctx context.Context, packageIds []int64) ([]KolUsageCountByPackageRow, error) { func (q *Queries) KolUsageCountByPackage(ctx context.Context, packageIds []int64) ([]KolUsageCountByPackageRow, error) {
rows, err := q.db.Query(ctx, kolUsageCountByPackage, packageIds) rows, err := q.db.Query(ctx, kolUsageCountByPackage, packageIds)
if err != nil { if err != nil {
@@ -76,11 +76,16 @@ type Querier interface {
GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error)
InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error) InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error)
InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerParams) (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) 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) KolDashboardTrend(ctx context.Context, arg KolDashboardTrendParams) ([]KolDashboardTrendRow, error)
// Cross-tenant aggregation for KOL dashboard.
KolUsageCountByPackage(ctx context.Context, packageIds []int64) ([]KolUsageCountByPackageRow, error) KolUsageCountByPackage(ctx context.Context, packageIds []int64) ([]KolUsageCountByPackageRow, error)
ListActiveImagesByFolder(ctx context.Context, arg ListActiveImagesByFolderParams) ([]ListActiveImagesByFolderRow, error) ListActiveImagesByFolder(ctx context.Context, arg ListActiveImagesByFolderParams) ([]ListActiveImagesByFolderRow, error)
ListActiveKolPromptsByPackage(ctx context.Context, arg ListActiveKolPromptsByPackageParams) ([]ListActiveKolPromptsByPackageRow, 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) ListActiveSubscribersForPackage(ctx context.Context, packageID int64) ([]KolSubscription, error)
ListAllPromptRulesSimple(ctx context.Context, tenantID int64) ([]ListAllPromptRulesSimpleRow, error) ListAllPromptRulesSimple(ctx context.Context, tenantID int64) ([]ListAllPromptRulesSimpleRow, error)
ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error) ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error)
@@ -35,6 +35,8 @@ type KolSubscriptionPrompt struct {
PlatformHint *string PlatformHint *string
PackageName string PackageName string
PublishedRevisionNo *int PublishedRevisionNo *int
// KolDisplayName is populated by ListSubscriptionPromptsByTenant (joins kol_profiles).
KolDisplayName string
// Fields below only populated by GetSubscriptionPromptByID. // Fields below only populated by GetSubscriptionPromptByID.
SubscriptionStatus string SubscriptionStatus string
SubscriptionEndAt *time.Time SubscriptionEndAt *time.Time
@@ -201,6 +203,7 @@ func (r *kolSubscriptionRepository) ListSubscriptionPromptsByTenant(ctx context.
PlatformHint: nullableText(row.PlatformHint), PlatformHint: nullableText(row.PlatformHint),
PackageName: row.PackageName, PackageName: row.PackageName,
PublishedRevisionNo: nullableIntFromInt4(row.PublishedRevisionNo), PublishedRevisionNo: nullableIntFromInt4(row.PublishedRevisionNo),
KolDisplayName: row.KolDisplayName,
} }
} }
return result, nil return result, nil
@@ -46,8 +46,9 @@ type KolDashboardOverview struct {
} }
type KolDashboardTrendPoint struct { type KolDashboardTrendPoint struct {
Day time.Time Day time.Time
Usages int64 Usages int64
Subscriptions int64
} }
type KolUsagePackageStats struct { type KolUsagePackageStats struct {
@@ -170,8 +171,9 @@ func (r *kolUsageRepository) DashboardTrend(ctx context.Context, packageIDs []in
day = row.D.Time day = row.D.Time
} }
result[i] = KolDashboardTrendPoint{ result[i] = KolDashboardTrendPoint{
Day: day, Day: day,
Usages: row.Usages, Usages: row.Usages,
Subscriptions: row.Subscriptions,
} }
} }
return result, nil return result, nil
@@ -8,7 +8,8 @@ SELECT kp.id, kp.tenant_id, kp.kol_profile_id, kp.name, kp.description,
(SELECT COUNT(*) FROM kol_prompts p (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, 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 (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 FROM kol_packages kp
JOIN kol_profiles pf ON pf.id = kp.kol_profile_id JOIN kol_profiles pf ON pf.id = kp.kol_profile_id
AND pf.status = 'active' AND pf.status = 'active'
@@ -23,11 +23,13 @@ WHERE tenant_id = sqlc.arg(tenant_id)::bigint
ORDER BY created_at DESC; ORDER BY created_at DESC;
-- name: ListActiveSubscribersForPackage :many -- 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 SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at
FROM kol_subscriptions FROM kol_subscriptions
WHERE package_id = sqlc.arg(package_id) 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 -- name: ApproveKolSubscription :exec
UPDATE kol_subscriptions UPDATE kol_subscriptions
@@ -37,7 +39,8 @@ SET status = 'active',
end_at = sqlc.arg(end_at), end_at = sqlc.arg(end_at),
updated_at = NOW() updated_at = NOW()
WHERE id = sqlc.arg(id) 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 -- name: RevokeKolSubscription :exec
UPDATE kol_subscriptions 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, SELECT sp.id, sp.tenant_id, sp.subscription_id, sp.package_id, sp.prompt_id,
sp.status, sp.granted_at, sp.revoked_at, sp.status, sp.granted_at, sp.revoked_at,
p.name AS prompt_name, p.platform_hint, p.published_revision_no, 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 FROM kol_subscription_prompts sp
JOIN kol_prompts p ON p.id = sp.prompt_id AND p.deleted_at IS NULL JOIN kol_subscriptions s ON s.id = sp.subscription_id
JOIN kol_packages k ON k.id = sp.package_id AND k.deleted_at IS NULL 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 WHERE sp.tenant_id = sqlc.arg(tenant_id)::bigint
AND sp.status = 'active' AND sp.status = 'active'
ORDER BY sp.granted_at DESC; ORDER BY sp.granted_at DESC;
@@ -44,6 +44,7 @@ WHERE generation_task_id = sqlc.arg(generation_task_id)
AND tenant_id = sqlc.arg(tenant_id)::bigint; AND tenant_id = sqlc.arg(tenant_id)::bigint;
-- name: KolDashboardOverview :one -- name: KolDashboardOverview :one
-- Cross-tenant aggregation: KOL owns packages across subscriber tenants.
SELECT SELECT
(SELECT COUNT(DISTINCT s.tenant_id) FROM kol_subscriptions s (SELECT COUNT(DISTINCT s.tenant_id) FROM kol_subscriptions s
WHERE s.package_id = ANY(sqlc.arg(package_ids)::bigint[]) AND s.status='active' 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.status='active'
AND s.created_at >= date_trunc('month', NOW())) AS month_subs, AND s.created_at >= date_trunc('month', NOW())) AS month_subs,
(SELECT COUNT(*) FROM kol_usage_logs u (SELECT COUNT(*) FROM kol_usage_logs u
WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[]) WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[])) AS total_usage,
AND u.tenant_id IS NOT NULL) AS total_usage,
(SELECT COUNT(*) FROM kol_usage_logs u (SELECT COUNT(*) FROM kol_usage_logs u
WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[]) 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, AND u.created_at >= date_trunc('month', NOW())) AS month_usage,
(SELECT COUNT(*) FROM kol_usage_logs u (SELECT COUNT(*) FROM kol_usage_logs u
WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[]) WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[])
AND u.tenant_id IS NOT NULL
AND u.status='failed') AS failure_count; AND u.status='failed') AS failure_count;
-- name: KolDashboardTrend :many -- name: KolDashboardTrend :many
SELECT date_trunc('day', u.created_at)::date AS d, -- Returns a per-day series combining usage count and new subscription count for KOL dashboard.
COUNT(*) FILTER (WHERE u.status='completed') AS usages WITH days AS (
FROM kol_usage_logs u SELECT generate_series(
WHERE u.package_id = ANY(sqlc.arg(package_ids)::bigint[]) date_trunc('day', NOW()) - (sqlc.arg(period_days)::int - 1) * INTERVAL '1 day',
AND u.tenant_id IS NOT NULL date_trunc('day', NOW()),
AND u.created_at >= NOW() - sqlc.arg(period_days)::int * INTERVAL '1 day' INTERVAL '1 day'
GROUP BY d )::date AS d
ORDER BY d ASC; ),
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 -- name: KolUsageCountByPackage :many
-- Cross-tenant aggregation for KOL dashboard.
SELECT package_id, COUNT(*) AS total, SELECT package_id, COUNT(*) AS total,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed
FROM kol_usage_logs FROM kol_usage_logs
WHERE package_id = ANY(sqlc.arg(package_ids)::bigint[]) WHERE package_id = ANY(sqlc.arg(package_ids)::bigint[])
AND tenant_id IS NOT NULL
GROUP BY package_id; GROUP BY package_id;
@@ -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;
@@ -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'));
+29 -14
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/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 # Usage: scripts/check_tenant_scope.sh
set -euo pipefail set -euo pipefail
@@ -7,30 +8,44 @@ set -euo pipefail
QUERY_DIR="internal/tenant/repository/queries" QUERY_DIR="internal/tenant/repository/queries"
EXIT_CODE=0 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 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") basename=$(basename "$f")
if [[ "$basename" == "audit.sql" ]] || [[ "$basename" == "kol_marketplace.sql" ]]; then if is_exempt "$basename"; then
continue continue
fi fi
# Check each named query block for tenant_id # Must contain at least one tenant_id = sqlc.arg(...) or sqlc.narg(...) filter.
while IFS= read -r line; do if ! grep -Eq 'tenant_id[[:space:]]*=[[:space:]]*sqlc\.(n?arg)\(' "$f"; then
if [[ "$line" =~ ^--\ name: ]]; then echo "ERROR: $f must filter on tenant_id = sqlc.(n?arg)(...)"
query_name="${line#-- name: }" EXIT_CODE=1
query_name="${query_name%% *}" fi
fi
done < "$f"
# Simple check: file should contain tenant_id somewhere # Reject cosmetic "tenant_id IS NOT NULL" that bypasses actor scoping.
if ! grep -q 'tenant_id' "$f"; then if grep -Eq 'tenant_id[[:space:]]+IS[[:space:]]+NOT[[:space:]]+NULL' "$f"; then
echo "ERROR: $f is missing tenant_id filter" echo "ERROR: $f uses 'tenant_id IS NOT NULL' — this does not scope the actor"
EXIT_CODE=1 EXIT_CODE=1
fi fi
done done
if [ "$EXIT_CODE" -eq 0 ]; then 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 fi
exit $EXIT_CODE exit $EXIT_CODE