This document defines the security posture, known risks, and mitigation strategy for MetaSAAS. It is a living document — updated as the application evolves through each development phase.
Security is not a feature bolted on at the end. Every layer of MetaSAAS must be designed with the assumption that inputs are hostile and users are untrusted.
-
Defense in Depth — No single layer is trusted. Validation happens at the schema, the action bus, the database, and the UI. If one layer fails, the next catches it.
-
Fail Closed — When in doubt, deny. Missing permissions = deny. Invalid input = reject. Ambiguous state = error, not silent success.
-
Least Privilege — Every caller gets the minimum access needed. The default is no access. Permissions are explicitly granted, never implicitly assumed.
-
Secure by Default — New entities, actions, and endpoints inherit secure defaults. Developers must opt OUT of security, never opt IN.
-
Auditability — Every mutation is logged with who did it, when, and what changed. The audit trail is append-only and tamper-evident.
v0 is a development/demo environment. The following security controls are intentionally deferred but tracked here for implementation:
| Control | Status | Target Phase |
|---|---|---|
| Input validation (Zod) | Implemented | v0 (done) |
| SQL injection (migrate) | Mitigated (escape-based) | v0 (done) |
| Filter field whitelisting | Implemented | v0 (done) |
| UUID parameter validation | Implemented | v0 (done) |
| Foreign key constraints | Implemented (ON DELETE SET NULL) | v0 (done) |
| API contract consistency | Implemented (camelCase) | v0 (done) |
| Action ID validation | Implemented | v0 (done) |
| Authentication (JWT) | Implemented (Supabase + Dev) | v0 (done) |
| Authorization (RBAC) | Implemented | v0 (done) |
| Frontend auth guard | Implemented | v0 (done) |
| JWT token forwarding | Implemented | v0 (done) |
| Tenant data isolation | Implemented (tenant_id on all tables, all queries scoped) | v0 (done) |
| HTTP status codes | Implemented (400/403/404/422/500 mapping) | v0 (done) |
| AI Gateway security | Implemented (multi-provider, server-side only, Zod output parsing, fallbacks) | v0 (done) |
| AI Command interpreter | Implemented (input length limit, dispatches through Action Bus, no security bypass) | v0 (done) |
| Side effects processing | Implemented (fire-and-forget, never break actions, typed effect handlers) | v0 (done) |
| Auth integration tested | Verified (Supabase JWT flow: signup, login, token forwarding, tenant scoping) | v0 (done) |
| Env var isolation | Implemented (monorepo root .env, explicit forwarding to Next.js client bundle) | v0 (done) |
| Domain event subscribers | Implemented (EventBus → subscriber pattern, error isolation) | v0 (done) |
| CORS preflight handling | Implemented (OPTIONS bypass in auth middleware) | v0 (done) |
| Chat session persistence | Implemented (platform DB tables, tenant-scoped) | v0 (done) |
| SSE streaming endpoint | Implemented (text/event-stream with typed events) | v0 (done) |
| Chat history validation | Implemented (role whitelist, length limits, max 10 messages) | v0 (done) |
| Rate limiting | Not started | v1 |
| CSRF protection | Not started | v1 |
| Input size limits | Fastify default (1MB) | v1 |
| CORS lockdown | Allow-all | v1 |
| Audit logging | Console only | v1 |
| Encryption at rest | Not started | v2 |
| Field-level encryption | Not started | v2 |
File: packages/platform/src/core/database/migrate.ts
Original Issue: Entity defaultValue was string-interpolated directly into
raw SQL, allowing arbitrary SQL execution via malformed defaults.
Current State (v0): Mitigated with defense-in-depth:
validateIdentifier()rejects table/column names with unsafe characters.buildDefaultClause()type-validates defaults (boolean, numeric, string, date) and escapes single quotes ('→'').- Suspicious string patterns containing SQL control characters are rejected.
Residual Risk: The overall CREATE TABLE statement still uses pgSql.unsafe()
with concatenated identifiers and escaped values. While identifiers are validated
and values are escaped, a parameterized migration API would eliminate this class
of risk entirely.
Status: Mitigated — escape-based. Parameterized migration is the long-term fix.
Files:
packages/platform/src/adapters/rest/auth-middleware.ts(Fastify preHandler)packages/platform/src/auth/(AuthProvider implementations)packages/platform/src/core/action-bus/middleware/permission.ts(RBAC)packages/contracts/src/auth.ts(AuthProvider contract)apps/web/src/lib/auth-context.tsx(Frontend auth)apps/web/src/lib/api-client.ts(JWT token forwarding)
Original Issue: All API endpoints used a hardcoded DEFAULT_CALLER with admin
privileges. The permission middleware (checkPermission) always returned true.
Current State (v0): Fixed with a multi-layer authentication and authorization system:
-
AuthProvider Contract (
AuthProviderinterface in@metasaas/contracts):- Defines a swappable authentication abstraction (Dependency Inversion).
- Two implementations:
SupabaseAuthProvider(production) andDevAuthProvider(local dev). - Swapping providers requires changing only the bootstrap code — no middleware, action, or frontend changes.
-
Fastify Auth Middleware (
auth-middleware.ts):- Runs as a global
preHandlerhook on every request. - Extracts Bearer token from the
Authorizationheader. - Verifies the token via the active
AuthProvider. - Attaches the authenticated
Callertorequest.caller. - Public routes (e.g.,
/api/auth/config) are bypassed. - Returns HTTP 401 for invalid/missing tokens when auth is enabled.
- Runs as a global
-
Real RBAC (
permission.ts):checkPermissionevaluatesPermissionRule[]against theCaller's roles and type.- Supports
callerTypesmatching (e.g., only"human"callers). - Supports role-based matching (e.g.,
"admin","viewer"). - Default deny: if no rules match, access is denied.
- First matching rule wins (allow or deny).
-
Frontend Auth (
auth-context.tsx,api-client.ts):AuthProviderReact context manages session state, login, logout, token refresh.setTokenProvider()bridges the auth context to the API client.- Every API request includes
Authorization: Bearer <token>when a token is available. - Auth guard on
(app)layout redirects to/loginwhen auth is enabled and user is not authenticated. - Login and signup pages with proper form validation and error handling.
-
Dev Mode Fallback:
- When Supabase is not configured,
DevAuthProviderreturns a hardcoded admin caller. - Frontend detects dev mode and grants unrestricted dashboard access.
- No tokens required in development — zero friction for local development.
- When Supabase is not configured,
Remaining Work (v1):
- Tenant scoping: all database queries filtered by
Caller.tenantId. - Ownership-based permissions (
ownerfield inPermissionRule). - Token refresh and session expiry handling in the frontend.
- Password reset flow.
Status: Resolved for v0.
File: packages/platform/src/adapters/rest/adapter.ts
Original Issue: The REST adapter spread all unrecognized query parameters
into a where filter, allowing probing of system columns.
Current State (v0): Fixed.
- Filter fields are whitelisted against
EntityDefinition.fields. - Unknown filter parameters return HTTP 400 with the list of allowed fields.
orderByis also validated against the entity's field list.- URL
:idparameters are validated as UUID format before dispatch. - Action IDs are validated against a safe format pattern.
Status: Resolved.
File: packages/platform/src/core/database/migrate.ts
Original Issue: belongsTo relationships created UUID columns without
REFERENCES constraints.
Current State (v0): Fixed.
belongsTorelationships generateREFERENCES {parent_table}(id)constraints.ON DELETE SET NULLis the default behavior (configurable per relationship in v1).- Foreign key column names are validated with
validateIdentifier().
Remaining Work (v1):
- Configurable
ON DELETEbehavior per relationship (CASCADE, SET NULL, RESTRICT). - Integration tests that verify referential integrity enforcement.
Status: Resolved for v0.
Issue: No explicit payload size limits beyond Fastify's 1MB default. No rate limiting on any endpoint.
Impact: Denial of service via large payloads or request flooding.
Mitigation (v1):
- Configure explicit
bodyLimiton Fastify (e.g., 256KB for CRUD, larger for file upload). - Add
@fastify/rate-limitwith per-IP and per-tenant limits. - Implement request timeout configuration.
File: apps/api/src/index.ts
Issue: origin: true allows any domain to make API requests.
Mitigation (v1):
- Restrict to
web.urlfrom config in production. - Use environment-based CORS configuration.
- All Zod schemas MUST use
.strict()mode in production to reject unknown fields. - Enum fields MUST validate against the declared options list.
- String fields MUST have maximum length constraints.
- Numeric fields MUST have range constraints where applicable.
- Email fields MUST validate format (Zod
.email()— already implemented). - URL fields MUST validate format (Zod
.url()— already implemented).
Action Bus:
- Every action MUST have an
inputSchema(enforced by TypeScript). - Validation MUST run before permission check (prevent information leakage).
- Unknown errors MUST NOT leak stack traces to the caller (implemented).
- Action IDs MUST be validated against a whitelist of registered actions.
Database:
- NEVER use
sql.unsafe()with interpolated values. - All query parameters MUST be parameterized via Drizzle's query builder.
- Database credentials MUST NOT appear in logs or error messages.
- Connection strings MUST use SSL in production.
- Table and column names MUST be validated against a safe character set.
Entity Manager:
- Entity names MUST be alphanumeric + underscore only.
- Field names MUST be alphanumeric + underscore only.
- Reserved SQL keywords MUST NOT be used as entity or field names.
- All mutation endpoints MUST validate input via the Action Bus (implemented).
- URL parameters (
:id) MUST be validated as UUID format before dispatch. - Query parameters MUST be whitelisted per entity.
- Response bodies MUST NOT include internal error details in production.
- Health check MUST verify database connectivity.
- User input MUST be sanitized before rendering (React handles this by default).
- API errors MUST be displayed safely (no raw HTML rendering).
- Form submissions MUST be debounced to prevent duplicate mutations.
- Destructive actions MUST require explicit confirmation.
- URL parameters MUST be validated before use in API calls.
- Sensitive fields (marked
sensitive: true) MUST NOT appear in URL state or logs.
Every security control above MUST have a corresponding test. Tests are organized by risk level:
- SQL injection: default values with SQL metacharacters are rejected or escaped.
- Zod schemas reject malformed input for every field type.
- Unknown fields in request bodies are stripped (not passed to database).
- Invalid UUID in URL parameters returns 400, not 500.
- Unknown filter fields in query parameters return 400.
- API error responses do not leak stack traces.
- Oversized request bodies are rejected (Fastify default 1MB; explicit limits in v1).
- Authentication middleware rejects requests without valid tokens (when auth is enabled).
- Authorization middleware denies access based on role (
checkPermissionwith real RBAC). - Tenant A cannot access Tenant B's data.
- Rate limiting returns 429 for excessive requests.
- CORS rejects requests from non-whitelisted origins.
- CSRF tokens are required for mutation requests.
- Sensitive fields are encrypted at rest.
- Audit log captures all mutations with caller identity.
- Session tokens expire and refresh correctly.
- Password hashing uses bcrypt/argon2 with appropriate cost factor.
If a security vulnerability is discovered:
- Assess — Determine severity (CRITICAL/HIGH/MEDIUM/LOW).
- Contain — If actively exploited, disable the affected endpoint.
- Fix — Patch the vulnerability with a test that prevents regression.
- Verify — Run the full security test suite.
- Document — Update this file with the vulnerability and fix.
- Review — Conduct a post-mortem to prevent similar issues.
Status: Fully built and tested.
What was built:
- Multi-provider AI Gateway (Gemini, OpenAI, Anthropic) with auto-detection
- AI Capability declarations on entities with auto-triggers (on_create, on_update)
- AI Command Interpreter (Cmd+K) — natural language → Action Bus dispatch
- All AI operations go through the Action Bus (logged, permissioned, auditable)
- Zod schema validation on all AI responses
- Graceful fallback on any AI failure (app always works without AI)
- Command input length-limited to 2000 chars to mitigate prompt injection
- API keys server-side only (never exposed to frontend)
Status: Designed, not yet built.
What it is: A git-based strategy for pulling platform updates from an upstream template repository without conflicting with domain-layer customizations. The "Living Architecture" concept.
Why deferred: This is an operational concern (git remote tracking, merge strategies, conflict resolution) rather than an architectural one. The contract/platform/domain package separation already makes it structurally possible — domain code only imports from contracts, so platform internals can change independently.
Prep work completed:
- Clean package boundaries verified (no violations found)
- Domain imports only from
@metasaas/contracts - Platform imports only from
@metasaas/contracts - Turborepo workspace structure supports independent package versioning
Prerequisites for implementation: A stable v1 release to serve as the first "upstream" version that forks are tested against.