stack/apps/backend/src/lib/sign-up-rules.ts
Mantra bb277d33c9
Backend fallback (cloud run) (#1306)
- Added support for `@opentelemetry/sdk-node` in the backend.
- Updated various dependencies including AWS SDK and OpenTelemetry
packages.
- Implemented graceful shutdown handling for non-Vercel runtimes in
`prisma-client.tsx`.
- Enhanced AWS credentials retrieval to support GCP Workload Identity
Federation.
- Introduced a Dockerfile for Cloud Run deployment, optimizing the
backend build process.
- Updated `.gitignore` to include Terraform runtime files and secrets.

This commit improves the backend's observability and deployment
flexibility, particularly for Cloud Run environments.

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* OpenTelemetry observability with dynamic provider selection per
deployment.
  * Cloud Run trusted-proxy support for accurate client IP handling.
  * Graceful shutdown that waits for in-flight background work.
* New background-task handling to improve async webhook/email delivery
reliability.
* AWS credential providers added (Vercel OIDC & GCP Workload Identity
Federation).
  * Dockerized backend image for Cloud Run / self-host deployments.

* **Chores**
  * Updated dependencies for OpenTelemetry and AWS SDK support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
2026-04-11 00:57:37 +00:00

205 lines
6.2 KiB
TypeScript

import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
import type { SignUpRule, SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules";
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { CelEvaluationError, evaluateCelExpression, SignUpRuleContext } from "./cel-evaluator";
import { logEvent, SystemEventTypes } from "./events";
import { Tenancy } from "./tenancies";
/**
* Logs a sign-up rule trigger as a ClickHouse event for analytics.
* This runs asynchronously and doesn't block the signup flow.
*/
async function logRuleTrigger(
tenancy: Tenancy,
ruleId: string,
context: SignUpRuleContext,
action: SignUpRuleAction,
): Promise<void> {
try {
await logEvent([SystemEventTypes.SignUpRuleTrigger], {
projectId: tenancy.project.id,
branchId: tenancy.branchId,
ruleId,
action: action.type,
email: context.email,
authMethod: context.authMethod,
oauthProvider: context.oauthProvider,
});
} catch (e) {
// Don't fail the signup if logging fails
captureError(`sign-up-rule-trigger-log-error`, new StackAssertionError(`Failed to log sign-up rule trigger for rule ${ruleId}`, { cause: e }));
}
}
/**
* Evaluates all sign-up rules for a tenancy against the given context.
* Rules are evaluated in order of priority (highest first), then alphabetically by ID.
* Returns the first matching rule's action, or the default action if no rules match.
*
* This function should be called from all signup paths:
* - Password signup
* - OTP signup
* - OAuth signup
* - Passkey signup
* - Anonymous user conversion (when anonymous user adds email/auth method)
*
* This function should NOT be called when creating anonymous users.
*
* @param tenancy - The tenancy to evaluate rules for
* @param context - The signup context with email, authMethod, etc.
* @returns The rule result with action to take
*/
export async function evaluateSignUpRules(
tenancy: Tenancy,
context: SignUpRuleContext
) {
const result = evaluateSignUpRulesInternal(tenancy, context, { includeEvaluations: false, logTriggers: true });
return {
restrictedBecauseOfSignUpRuleId: result.outcome.restrictedBecauseOfSignUpRuleId,
shouldAllow: result.outcome.shouldAllow,
};
}
export type SignUpRuleEvaluationStatus =
| 'matched'
| 'not_matched'
| 'disabled'
| 'missing_condition'
| 'error';
export type SignUpRuleEvaluation = {
ruleId: string,
rule: SignUpRule,
status: SignUpRuleEvaluationStatus,
error?: string,
};
export type SignUpRulesTraceResult = {
evaluations: SignUpRuleEvaluation[],
outcome: {
shouldAllow: boolean,
decision: 'allow' | 'reject' | 'default-allow' | 'default-reject',
decisionRuleId: string | null,
restrictedBecauseOfSignUpRuleId: string | null,
},
};
/**
* Evaluates all sign-up rules and returns a trace of evaluations.
* This is used for admin testing and does not log any analytics events.
*/
export function evaluateSignUpRulesWithTrace(
tenancy: Tenancy,
context: SignUpRuleContext
): SignUpRulesTraceResult {
return evaluateSignUpRulesInternal(tenancy, context, { includeEvaluations: true, logTriggers: false });
}
function evaluateSignUpRulesInternal(
tenancy: Tenancy,
context: SignUpRuleContext,
options: { includeEvaluations: boolean, logTriggers: boolean }
): SignUpRulesTraceResult {
const config = tenancy.config;
const evaluations: SignUpRuleEvaluation[] = [];
let restrictedBecauseOfSignUpRuleId: string | null = null;
const recordEvaluation = options.includeEvaluations
? (evaluation: SignUpRuleEvaluation) => {
evaluations.push(evaluation);
}
: () => {};
for (const [ruleId, rule] of typedEntries(config.auth.signUpRules)) {
const isEnabled = rule.enabled === true;
if (!isEnabled) {
recordEvaluation({
ruleId,
rule,
status: 'disabled',
});
continue;
}
if (!rule.condition) {
recordEvaluation({
ruleId,
rule,
status: 'missing_condition',
});
continue;
}
let matches = false;
let status: SignUpRuleEvaluationStatus = 'not_matched';
let error: string | undefined;
try {
matches = evaluateCelExpression(rule.condition, context);
status = matches ? 'matched' : 'not_matched';
} catch (e) {
if (e instanceof CelEvaluationError) {
status = 'error';
error = e.message;
// technically a custom config could cause this, but the dashboard shouldn't allow creating faulty configs
// so for now, let's capture an error so we know that something is probably wrong on the DB
captureError(`cel-evaluation-error:${ruleId}`, new StackAssertionError(`CEL evaluation error for rule ${ruleId}`, { cause: e }));
} else {
throw e;
}
}
recordEvaluation({
ruleId,
rule,
status,
...(error ? { error } : {}),
});
if (matches) {
const actionConfig = rule.action;
const actionType = actionConfig.type;
const action: SignUpRuleAction = {
type: actionType,
message: actionConfig.message,
};
if (options.logTriggers) {
// log asynchronously
runAsynchronouslyAndWaitUntil(logRuleTrigger(tenancy, ruleId, context, action));
}
// apply the action
if (actionType === 'restrict') {
// Only record the first restrict rule (highest priority)
if (restrictedBecauseOfSignUpRuleId === null) {
restrictedBecauseOfSignUpRuleId = ruleId;
}
}
if (actionType === 'allow' || actionType === 'reject') {
const outcome = {
restrictedBecauseOfSignUpRuleId,
shouldAllow: actionType === 'allow',
decision: actionType,
decisionRuleId: ruleId,
};
return {
evaluations,
outcome,
};
}
}
}
const shouldAllow = config.auth.signUpRulesDefaultAction !== 'reject';
const outcome = {
restrictedBecauseOfSignUpRuleId,
shouldAllow,
decision: shouldAllow ? 'default-allow' as const : 'default-reject' as const,
decisionRuleId: null,
};
return {
evaluations,
outcome,
};
}