forked from wangziqi/gongxue-base
feat: enforce public bank SaaS scopes
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user