As LogicSpike moves from a "User-glued-to-one-Tenant" model to a decoupled "Identity-with-multiple-Memberships" model, our foundational internal NPM packages (@repo/core-types, @repo/core-auth, @repo/core-access, @repo/core-tenant) require a significant type overhaul.
This document serves as a guide for developers to understand why the types are changing and how the new models interact.
1. Decoupling Identity: @repo/core-types
The Old World
Previously, an authenticated user's token directly answered: "What role are they?"
export interface User {
id: UserId
tenant_id: TenantId
role: UserRole // 'owner', 'admin', etc.
}Problem: This schema prevents a user from existing in two workspaces simultaneously because the tenant_id and role are hardcoded to the human identity. If they join another workspace, their role might be completely different.
The New World
Identity (User) is now completely separate from Authorization (Membership and Role).
-
User(Identity) Represents the human being globally across the platform.export interface User { id: UserId email: string name: string default_tenant_id: TenantId phone?: string phone_verified: boolean is_two_factor_enabled: boolean two_factor_method?: "email" | "phone" | "totp" }Notice:
roleandtenant_idare gone from the core definition.default_tenant_idis just a UX convenience. -
Membership(The Link) Represents access to a specific workspace.export interface Membership { id: string user_id: UserId tenant_id: TenantId role_id: string is_owner: boolean // True ONLY for the workspace creator } -
Role&Permission(RBAC) Roles are now dynamic collections of permissions.export interface Role { id: string tenant_id: TenantId | null // null = Global System Role (e.g., "Default Admin") name: string permissions: string[] // e.g., ["blog:posts.update", "team:members.invite"] }
2. Token Claims (JWTs)
When a user logs in, the Manager service looks at their Membership for the selected Tenant, unpacks the Role, and embeds the final flattened permissions directly into the short-lived JWT token.
New JWTClaims Interface
// @repo/core-types/src/auth.ts
export interface JWTClaims {
user_id: UserId
tenant_id: TenantId
// The crucial change:
permissions: string[]
services: Partial<Record<ServiceCode, ServiceAccess>>
}Microservices (like Blog or Media) no longer care what the user's role is named. They only check if the permissions array contains what they need.
3. Authorization Engine: @repo/core-access
The core access engine will be refactored to verify intent against the token's permissions array.
Old Pattern (Role-Based)
// BAD: Roles are rigid. What if a "Moderator" should also delete?
if (token.role !== "admin" && token.role !== "owner") {
throw new UnauthorizedError()
}New Pattern (Permission-Based)
// packages/core-access/src/access.rules.ts
export function assertUserHasPermission(token: JWTClaims, requiredPermission: string) {
// 1. Owners bypass everything
if (token.permissions.includes("system:owner")) {
return true
}
// 2. Exact match
if (!token.permissions.includes(requiredPermission)) {
throw new UnauthorizedError(`Requires permission: ${requiredPermission}`)
}
return true
}Usage in Microservices:
// Inside apps/blog-service/src/routes/posts.ts
app.delete("/:id", (c) => {
assertUserHasPermission(c.get("jwtPayload"), "blog:posts.delete");
// proceed to delete...
})4. Upgrading Cryptography: @repo/core-auth
As we move toward a true decentralized microservice architecture (where Gateway, Blog, Media, Manager, etc. run completely isolated), sharing the symmetric JWT_SECRET everywhere becomes a major security risk.
If the Media service is compromised, the attacker steals the JWT_SECRET and can forge tokens to access the Billing service.
Asymmetric Keys (RS256)
We are refactoring core-auth/src/jwt.ts to use Private/Public Key pairs (RS256).
- Manager Service (Identity Provider):
Holds the Private Key. It is the ONLY service capable of generating and signing tokens (
signAccessToken). - All Other Services:
Hold the Public Key. They can seamlessly verify the token's authenticity (
verifyAccessToken) but cannot create new spoofed tokens.
// core-auth/src/jwt.ts
export async function signAccessToken(payload: JWTClaims, privateKey: string) {
return sign(payload, privateKey, "RS256")
}
export async function verifyAccessToken(token: string, publicKey: string) {
return verify(token, publicKey, "RS256")
}5. Security Summary Checklist
When reviewing the refactored code in the future, ensure these invariants hold:
- No database schema should attach a
roledirectly to auser. - Microservices should never query the database for a user's role. They trust the
permissionsarray embedded inside the signed JWT. - Only the Manager service has the
PRIVATE_KEYinitialized in its environment variables. -
core-accesslogic strictly evaluatesservice:resource.actionstrings (likebilling:account.manage).