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:
@@ -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
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ 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 {
|
||||||
@@ -172,6 +173,7 @@ func (r *kolUsageRepository) DashboardTrend(ctx context.Context, packageIDs []in
|
|||||||
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'));
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user