Checkstack provides a comprehensive Teams system for organizing users and controlling access to resources. Teams enable:
This system complements the existing role-based access control (RBAC) by adding resource-level granularity.
| Concept | Description |
|---|---|
| Team | A named group of users with optional description |
| Team Member | A user belonging to a team |
| Team Manager | A user who can manage team membership and settings |
| Resource Grant | An access entry linking a team to a specific resource |
The teams system uses five tables in the auth-backend schema:
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ team │ │ userTeam │ │ resourceTeamAccess │
├──────────────┤ ├──────────────┤ ├──────────────────────┤
│ id (PK) │────▶│ teamId (FK) │ │ resourceType (PK) │
│ name │ │ userId (FK) │ │ resourceId (PK) │
│ description │ └──────────────┘ │ teamId (PK, FK) │
│ createdAt │ │ canRead │
│ updatedAt │ ┌──────────────┐ │ canManage │
└──────────────┘ │ teamManager │ └──────────────────────┘
├──────────────┤
│ teamId (FK) │ ┌─────────────────────────┐
│ userId (FK) │ │ resourceAccessSettings │
└──────────────┘ ├─────────────────────────┤
│ resourceType (PK) │
┌──────────────────┐ │ resourceId (PK) │
│ applicationTeam │ │ teamOnly │
├──────────────────┤ └─────────────────────────┘
│ applicationId │
│ teamId (FK) │
└──────────────────┘
Note: The teamOnly setting is stored at the resource level in resourceAccessSettings, not per-grant. This allows enabling “Team Only” mode for a resource without associating it with any specific team grant.
When a user authenticates, their team memberships are automatically loaded and included in their identity:
interface RealUser {
type: "user";
id: string;
accessRules: string[];
roles: string[];
teamIds: string[]; // All teams the user belongs to
}
interface ApplicationUser {
type: "application";
id: string;
name: string;
accessRules: string[];
teamIds: string[]; // Teams the application is assigned to
}
This enrichment happens in:
auth-backend/src/utils/user.ts → enrichUser() for real usersauth-backend/src/index.ts → Application authentication for API keysAll team endpoints require the auth.teams.manage access rule unless noted.
getTeamsLists all teams with member count and manager status for the current user.
// Returns
{
id: string;
name: string;
description: string | null;
memberCount: number;
isManager: boolean; // Current user is a manager of this team
}[]
getTeamGets detailed information about a specific team including members.
// Input
{ id: string }
// Returns
{
id: string;
name: string;
description: string | null;
members: { userId: string; isManager: boolean }[];
createdAt: Date;
updatedAt: Date;
} | undefined
createTeamCreates a new team. The creating user is automatically added as a manager.
// Input
{
name: string;
description?: string;
}
// Returns
{ id: string; name: string }
updateTeamUpdates team name or description.
// Input
{
id: string;
name?: string;
description?: string;
}
deleteTeamDeletes a team and all associated grants (via database cascade).
// Input
{ id: string }
addUserToTeamAdds a user to a team.
// Input
{ teamId: string; userId: string }
removeUserFromTeamRemoves a user from a team.
// Input
{ teamId: string; userId: string }
addTeamManagerGrants manager privileges to a team member.
// Input
{ teamId: string; userId: string }
removeTeamManagerRevokes manager privileges from a team member.
// Input
{ teamId: string; userId: string }
getResourceTeamAccessLists teams with access to a specific resource.
// Input
{ resourceType: string; resourceId: string }
// Returns
{
teamId: string;
teamName: string;
canRead: boolean;
canManage: boolean;
}[]
setResourceTeamAccessGrants or updates team access to a resource (upsert).
// Input
{
resourceType: string;
resourceId: string;
teamId: string;
canRead?: boolean; // Default: true
canManage?: boolean; // Default: false
}
removeResourceTeamAccessRevokes team access from a resource.
// Input
{ resourceType: string; resourceId: string; teamId: string }
getResourceAccessSettingsGets resource-level access settings (e.g., teamOnly mode).
// Input
{ resourceType: string; resourceId: string }
// Returns
{ teamOnly: boolean }
setResourceAccessSettingsUpdates resource-level access settings.
// Input
{
resourceType: string;
resourceId: string;
teamOnly: boolean; // If true, global access don't apply
}
These endpoints are called by the autoAuthMiddleware for access control checks.
checkResourceAccessChecks if a user has access to a specific resource.
// Input
{
resourceType: string;
resourceId: string;
userId: string;
teamIds: string[];
checkManage?: boolean;
}
// Returns
{ hasAccess: boolean }
getAccessibleResourceIdsFilters a list of resource IDs to those the user can access.
// Input
{
resourceType: string;
resourceIds: string[];
userId: string;
teamIds: string[];
}
// Returns
{ accessibleIds: string[] }
The RLAC system uses metadata on RPC procedures to declare access requirements:
// In contract definition (e.g., catalog-common/src/rpc-contract.ts)
import { createResourceAccess, createResourceAccessList } from "@checkstack/common";
// Resource types are auto-prefixed with pluginId by the middleware
// Just use the resource name, not the fully qualified type
const systemAccess = createResourceAccess("system", "systemId");
const systemListAccess = createResourceAccessList("system", "systems");
export const catalogContract = {
// Single resource with access check
getSystem: _base
.meta({
userType: "user",
access: [access.read.id],
resourceAccess: [systemAccess], // Array of resource access configs
})
.input(z.object({ systemId: z.string() }))
.output(SystemSchema.optional()),
// List with automatic filtering
getSystems: _base
.meta({
userType: "user",
access: [access.read.id],
resourceAccess: [systemListAccess],
})
.output(z.object({ systems: z.array(SystemSchema) })),
};
| Mode | Property | Description | Implementation |
|---|---|---|---|
single |
idParam |
Pre-handler check for individual resource | Validates access before handler runs, throws 403 if denied |
list |
listKey |
Post-handler filter for collections | Filters response array to only accessible resources |
record |
recordKey |
Post-handler filter for bulk records | Filters Record<resourceId, data> to only accessible keys |
Note:
resourceAccessis an array, so you can specify multiple resource access configs if an endpoint needs to check access to multiple resource types.
For endpoints that return data keyed by resource IDs (e.g., getBulkSystemHealthStatus), use recordKey to filter the output record:
// Access rule with recordKey
const bulkStatusAccess = access("healthcheck.status", "read", "View status", {
recordKey: "statuses", // Key in response containing Record<systemId, data>
isPublic: true,
});
// Contract definition
getBulkSystemHealthStatus: _base
.meta({
userType: "public",
access: [bulkStatusAccess],
})
.input(z.object({ systemIds: z.array(z.string()) }))
.output(z.object({
statuses: z.record(z.string(), HealthStatusSchema),
})),
The middleware automatically filters the statuses record, removing keys the user doesn’t have access to.
| Access | Description |
|---|---|
canRead |
User can view the resource |
canManage |
User can modify the resource |
teamOnly |
Only team members can access (disables global access) |
When checking access to a resource:
resourceTeamAccess entries matching (resourceType, resourceId)teamOnly is set on any grant, only team-based access is allowedcheckManage is true, verify the grant includes canManage// Pseudocode for access resolution
function checkAccess(user, resourceType, resourceId, checkManage) {
const grants = getGrants(resourceType, resourceId);
if (grants.length === 0) {
// No restrictions - allow anyone with access
return true;
}
// Check team-based grants
const userTeamGrants = grants.filter(g => user.teamIds.includes(g.teamId));
for (const grant of userTeamGrants) {
if (checkManage && !grant.canManage) continue;
if (!checkManage && !grant.canRead) continue;
return true; // Access granted
}
return false; // No matching grant found
}
// plugins/myplugin-common/src/rpc-contract.ts
import { createResourceAccess, createResourceAccessList } from "@checkstack/common";
// Use simple resource names - the middleware auto-prefixes with "myplugin."
const itemAccess = createResourceAccess("item", "id");
const itemListAccess = createResourceAccessList("item", "items");
export const myPluginContract = {
getItem: _base
.meta({
userType: "user",
access: [access.itemRead.id],
resourceAccess: [itemAccess], // Must be an array
})
.input(z.object({ id: z.string() }))
.output(ItemSchema),
listItems: _base
.meta({
userType: "user",
access: [access.itemRead.id],
resourceAccess: [itemListAccess],
})
.output(z.object({ items: z.array(ItemSchema) })),
};
List endpoints must return an object with the array under a named key:
// ❌ Before (array directly)
return items;
// ✅ After (object with named key)
return { items };
This is required for the middleware to identify and filter the correct array.
// In your editor component
import { TeamAccessEditor } from "@checkstack/auth-frontend";
export const ItemEditor = ({ item }) => {
return (
<Dialog>
{/* ... form fields ... */}
{/* Only show for existing items */}
{/* Note: Frontend uses fully qualified type since there's no middleware context */}
{item?.id && (
<TeamAccessEditor
resourceType="myplugin.item"
resourceId={item.id}
compact
expanded
/>
)}
</Dialog>
);
};
Add @checkstack/auth-frontend to your frontend package:
{
"dependencies": {
"@checkstack/auth-frontend": "workspace:*"
}
}
The teams system defines these access rules:
| Access Rule ID | Description | Default |
|---|---|---|
auth.teams.read |
View teams and membership | ✓ |
auth.teams.manage |
Create, update, delete teams and manage membership |
In backend contracts, use simple resource names without the plugin prefix - the middleware auto-qualifies them:
// ✅ Backend: Use simple name (auto-prefixed to "catalog.system")
const systemAccess = createResourceAccess("system", "systemId");
In frontend components, use the fully qualified type since there’s no middleware context:
// ✅ Frontend: Use fully qualified type
<TeamAccessEditor resourceType="catalog.system" resourceId={id} />
Stored values in the database are always fully qualified:
catalog.systemhealthcheck.configurationincident.incidentmaintenance.maintenanceWhen a team is deleted, all resourceTeamAccess grants are automatically deleted via database cascade (ON DELETE CASCADE).
When testing RLAC in your plugin:
// Create test user with team membership
const user = {
type: "user",
id: "test-user",
access: [access.itemRead],
roles: ["users"],
teamIds: ["team-1"],
};
// Mock the auth service for access checks
const mockAuth = {
checkResourceTeamAccess: mock(() => Promise.resolve({ hasAccess: true })),
getAccessibleResourceIds: mock(() =>
Promise.resolve({ accessibleIds: ["item-1", "item-2"] })
),
};
Check that:
teamOnly set on the resourceVerify:
{ keyName: [...] }, not an array directlyresultKey in createResourceAccessList matches the response keyid fieldEnsure:
auth.teams.manage access to assign accesscatalog.system, not just system)