mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
- 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>
205 lines
6.2 KiB
TypeScript
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,
|
|
};
|
|
}
|