feat: enforce public bank SaaS scopes

This commit is contained in:
Codex
2026-06-30 08:55:22 +08:00
parent 4f64b7aed8
commit d1cb341351
10 changed files with 576 additions and 80 deletions

View File

@@ -1039,9 +1039,9 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
}
const item = await transaction(async client => {
const bankResult = await client.query<{ id: string }>(
const bankResult = await client.query<{ id: string; tenantId: string; regionId: string | null }>(
`
select id
select id, tenant_id as "tenantId", region_id as "regionId"
from public.question_banks
where id = $1
and source_scope = 'platform'
@@ -1052,6 +1052,45 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
if (!bankResult.rows[0]) {
throw new HttpError(404, 'Platform question bank not found', 'PLATFORM_QUESTION_BANK_NOT_FOUND');
}
const sourceBank = bankResult.rows[0];
if (allowedRegionIds.length) {
const regionCount = await client.query<{ count: string }>(
'select count(*)::text as count from public.regions where tenant_id = $1 and id = any($2::uuid[])',
[sourceBank.tenantId, allowedRegionIds],
);
if (Number(regionCount.rows[0]?.count || 0) !== allowedRegionIds.length) {
throw new HttpError(400, 'One or more allowed regions do not belong to the source platform tenant', 'PUBLIC_BANK_REGION_NOT_FOUND');
}
if (sourceBank.regionId && !allowedRegionIds.includes(sourceBank.regionId)) {
throw new HttpError(400, 'allowedRegionIds must include the source question bank region', 'PUBLIC_BANK_REGION_SCOPE_MISMATCH');
}
}
if (allowedSubjectIds.length) {
const subjectCount = await client.query<{ count: string }>(
'select count(*)::text as count from public.subjects where tenant_id = $1 and id = any($2::uuid[])',
[sourceBank.tenantId, allowedSubjectIds],
);
if (Number(subjectCount.rows[0]?.count || 0) !== allowedSubjectIds.length) {
throw new HttpError(400, 'One or more allowed subjects do not belong to the source platform tenant', 'PUBLIC_BANK_SUBJECT_NOT_FOUND');
}
const outOfScopeSubjects = await client.query<{ count: string }>(
`
select count(distinct q.subject_id)::text as count
from public.questions q
where q.tenant_id = $1
and q.question_bank_id = $2
and q.status = 'published'
and q.subject_id is not null
and not q.subject_id = any($3::uuid[])
`,
[sourceBank.tenantId, questionBankId, allowedSubjectIds],
);
if (Number(outOfScopeSubjects.rows[0]?.count || 0) > 0) {
throw new HttpError(400, 'allowedSubjectIds do not cover all published questions in this bank', 'PUBLIC_BANK_SUBJECT_SCOPE_MISMATCH');
}
}
if (allowedPlanCodes.length) {
const planCount = await client.query<{ count: string }>(
@@ -1131,6 +1170,8 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
grantScope,
allowedPlanCodes,
allowedTenantIds,
allowedRegionIds,
allowedSubjectIds,
status,
}),
],

View File

@@ -15,6 +15,10 @@ interface EligibleBankRow {
sourceRegionName: string | null;
grantScope: string;
allowedPlanCodes: string[];
allowedRegionIds: string[];
allowedSubjectIds: string[];
accessPlanCode: string | null;
accessMode: string | null;
questionCount: number;
adoptedId: string | null;
adoptionStatus: string | null;
@@ -109,6 +113,80 @@ export interface PublicQuestionBankSyncInput {
workerId?: string | null;
}
const PUBLIC_BANK_ACCESS_CTES = `
with active_subscriptions as (
select s.plan_code, s.metadata, coalesce(p.feature_flags, '{}'::jsonb) as plan_feature_flags
from public.tenant_subscriptions s
left join public.platform_saas_plans p on p.code = s.plan_code
where s.tenant_id = $1
and s.status in ('trial', 'active')
and (s.expires_at is null or s.expires_at > now())
),
source_subjects as (
select q.question_bank_id, array_agg(distinct q.subject_id) filter (where q.subject_id is not null) as subject_ids
from public.questions q
where q.status = 'published'
group by q.question_bank_id
),
eligible_grants as (
select g.*,
access.plan_code as access_plan_code,
access.access_mode
from public.question_bank_grants g
join public.question_banks source_qb
on source_qb.id = g.source_question_bank_id
and source_qb.source_scope = 'platform'
and source_qb.status = 'active'
left join source_subjects ss on ss.question_bank_id = source_qb.id
left join lateral (
select s.plan_code,
coalesce(
nullif(s.metadata #>> '{publicQuestionBankAccess,mode}', ''),
nullif(s.plan_feature_flags #>> '{publicQuestionBanks,mode}', ''),
'all'
) as access_mode
from active_subscriptions s
where app.public_question_bank_subscription_allows(
s.metadata,
s.plan_feature_flags,
source_qb.id,
source_qb.region_id,
coalesce(ss.subject_ids, '{}'::uuid[])
)
order by case when g.grant_scope in ('plans', 'mixed') and s.plan_code = any(g.allowed_plan_codes) then 0 else 1 end,
s.plan_code
limit 1
) access on true
where g.status = 'active'
and (g.starts_at is null or g.starts_at <= now())
and (g.expires_at is null or g.expires_at > now())
and app.public_question_bank_grant_allows(
g.allowed_region_ids,
g.allowed_subject_ids,
source_qb.region_id,
coalesce(ss.subject_ids, '{}'::uuid[])
)
and (
(
g.grant_scope = 'all_active_tenants'
and access.plan_code is not null
)
or (
g.grant_scope in ('plans', 'mixed')
and access.plan_code = any(g.allowed_plan_codes)
)
or (
g.grant_scope in ('tenants', 'mixed')
and $1 = any(g.allowed_tenant_ids)
and (
g.metadata->>'requiresActiveSubscription' = 'false'
or access.plan_code is not null
)
)
)
)
`;
function slugFromName(name: string) {
const ascii = name
.normalize('NFKD')
@@ -201,13 +279,7 @@ async function loadEligibleGrant(
) {
const result = await client.query<EligibleBankRow>(
`
with active_subscriptions as (
select plan_code
from public.tenant_subscriptions
where tenant_id = $1
and status in ('trial', 'active')
and (expires_at is null or expires_at > now())
)
${PUBLIC_BANK_ACCESS_CTES}
select g.id as "grantId",
qb.id as "sourceQuestionBankId",
qb.name as "sourceQuestionBankName",
@@ -216,6 +288,10 @@ async function loadEligibleGrant(
r.name as "sourceRegionName",
g.grant_scope as "grantScope",
g.allowed_plan_codes as "allowedPlanCodes",
g.allowed_region_ids as "allowedRegionIds",
g.allowed_subject_ids as "allowedSubjectIds",
g.access_plan_code as "accessPlanCode",
g.access_mode as "accessMode",
coalesce(qs.question_count, 0)::integer as "questionCount",
a.id as "adoptedId",
a.status as "adoptionStatus",
@@ -225,7 +301,7 @@ async function loadEligibleGrant(
a.target_collection_id as "targetCollectionId",
a.copied_question_count as "copiedQuestionCount",
a.last_synced_at as "lastSyncedAt"
from public.question_bank_grants g
from eligible_grants g
join public.question_banks qb on qb.id = g.source_question_bank_id
left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id
left join lateral (
@@ -239,29 +315,6 @@ async function loadEligibleGrant(
on a.tenant_id = $1
and a.source_question_bank_id = qb.id
where g.id = $2
and g.status = 'active'
and (g.starts_at is null or g.starts_at <= now())
and (g.expires_at is null or g.expires_at > now())
and qb.source_scope = 'platform'
and qb.status = 'active'
and (
(
g.grant_scope = 'all_active_tenants'
and exists (select 1 from active_subscriptions)
)
or (
g.grant_scope in ('plans', 'mixed')
and exists (
select 1
from active_subscriptions s
where s.plan_code = any(g.allowed_plan_codes)
)
)
or (
g.grant_scope in ('tenants', 'mixed')
and $1 = any(g.allowed_tenant_ids)
)
)
limit 1
`,
[tenantId, grantId],
@@ -805,38 +858,7 @@ export async function publicQuestionBanksRoute(ctx: RequestContext) {
const items = await query<EligibleBankRow>(
`
with active_subscriptions as (
select plan_code
from public.tenant_subscriptions
where tenant_id = $1
and status in ('trial', 'active')
and (expires_at is null or expires_at > now())
),
eligible_grants as (
select g.*
from public.question_bank_grants g
where g.status = 'active'
and (g.starts_at is null or g.starts_at <= now())
and (g.expires_at is null or g.expires_at > now())
and (
(
g.grant_scope = 'all_active_tenants'
and exists (select 1 from active_subscriptions)
)
or (
g.grant_scope in ('plans', 'mixed')
and exists (
select 1
from active_subscriptions s
where s.plan_code = any(g.allowed_plan_codes)
)
)
or (
g.grant_scope in ('tenants', 'mixed')
and $1 = any(g.allowed_tenant_ids)
)
)
)
${PUBLIC_BANK_ACCESS_CTES}
select g.id as "grantId",
qb.id as "sourceQuestionBankId",
qb.name as "sourceQuestionBankName",
@@ -845,6 +867,10 @@ export async function publicQuestionBanksRoute(ctx: RequestContext) {
r.name as "sourceRegionName",
g.grant_scope as "grantScope",
g.allowed_plan_codes as "allowedPlanCodes",
g.allowed_region_ids as "allowedRegionIds",
g.allowed_subject_ids as "allowedSubjectIds",
g.access_plan_code as "accessPlanCode",
g.access_mode as "accessMode",
coalesce(qs.question_count, 0)::integer as "questionCount",
a.id as "adoptedId",
a.status as "adoptionStatus",