From 4c8ed5e2f21e74f1103580e3bd37dc22a336181a Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 8 Mar 2026 16:16:03 -0700 Subject: [PATCH] Risk scores --- .../migration.sql | 5 + apps/backend/prisma/schema.prisma | 4 + apps/backend/prisma/seed.ts | 6 +- .../internal/sign-up-rules-test/route.tsx | 16 + .../backend/src/app/api/latest/users/crud.tsx | 18 +- apps/backend/src/lib/cel-evaluator.ts | 43 ++ apps/backend/src/lib/risk-scores.tsx | 29 + apps/backend/src/lib/users.tsx | 27 +- .../[projectId]/sign-up-rules/page-client.tsx | 47 +- .../users/[userId]/page-client.tsx | 6 + .../rule-builder/condition-builder.tsx | 32 +- .../src/lib/cel-visual-parser.test.ts | 28 + apps/dashboard/src/lib/cel-visual-parser.ts | 121 +++- .../v1/internal/sign-up-rules-test.test.ts | 45 ++ .../endpoints/api/v1/risk-scores.test.ts | 569 ++++++++++++++++++ .../stack-shared/src/interface/crud/users.ts | 18 + .../apps/implementations/server-app-impl.ts | 6 + .../template/src/lib/stack-app/users/index.ts | 7 + 18 files changed, 1009 insertions(+), 18 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260308000000_add_signup_risk_scores/migration.sql create mode 100644 apps/backend/src/lib/risk-scores.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts diff --git a/apps/backend/prisma/migrations/20260308000000_add_signup_risk_scores/migration.sql b/apps/backend/prisma/migrations/20260308000000_add_signup_risk_scores/migration.sql new file mode 100644 index 0000000000..73a3a13d25 --- /dev/null +++ b/apps/backend/prisma/migrations/20260308000000_add_signup_risk_scores/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable: add columns with a temporary default for existing rows, then drop the default +ALTER TABLE "ProjectUser" ADD COLUMN "signUpRiskScoreBot" SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE "ProjectUser" ADD COLUMN "signUpRiskScoreFreeTrialAbuse" SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE "ProjectUser" ALTER COLUMN "signUpRiskScoreBot" DROP DEFAULT; +ALTER TABLE "ProjectUser" ALTER COLUMN "signUpRiskScoreFreeTrialAbuse" DROP DEFAULT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0ce2ed3914..cc54d874ce 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -221,6 +221,10 @@ model ProjectUser { restrictedByAdminReason String? // Publicly viewable reason (shown to user) restrictedByAdminPrivateDetails String? // Private details (server access only) + // Sign-up risk scores (0-100, set at sign-up time) + signUpRiskScoreBot Int @db.SmallInt + signUpRiskScoreFreeTrialAbuse Int @db.SmallInt + projectUserOAuthAccounts ProjectUserOAuthAccount[] teamMembers TeamMember[] contactChannels ContactChannel[] diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 2663aa188c..6ed47a942c 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -7,7 +7,7 @@ import { overrideBranchConfigOverride, overrideEnvironmentConfigOverride, setBra import { ensurePermissionDefinition, grantTeamPermission } from '@/lib/permissions'; import { createOrUpdateProjectWithLegacyConfig, getProject } from '@/lib/projects'; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from '@/lib/tenancies'; -import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction } from '@/prisma-client'; +import { PrismaClientTransaction, getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client'; import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config'; import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails'; import { AdminUserProjectsCrud, ProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects'; @@ -315,6 +315,8 @@ export async function seed() { tenancyId: internalTenancy.id, mirroredProjectId: 'internal', mirroredBranchId: DEFAULT_BRANCH_ID, + signUpRiskScoreBot: 0, + signUpRiskScoreFreeTrialAbuse: 0, } }); @@ -448,6 +450,8 @@ export async function seed() { tenancyId: internalTenancy.id, mirroredProjectId: 'internal', mirroredBranchId: DEFAULT_BRANCH_ID, + signUpRiskScoreBot: 0, + signUpRiskScoreFreeTrialAbuse: 0, } }); diff --git a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx index cc55fd772c..090480fc21 100644 --- a/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx +++ b/apps/backend/src/app/api/latest/internal/sign-up-rules-test/route.tsx @@ -21,6 +21,10 @@ export const POST = createSmartRouteHandler({ email: yupString().optional(), auth_method: yupString().oneOf(AUTH_METHODS).defined(), oauth_provider: yupString().optional(), + risk_scores: yupObject({ + bot: yupNumber().min(0).max(100).integer().defined(), + free_trial_abuse: yupNumber().min(0).max(100).integer().defined(), + }).defined(), }).defined(), }), response: yupObject({ @@ -32,6 +36,10 @@ export const POST = createSmartRouteHandler({ email_domain: yupString().defined(), auth_method: yupString().oneOf(AUTH_METHODS).defined(), oauth_provider: yupString().defined(), + risk_scores: yupObject({ + bot: yupNumber().min(0).max(100).integer().defined(), + free_trial_abuse: yupNumber().min(0).max(100).integer().defined(), + }).defined(), }).defined(), evaluations: yupArray(yupObject({ rule_id: yupString().defined(), @@ -58,6 +66,10 @@ export const POST = createSmartRouteHandler({ email: req.body.email, authMethod: req.body.auth_method, oauthProvider: req.body.oauth_provider, + riskScores: { + bot: req.body.risk_scores.bot, + freeTrialAbuse: req.body.risk_scores.free_trial_abuse, + }, }); const trace = evaluateSignUpRulesWithTrace(req.auth.tenancy, context); @@ -70,6 +82,10 @@ export const POST = createSmartRouteHandler({ email_domain: context.emailDomain, auth_method: context.authMethod, oauth_provider: context.oauthProvider, + risk_scores: { + bot: context.riskScores.bot, + free_trial_abuse: context.riskScores.freeTrialAbuse, + }, }, evaluations: trace.evaluations.map((evaluation) => ({ rule_id: evaluation.ruleId, diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 3a16996d3d..9428795e8c 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -16,8 +16,8 @@ import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { KnownErrors } from "@stackframe/stack-shared"; import { currentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user"; import { UsersCrud, usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; +import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64"; import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -184,6 +184,12 @@ export const userPrismaToCrud = ( restricted_by_admin: prisma.restrictedByAdmin, restricted_by_admin_reason: prisma.restrictedByAdminReason, restricted_by_admin_private_details: prisma.restrictedByAdminPrivateDetails, + risk_scores: { + sign_up: { + bot: prisma.signUpRiskScoreBot, + free_trial_abuse: prisma.signUpRiskScoreFreeTrialAbuse, + }, + }, }; return result; }; @@ -402,6 +408,12 @@ export function getUserQuery(projectId: string, branchId: string, userId: string restricted_by_admin: row.restrictedByAdmin, restricted_by_admin_reason: row.restrictedByAdminReason, restricted_by_admin_private_details: row.restrictedByAdminPrivateDetails, + risk_scores: { + sign_up: { + bot: row.signUpRiskScoreBot, + free_trial_abuse: row.signUpRiskScoreFreeTrialAbuse, + }, + }, }; }, }; @@ -642,6 +654,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC restrictedByAdmin, restrictedByAdminReason, restrictedByAdminPrivateDetails, + signUpRiskScoreBot: data.risk_scores?.sign_up.bot ?? 0, + signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse ?? 0, }, include: userFullInclude, }); @@ -1144,6 +1158,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC restrictedByAdmin: data.restricted_by_admin ?? undefined, restrictedByAdminReason: restrictedByAdminReason, restrictedByAdminPrivateDetails: restrictedByAdminPrivateDetails, + signUpRiskScoreBot: data.risk_scores?.sign_up.bot, + signUpRiskScoreFreeTrialAbuse: data.risk_scores?.sign_up.free_trial_abuse, }), include: userFullInclude, }); diff --git a/apps/backend/src/lib/cel-evaluator.ts b/apps/backend/src/lib/cel-evaluator.ts index 33900b1fb1..970bd32b21 100644 --- a/apps/backend/src/lib/cel-evaluator.ts +++ b/apps/backend/src/lib/cel-evaluator.ts @@ -1,5 +1,6 @@ import { evaluate } from "cel-js"; import { normalizeEmail } from "./emails"; +import { SignUpRiskScores } from "./risk-scores"; /** * Custom error class for CEL evaluation failures. @@ -32,6 +33,7 @@ export type SignUpRuleContext = { authMethod: 'password' | 'otp' | 'oauth' | 'passkey', /** OAuth provider ID if authMethod is "oauth", empty string otherwise */ oauthProvider: string, + riskScores: SignUpRiskScores, }; /** @@ -160,6 +162,7 @@ export function createSignUpRuleContext(params: { email?: string, authMethod: 'password' | 'otp' | 'oauth' | 'passkey', oauthProvider?: string, + riskScores: SignUpRiskScores, }): SignUpRuleContext { // Handle missing email (e.g., OAuth providers that don't return email) // Use empty string so email-based rules don't match @@ -178,6 +181,7 @@ export function createSignUpRuleContext(params: { emailDomain, authMethod: params.authMethod, oauthProvider: params.oauthProvider ?? '', + riskScores: params.riskScores, }; } @@ -187,11 +191,19 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { expect(createSignUpRuleContext({ email: 'Test.User@Example.COM', authMethod: 'password', + riskScores: { + bot: 17, + freeTrialAbuse: 23, + }, })).toEqual({ email: 'test.user@example.com', emailDomain: 'example.com', authMethod: 'password', oauthProvider: '', + riskScores: { + bot: 17, + freeTrialAbuse: 23, + }, }); // Should handle missing email (OAuth providers without email) @@ -199,11 +211,19 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { email: undefined, authMethod: 'oauth', oauthProvider: 'discord', + riskScores: { + bot: 1, + freeTrialAbuse: 2, + }, })).toEqual({ email: '', emailDomain: '', authMethod: 'oauth', oauthProvider: 'discord', + riskScores: { + bot: 1, + freeTrialAbuse: 2, + }, }); // Should handle empty string email @@ -211,11 +231,19 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { email: '', authMethod: 'oauth', oauthProvider: 'twitter', + riskScores: { + bot: 10, + freeTrialAbuse: 20, + }, })).toEqual({ email: '', emailDomain: '', authMethod: 'oauth', oauthProvider: 'twitter', + riskScores: { + bot: 10, + freeTrialAbuse: 20, + }, }); // Should handle OAuth with email @@ -223,11 +251,19 @@ import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { email: 'oauth.user@gmail.com', authMethod: 'oauth', oauthProvider: 'google', + riskScores: { + bot: 8, + freeTrialAbuse: 9, + }, })).toEqual({ email: 'oauth.user@gmail.com', emailDomain: 'gmail.com', authMethod: 'oauth', oauthProvider: 'google', + riskScores: { + bot: 8, + freeTrialAbuse: 9, + }, }); }); @@ -237,6 +273,10 @@ import.meta.vitest?.test('evaluateCelExpression with missing email', async ({ ex email: undefined, authMethod: 'oauth', oauthProvider: 'discord', + riskScores: { + bot: 33, + freeTrialAbuse: 44, + }, }); // Email-based conditions should fail when email is empty @@ -247,6 +287,9 @@ import.meta.vitest?.test('evaluateCelExpression with missing email', async ({ ex // But authMethod-based conditions should still work expect(evaluateCelExpression('authMethod == "oauth"', context)).toBe(true); expect(evaluateCelExpression('oauthProvider == "discord"', context)).toBe(true); + expect(evaluateCelExpression('riskScores.bot == 33', context)).toBe(true); + expect(evaluateCelExpression('riskScores.freeTrialAbuse == 44', context)).toBe(true); + expect(evaluateCelExpression('riskScores.bot > 10 && riskScores.freeTrialAbuse < 90', context)).toBe(true); // Empty email should match empty string expect(evaluateCelExpression('email == ""', context)).toBe(true); diff --git a/apps/backend/src/lib/risk-scores.tsx b/apps/backend/src/lib/risk-scores.tsx new file mode 100644 index 0000000000..d890578b52 --- /dev/null +++ b/apps/backend/src/lib/risk-scores.tsx @@ -0,0 +1,29 @@ +import { Tenancy } from "./tenancies"; + +export type SignUpRiskScores = { + bot: number, + freeTrialAbuse: number, +}; + +export type SignUpRiskScoreContext = { + primaryEmail: string | null, + primaryEmailVerified: boolean, + authMethod: 'password' | 'otp' | 'oauth' | 'passkey', + oauthProvider?: string, + ipAddress: string | null, +}; + +export async function calculateSignUpRiskScores(tenancy: Tenancy, context: SignUpRiskScoreContext): Promise { + // TODO + if (context.primaryEmail === "test@example.com") { + return { + bot: 100, + freeTrialAbuse: 100, + }; + } else { + return { + bot: 0, + freeTrialAbuse: 0, + }; + } +} diff --git a/apps/backend/src/lib/users.tsx b/apps/backend/src/lib/users.tsx index 4de1115a7a..a9df28dfb2 100644 --- a/apps/backend/src/lib/users.tsx +++ b/apps/backend/src/lib/users.tsx @@ -3,6 +3,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { KeyIntersect } from "@stackframe/stack-shared/dist/utils/types"; import { createSignUpRuleContext } from "./cel-evaluator"; +import { calculateSignUpRiskScores } from "./risk-scores"; import { evaluateSignUpRules } from "./sign-up-rules"; import { Tenancy } from "./tenancies"; @@ -12,6 +13,7 @@ import { Tenancy } from "./tenancies"; export type SignUpRuleOptions = { authMethod: 'password' | 'otp' | 'oauth' | 'passkey', oauthProvider?: string, + ipAddress: string | null, }; /** @@ -43,10 +45,21 @@ export async function createOrUpgradeAnonymousUserWithRules( signUpRuleOptions: SignUpRuleOptions, ): Promise { const email = createOrUpdate.primary_email ?? currentUser?.primary_email ?? undefined; + const primaryEmailVerified = createOrUpdate.primary_email_verified ?? currentUser?.primary_email_verified ?? false; + + const riskScores = await calculateSignUpRiskScores(tenancy, { + primaryEmail: email ?? null, + primaryEmailVerified, + authMethod: signUpRuleOptions.authMethod, + oauthProvider: signUpRuleOptions.oauthProvider, + ipAddress: signUpRuleOptions.ipAddress, + }); + const ruleResult = await evaluateSignUpRules(tenancy, createSignUpRuleContext({ email, authMethod: signUpRuleOptions.authMethod, oauthProvider: signUpRuleOptions.oauthProvider, + riskScores, })); if (!ruleResult.shouldAllow) { @@ -68,17 +81,21 @@ export async function createOrUpgradeAnonymousUserWithRules( restricted_by_admin: true, restricted_by_admin_private_details: existingRestrictionPrivateDetails ? `${existingRestrictionPrivateDetails}\n\n${restrictionPrivateDetails}` : restrictionPrivateDetails, } : {}, + risk_scores: { + sign_up: { + bot: riskScores.bot, + free_trial_abuse: riskScores.freeTrialAbuse, + }, + }, }; // Proceed with user creation/upgrade - const user = await createOrUpgradeAnonymousUserWithoutRules( + return await createOrUpgradeAnonymousUserWithoutRules( tenancy, currentUser, enrichedCreateOrUpdate as KeyIntersect, allowedErrorTypes, ); - - return user; } /** @@ -109,9 +126,11 @@ export async function createOrUpgradeAnonymousUserWithoutRules( }); } else { // Create new user (normal flow) + // Cast needed: createOrUpdate may contain create-only fields (like risk scores) that + // KeyIntersect strips from the type since they're absent on Update return await usersCrudHandlers.adminCreate({ tenancy, - data: createOrUpdate, + data: createOrUpdate as UsersCrud["Admin"]["Create"], allowedErrorTypes, }); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index ae32c451b2..60de270c82 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -87,6 +87,10 @@ type SignUpRulesTestResult = { email_domain: string, auth_method: 'password' | 'otp' | 'oauth' | 'passkey', oauth_provider: string, + risk_scores: { + bot: number, + free_trial_abuse: number, + }, }, evaluations: SignUpRulesTestEvaluation[], outcome: { @@ -521,6 +525,8 @@ function TestRulesCard({ const [email, setEmail] = useState(''); const [authMethod, setAuthMethod] = useState('password'); const [oauthProvider, setOauthProvider] = useState(''); + const [riskScoreBot, setRiskScoreBot] = useState(0); + const [riskScoreFreeTrialAbuse, setRiskScoreFreeTrialAbuse] = useState(0); const [result, setResult] = useState(null); const [runTest, isRunning] = useAsyncCallback(async () => { @@ -532,6 +538,10 @@ function TestRulesCard({ email: email || undefined, auth_method: authMethod, oauth_provider: authMethod === 'oauth' ? (oauthProvider || undefined) : undefined, + risk_scores: { + bot: riskScoreBot, + free_trial_abuse: riskScoreFreeTrialAbuse, + }, }), headers: { 'Content-Type': 'application/json', @@ -546,7 +556,7 @@ function TestRulesCard({ const data = await response.json(); setResult(data); - }, [authMethod, email, oauthProvider, stackAdminApp]); + }, [authMethod, email, oauthProvider, riskScoreBot, riskScoreFreeTrialAbuse, stackAdminApp]); const handleAuthMethodChange = (value: string) => { if (value === 'password' || value === 'otp' || value === 'oauth' || value === 'passkey') { @@ -653,6 +663,35 @@ function TestRulesCard({ +
+
+ + Risk score: bot + + setRiskScoreBot(Math.max(0, Math.min(100, Number(e.target.value) || 0)))} + /> +
+
+ + Risk score: free trial abuse + + setRiskScoreFreeTrialAbuse(Math.max(0, Math.min(100, Number(e.target.value) || 0)))} + /> +
+
+
)} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index d438a6083f..08da04683f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -417,6 +417,12 @@ function UserDetails({ user }: UserDetailsProps) { } name="Signed up at"> + } name="Risk score: bot"> + + + } name="Risk score: free trial abuse"> + + ); diff --git a/apps/dashboard/src/components/rule-builder/condition-builder.tsx b/apps/dashboard/src/components/rule-builder/condition-builder.tsx index e78d9616e2..c634eac715 100644 --- a/apps/dashboard/src/components/rule-builder/condition-builder.tsx +++ b/apps/dashboard/src/components/rule-builder/condition-builder.tsx @@ -59,12 +59,18 @@ const FIELD_OPTIONS: { value: ConditionField, label: string }[] = [ { value: 'emailDomain', label: 'Email Domain' }, { value: 'authMethod', label: 'Auth Method' }, { value: 'oauthProvider', label: 'OAuth Provider' }, + { value: 'riskScores.bot', label: 'Risk Score: Bot' }, + { value: 'riskScores.freeTrialAbuse', label: 'Risk Score: Free Trial Abuse' }, ]; // Operator options with labels const OPERATOR_OPTIONS: { value: ConditionOperator, label: string }[] = [ { value: 'equals', label: 'equals' }, { value: 'not_equals', label: 'does not equal' }, + { value: 'greater_than', label: 'is greater than' }, + { value: 'greater_or_equal', label: 'is greater than or equal' }, + { value: 'less_than', label: 'is less than' }, + { value: 'less_or_equal', label: 'is less than or equal' }, { value: 'contains', label: 'contains' }, { value: 'starts_with', label: 'starts with' }, { value: 'ends_with', label: 'ends with' }, @@ -72,8 +78,15 @@ const OPERATOR_OPTIONS: { value: ConditionOperator, label: string }[] = [ { value: 'in_list', label: 'is one of' }, ]; +function isNumericField(field: ConditionField): boolean { + return field === 'riskScores.bot' || field === 'riskScores.freeTrialAbuse'; +} + // Get available operators for a field function getOperatorsForField(field: ConditionField): ConditionOperator[] { + if (isNumericField(field)) { + return ['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal']; + } if (field === 'authMethod' || field === 'oauthProvider') { return ['equals', 'not_equals', 'in_list']; } @@ -111,7 +124,11 @@ function ConditionRow({ const operator = newOperators.includes(condition.operator) ? condition.operator : newOperators[0]; // Reset value - use array for in_list, string otherwise - const value: string | string[] = operator === 'in_list' ? [] : ''; + const value: string | number | string[] = operator === 'in_list' + ? [] + : isNumericField(field) + ? 0 + : ''; onChange({ ...condition, field, operator, value }); }; @@ -127,7 +144,7 @@ function ConditionRow({ onChange({ ...condition, operator, value }); }; - const handleValueChange = (value: string | string[]) => { + const handleValueChange = (value: string | number | string[]) => { onChange({ ...condition, value }); }; @@ -178,6 +195,17 @@ function ConditionRow({ ))} + ) : isNumericField(condition.field) ? ( + handleValueChange(e.target.value === '' ? 0 : Number(e.target.value))} + placeholder="0-100" + className="h-8 px-2 text-sm bg-background/60 border border-border/50 rounded-md flex-1" + /> ) : (
{ expect(cel).toContain('inject\\"attack.com'); expect(cel).toContain('also\\\\bad.com'); }); + + it('should serialize numeric risk score comparisons', () => { + const greaterThan = visualTreeToCel({ + ...createEmptyCondition(), + field: 'riskScores.bot' as const, + operator: 'greater_than' as const, + value: 80, + }); + const lessOrEqual = visualTreeToCel({ + ...createEmptyCondition(), + field: 'riskScores.freeTrialAbuse' as const, + operator: 'less_or_equal' as const, + value: 40, + }); + + expect(greaterThan).toBe('riskScores.bot > 80'); + expect(lessOrEqual).toBe('riskScores.freeTrialAbuse <= 40'); + }); }); describe('CEL to visual tree parsing', () => { @@ -166,5 +184,15 @@ describe('cel-visual-parser', () => { expect(result.value).toBe('test\\value'); } }); + + it('should parse numeric risk score comparisons', () => { + const result = parseCelToVisualTree('riskScores.bot >= 75'); + expect(result).toBeDefined(); + if (result?.type === 'condition') { + expect(result.field).toBe('riskScores.bot'); + expect(result.operator).toBe('greater_or_equal'); + expect(result.value).toBe(75); + } + }); }); }); diff --git a/apps/dashboard/src/lib/cel-visual-parser.ts b/apps/dashboard/src/lib/cel-visual-parser.ts index fb128a79a9..5dc059abbe 100644 --- a/apps/dashboard/src/lib/cel-visual-parser.ts +++ b/apps/dashboard/src/lib/cel-visual-parser.ts @@ -11,11 +11,16 @@ * - emailDomain == "domain.com" / emailDomain in ["d1", "d2"] * - authMethod == "password" / authMethod in ["password", "otp"] * - oauthProvider == "google" / oauthProvider in ["google", "github"] + * - riskScores.bot > 80 / riskScores.freeTrialAbuse >= 60 */ export type ConditionOperator = | 'equals' | 'not_equals' + | 'greater_than' + | 'greater_or_equal' + | 'less_than' + | 'less_or_equal' | 'matches' // regex | 'ends_with' | 'starts_with' @@ -26,14 +31,20 @@ export type ConditionField = | 'email' | 'emailDomain' | 'authMethod' - | 'oauthProvider'; + | 'oauthProvider' + | 'riskScores.bot' + | 'riskScores.freeTrialAbuse'; + +function isNumericField(field: ConditionField): boolean { + return field === 'riskScores.bot' || field === 'riskScores.freeTrialAbuse'; +} export type ConditionNode = { type: 'condition', id: string, field: ConditionField, operator: ConditionOperator, - value: string | string[], + value: string | number | string[], }; export type GroupNode = { @@ -77,14 +88,34 @@ function unescapeCelString(value: string): string { function conditionToCel(condition: ConditionNode): string { const { field, operator, value } = condition; + const valueAsNumber = typeof value === 'number' ? value : Number(value); + const useNumericValue = isNumericField(field) && Number.isFinite(valueAsNumber); switch (operator) { case 'equals': { + if (useNumericValue) { + return `${field} == ${valueAsNumber}`; + } return `${field} == "${escapeCelString(String(value))}"`; } case 'not_equals': { + if (useNumericValue) { + return `${field} != ${valueAsNumber}`; + } return `${field} != "${escapeCelString(String(value))}"`; } + case 'greater_than': { + return `${field} > ${useNumericValue ? valueAsNumber : 0}`; + } + case 'greater_or_equal': { + return `${field} >= ${useNumericValue ? valueAsNumber : 0}`; + } + case 'less_than': { + return `${field} < ${useNumericValue ? valueAsNumber : 0}`; + } + case 'less_or_equal': { + return `${field} <= ${useNumericValue ? valueAsNumber : 0}`; + } case 'matches': { return `${field}.matches("${escapeCelString(String(value))}")`; } @@ -260,8 +291,80 @@ function splitByOperator(expr: string, operator: string): string[] { function parseCondition(expr: string): ConditionNode | null { const trimmed = expr.trim(); + // Match patterns like: field >= 42 + const greaterOrEqualNumberMatch = trimmed.match(/^([\w.]+)\s*>=\s*(-?\d+(?:\.\d+)?)$/); + if (greaterOrEqualNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: greaterOrEqualNumberMatch[1] as ConditionField, + operator: 'greater_or_equal', + value: Number(greaterOrEqualNumberMatch[2]), + }; + } + + // Match patterns like: field <= 42 + const lessOrEqualNumberMatch = trimmed.match(/^([\w.]+)\s*<=\s*(-?\d+(?:\.\d+)?)$/); + if (lessOrEqualNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: lessOrEqualNumberMatch[1] as ConditionField, + operator: 'less_or_equal', + value: Number(lessOrEqualNumberMatch[2]), + }; + } + + // Match patterns like: field > 42 + const greaterNumberMatch = trimmed.match(/^([\w.]+)\s*>\s*(-?\d+(?:\.\d+)?)$/); + if (greaterNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: greaterNumberMatch[1] as ConditionField, + operator: 'greater_than', + value: Number(greaterNumberMatch[2]), + }; + } + + // Match patterns like: field < 42 + const lessNumberMatch = trimmed.match(/^([\w.]+)\s*<\s*(-?\d+(?:\.\d+)?)$/); + if (lessNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: lessNumberMatch[1] as ConditionField, + operator: 'less_than', + value: Number(lessNumberMatch[2]), + }; + } + + // Match patterns like: field == 42 + const equalsNumberMatch = trimmed.match(/^([\w.]+)\s*==\s*(-?\d+(?:\.\d+)?)$/); + if (equalsNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: equalsNumberMatch[1] as ConditionField, + operator: 'equals', + value: Number(equalsNumberMatch[2]), + }; + } + + // Match patterns like: field != 42 + const notEqualsNumberMatch = trimmed.match(/^([\w.]+)\s*!=\s*(-?\d+(?:\.\d+)?)$/); + if (notEqualsNumberMatch) { + return { + type: 'condition', + id: generateNodeId(), + field: notEqualsNumberMatch[1] as ConditionField, + operator: 'not_equals', + value: Number(notEqualsNumberMatch[2]), + }; + } + // Match patterns like: field == "value" - const equalsMatch = trimmed.match(/^(\w+)\s*==\s*"((?:\\.|[^"\\])*)"$/); + const equalsMatch = trimmed.match(/^([\w.]+)\s*==\s*"((?:\\.|[^"\\])*)"$/); if (equalsMatch) { return { type: 'condition', @@ -273,7 +376,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field != "value" - const notEqualsMatch = trimmed.match(/^(\w+)\s*!=\s*"((?:\\.|[^"\\])*)"$/); + const notEqualsMatch = trimmed.match(/^([\w.]+)\s*!=\s*"((?:\\.|[^"\\])*)"$/); if (notEqualsMatch) { return { type: 'condition', @@ -285,7 +388,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field.matches("regex") - const matchesMatch = trimmed.match(/^(\w+)\.matches\("((?:\\.|[^"\\])*)"\)$/); + const matchesMatch = trimmed.match(/^([\w.]+)\.matches\("((?:\\.|[^"\\])*)"\)$/); if (matchesMatch) { return { type: 'condition', @@ -297,7 +400,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field.endsWith("value") - const endsWithMatch = trimmed.match(/^(\w+)\.endsWith\("((?:\\.|[^"\\])*)"\)$/); + const endsWithMatch = trimmed.match(/^([\w.]+)\.endsWith\("((?:\\.|[^"\\])*)"\)$/); if (endsWithMatch) { return { type: 'condition', @@ -309,7 +412,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field.startsWith("value") - const startsWithMatch = trimmed.match(/^(\w+)\.startsWith\("((?:\\.|[^"\\])*)"\)$/); + const startsWithMatch = trimmed.match(/^([\w.]+)\.startsWith\("((?:\\.|[^"\\])*)"\)$/); if (startsWithMatch) { return { type: 'condition', @@ -321,7 +424,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field.contains("value") - const containsMatch = trimmed.match(/^(\w+)\.contains\("((?:\\.|[^"\\])*)"\)$/); + const containsMatch = trimmed.match(/^([\w.]+)\.contains\("((?:\\.|[^"\\])*)"\)$/); if (containsMatch) { return { type: 'condition', @@ -333,7 +436,7 @@ function parseCondition(expr: string): ConditionNode | null { } // Match patterns like: field in ["a", "b", "c"] - const inListMatch = trimmed.match(/^(\w+)\s+in\s+\[([^\]]*)\]$/); + const inListMatch = trimmed.match(/^([\w.]+)\s+in\s+\[([^\]]*)\]$/); if (inListMatch) { const listStr = inListMatch[2]; const items = listStr diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts index 11165a4062..9329235bda 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts @@ -81,4 +81,49 @@ describe("with admin access", () => { action: { type: "reject" }, }); }); + + it("evaluates risk score conditions", async ({ expect }) => { + await Project.createAndSwitch(); + await Project.updateConfig({ + "auth.signUpRules.block-high-bot-score": { + enabled: true, + displayName: "Block high bot score", + priority: 1, + condition: "riskScores.bot >= 80", + action: { + type: "reject", + message: "High bot risk", + }, + }, + "auth.signUpRulesDefaultAction": "allow", + }); + + const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", { + method: "POST", + accessType: "admin", + body: { + email: "risk@example.com", + auth_method: "password", + risk_scores: { + bot: 90, + free_trial_abuse: 10, + }, + }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + context: { + risk_scores: { + bot: 90, + free_trial_abuse: 10, + }, + }, + outcome: { + should_allow: false, + decision: "reject", + decision_rule_id: "block-high-bot-score", + }, + }); + }); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts new file mode 100644 index 0000000000..0def723e07 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/risk-scores.test.ts @@ -0,0 +1,569 @@ +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; +import { describe } from "vitest"; +import { it } from "../../../../helpers"; +import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../backend-helpers"; + +describe("risk scores", () => { + // ========================================== + // PERSISTENCE ON SIGNUP + // ========================================== + + describe("persistence on password signup", () => { + it("should persist non-zero risk scores for high-risk email (test@example.com stub)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(response.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${response.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 100, + free_trial_abuse: 100, + }, + }); + }); + + it("should persist zero risk scores for normal signups", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + expect(res.signUpResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + }); + + describe("persistence on OTP signup", () => { + it("should persist risk scores for OTP signup", async ({ expect }) => { + await Project.createAndSwitch({ + config: { magic_link_enabled: true }, + }); + + await Auth.Otp.signIn(); + + const meResponse = await niceBackendFetch("/api/v1/users/me", { + accessType: "server", + }); + expect(meResponse.status).toBe(200); + + expect(meResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + }); + + describe("persistence on OAuth signup", () => { + it("should persist risk scores for OAuth signup", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "spotify", type: "shared" }], + }, + }); + await InternalApiKey.createAndSetProjectKeys(); + + const response = await Auth.OAuth.signIn(); + expect(response.tokenResponse.status).toBe(200); + + const meResponse = await niceBackendFetch("/api/v1/users/me", { + accessType: "server", + }); + expect(meResponse.status).toBe(200); + + expect(meResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + }); + + describe("persistence on anonymous user conversion", () => { + it("should persist risk scores when converting anonymous user to password user", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const anonResponse = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", { + accessType: "client", + method: "POST", + body: {}, + }); + expect(anonResponse.status).toBe(200); + + const accessToken = anonResponse.body.access_token; + + const convertResponse = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + headers: { "x-stack-access-token": accessToken }, + body: { + email: `convert-${generateSecureRandomString(8)}@example.com`, + password: generateSecureRandomString(), + }, + }); + expect(convertResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${convertResponse.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + + it("should persist non-zero risk scores when converting anonymous user with high-risk email", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const anonResponse = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", { + accessType: "client", + method: "POST", + body: {}, + }); + expect(anonResponse.status).toBe(200); + + const accessToken = anonResponse.body.access_token; + + const convertResponse = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + headers: { "x-stack-access-token": accessToken }, + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(convertResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${convertResponse.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 100, + free_trial_abuse: 100, + }, + }); + }); + }); + + // ========================================== + // ANONYMOUS USERS + // ========================================== + + describe("anonymous users", () => { + it("should have zero risk scores for anonymous users (rules not evaluated)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const anonResponse = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", { + accessType: "client", + method: "POST", + body: {}, + }); + expect(anonResponse.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${anonResponse.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.status).toBe(200); + expect(userResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + }); + + // ========================================== + // SERVER API VISIBILITY + // ========================================== + + describe("API visibility", () => { + it("should include risk_scores in server user response", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const serverResponse = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "GET", + accessType: "server", + }); + + expect(serverResponse.status).toBe(200); + expect(serverResponse.body).toHaveProperty("risk_scores"); + expect(serverResponse.body.risk_scores).toHaveProperty("sign_up"); + expect(serverResponse.body.risk_scores.sign_up).toHaveProperty("bot"); + expect(serverResponse.body.risk_scores.sign_up).toHaveProperty("free_trial_abuse"); + }); + + it("should NOT include risk_scores in client user response", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Auth.Password.signUpWithEmail(); + + const clientResponse = await niceBackendFetch("/api/v1/users/me", { + accessType: "client", + }); + + expect(clientResponse.status).toBe(200); + expect(clientResponse.body).not.toHaveProperty("risk_scores"); + }); + + it("should include risk_scores in user list response (server access)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Auth.Password.signUpWithEmail(); + + const listResponse = await niceBackendFetch("/api/v1/users", { + accessType: "server", + }); + + expect(listResponse.status).toBe(200); + expect(listResponse.body.items.length).toBeGreaterThan(0); + expect(listResponse.body.items[0]).toHaveProperty("risk_scores"); + expect(listResponse.body.items[0].risk_scores.sign_up).toHaveProperty("bot"); + expect(listResponse.body.items[0].risk_scores.sign_up).toHaveProperty("free_trial_abuse"); + }); + }); + + // ========================================== + // SERVER-SIDE UPDATE + // ========================================== + + describe("server-side update", () => { + it("should allow server to update risk scores", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const updateResponse = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + risk_scores: { + sign_up: { + bot: 75, + free_trial_abuse: 30, + }, + }, + }, + }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 75, + free_trial_abuse: 30, + }, + }); + + const readResponse = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "GET", + accessType: "server", + }); + expect(readResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 75, + free_trial_abuse: 30, + }, + }); + }); + + it("should reject risk scores out of range (> 100)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const response = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + risk_scores: { + sign_up: { + bot: 150, + free_trial_abuse: 0, + }, + }, + }, + }); + + expect(response.status).toBe(400); + }); + + it("should reject risk scores out of range (< 0)", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const response = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + risk_scores: { + sign_up: { + bot: -10, + free_trial_abuse: 0, + }, + }, + }, + }); + + expect(response.status).toBe(400); + }); + + it("should reject non-integer risk scores", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const res = await Auth.Password.signUpWithEmail(); + + const response = await niceBackendFetch(`/api/v1/users/${res.userId}`, { + method: "PATCH", + accessType: "server", + body: { + risk_scores: { + sign_up: { + bot: 50.5, + free_trial_abuse: 0, + }, + }, + }, + }); + + expect(response.status).toBe(400); + }); + + it("should not change risk scores when updating other user fields", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(response.status).toBe(200); + + const userId = response.body.user_id; + + const updateResponse = await niceBackendFetch(`/api/v1/users/${userId}`, { + method: "PATCH", + accessType: "server", + body: { + display_name: "Updated Name", + }, + }); + expect(updateResponse.status).toBe(200); + + expect(updateResponse.body.display_name).toBe("Updated Name"); + expect(updateResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 100, + free_trial_abuse: 100, + }, + }); + }); + }); + + // ========================================== + // RISK SCORES + SIGN-UP RULES INTERACTION + // ========================================== + + describe("interaction with sign-up rules", () => { + it("should restrict user based on risk score CEL condition", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.restrict-high-bot': { + enabled: true, + displayName: 'Restrict high bot score', + priority: 0, + condition: 'riskScores.bot >= 80', + action: { type: 'restrict' }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + expect(response.status).toBe(200); + + const userResponse = await niceBackendFetch(`/api/v1/users/${response.body.user_id}`, { + method: "GET", + accessType: "server", + }); + + expect(userResponse.body.restricted_by_admin).toBe(true); + expect(userResponse.body.risk_scores.sign_up.bot).toBe(100); + }); + + it("should reject user based on risk score CEL condition", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.reject-high-risk': { + enabled: true, + displayName: 'Reject high risk', + priority: 0, + condition: 'riskScores.bot >= 80 && riskScores.freeTrialAbuse >= 80', + action: { type: 'reject' }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const response = await niceBackendFetch("/api/v1/auth/password/sign-up", { + method: "POST", + accessType: "client", + body: { + email: "test@example.com", + password: generateSecureRandomString(), + }, + }); + + expect(response.status).toBe(403); + expect(response.body.code).toBe("SIGN_UP_REJECTED"); + }); + + it("should allow user when risk score is below threshold", async ({ expect }) => { + await Project.createAndSwitch({ + config: { credential_enabled: true }, + }); + + await Project.updateConfig({ + 'auth.signUpRules.reject-high-bot': { + enabled: true, + displayName: 'Reject high bot score', + priority: 0, + condition: 'riskScores.bot >= 80', + action: { type: 'reject' }, + }, + 'auth.signUpRulesDefaultAction': 'allow', + }); + + const res = await Auth.Password.signUpWithEmail(); + expect(res.signUpResponse.status).toBe(200); + }); + }); + + // ========================================== + // SERVER-CREATED USERS + // ========================================== + + describe("server-created users", () => { + it("should default risk scores to 0 for server-created users", async ({ expect }) => { + const createResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: `server-created-${generateSecureRandomString(8)}@example.com`, + }, + }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 0, + free_trial_abuse: 0, + }, + }); + }); + + it("should allow setting risk scores when creating users via server API", async ({ expect }) => { + const createResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: `risky-${generateSecureRandomString(8)}@example.com`, + risk_scores: { + sign_up: { + bot: 55, + free_trial_abuse: 42, + }, + }, + }, + }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body.risk_scores).toEqual({ + sign_up: { + bot: 55, + free_trial_abuse: 42, + }, + }); + }); + }); +}); diff --git a/packages/stack-shared/src/interface/crud/users.ts b/packages/stack-shared/src/interface/crud/users.ts index cbeb164370..bd15329668 100644 --- a/packages/stack-shared/src/interface/crud/users.ts +++ b/packages/stack-shared/src/interface/crud/users.ts @@ -23,6 +23,12 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({ restricted_by_admin: fieldSchema.yupBoolean().optional().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), restricted_by_admin_reason: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), restricted_by_admin_private_details: fieldSchema.yupString().nullable().optional().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), + risk_scores: fieldSchema.yupObject({ + sign_up: fieldSchema.yupObject({ + bot: fieldSchema.yupNumber().integer().min(0).max(100).defined(), + free_trial_abuse: fieldSchema.yupNumber().integer().min(0).max(100).defined(), + }).defined(), + }).optional(), }).defined().test( "restricted_by_admin_consistency", "When restricted_by_admin is not true, reason and private_details must be null", @@ -64,6 +70,12 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({ restricted_by_admin: fieldSchema.yupBoolean().defined().meta({ openapiField: { description: 'Whether the user is restricted by an administrator. Can be set manually or by sign-up rules.', exampleValue: false } }), restricted_by_admin_reason: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Public reason shown to the user explaining why they are restricted. Optional.', exampleValue: null } }), restricted_by_admin_private_details: fieldSchema.yupString().nullable().defined().meta({ openapiField: { description: 'Private details about the restriction (e.g., which sign-up rule triggered). Only visible to server access and above.', exampleValue: null } }), + risk_scores: fieldSchema.yupObject({ + sign_up: fieldSchema.yupObject({ + bot: fieldSchema.yupNumber().min(0).max(100).integer().defined(), + free_trial_abuse: fieldSchema.yupNumber().min(0).max(100).integer().defined(), + }).defined(), + }).defined().meta({ openapiField: { description: 'User risk scores used for sign-up risk evaluation.', exampleValue: { sign_up: { bot: 0, free_trial_abuse: 0 } } } }), oauth_providers: fieldSchema.yupArray(fieldSchema.yupObject({ id: fieldSchema.yupString().defined(), @@ -107,6 +119,12 @@ export const usersCrudServerCreateSchema = usersCrudServerUpdateSchema.omit(['se email: fieldSchema.yupString().nullable().defined().default(null), }).defined()).optional().meta({ openapiField: { hidden: true } }), is_anonymous: fieldSchema.yupBoolean().optional(), + risk_scores: fieldSchema.yupObject({ + sign_up: fieldSchema.yupObject({ + bot: fieldSchema.yupNumber().integer().min(0).max(100).defined(), + free_trial_abuse: fieldSchema.yupNumber().integer().min(0).max(100).defined(), + }).defined(), + }).optional(), }).defined()); export const usersCrudServerDeleteSchema = fieldSchema.yupMixed(); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index c0f98f86ba..245f9f13ab 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -562,6 +562,12 @@ export class _StackServerAppImplIncomplete): Promise,