Skip to content

Teams and Resource-Level Access Control

Checkstack provides a comprehensive Teams system for organizing users and controlling access to resources. Teams enable:

  • Group Management: Organize users into logical groups (e.g., “Platform Team”, “API Developers”)
  • Resource-Level Access Control (RLAC): Grant teams specific access on individual resources
  • Granular Access Rules: Support for read, manage, and exclusive access modes

This system complements the existing role-based access control (RBAC) by adding resource-level granularity.

ConceptDescription
TeamA named group of users with optional description
Team MemberA user belonging to a team
Team ManagerA user who can manage team membership and settings
Resource GrantAn 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.tsenrichUser() for real users
  • auth-backend/src/index.ts → Application authentication for API keys

All team endpoints require the auth.teams.manage access rule unless noted.

Lists 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
}[]

Gets 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

Creates a new team. The creating user is automatically added as a manager.

// Input
{
name: string;
description?: string;
}
// Returns
{ id: string; name: string }

Updates team name or description.

// Input
{
id: string;
name?: string;
description?: string;
}

Deletes a team and all associated grants (via database cascade).

// Input
{ id: string }

Adds a user to a team.

// Input
{ teamId: string; userId: string }

Removes a user from a team.

// Input
{ teamId: string; userId: string }

Grants manager privileges to a team member.

// Input
{ teamId: string; userId: string }

Revokes manager privileges from a team member.

// Input
{ teamId: string; userId: string }

Lists teams with access to a specific resource.

// Input
{ resourceType: string; resourceId: string }
// Returns
{
teamId: string;
teamName: string;
canRead: boolean;
canManage: boolean;
}[]

Grants or updates team access to a resource (upsert).

// Input
{
resourceType: string;
resourceId: string;
teamId: string;
canRead?: boolean; // Default: true
canManage?: boolean; // Default: false
}

Revokes team access from a resource.

// Input
{ resourceType: string; resourceId: string; teamId: string }

Gets resource-level access settings (e.g., teamOnly mode).

// Input
{ resourceType: string; resourceId: string }
// Returns
{ teamOnly: boolean }

Updates 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.

Checks if a user has access to a specific resource.

// Input
{
resourceType: string;
resourceId: string;
userId: string;
teamIds: string[];
checkManage?: boolean;
}
// Returns
{ hasAccess: boolean }

Filters 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) })),
};
ModePropertyDescriptionImplementation
singleidParamPre-handler check for individual resourceValidates access before handler runs, throws 403 if denied
listlistKeyPost-handler filter for collectionsFilters response array to only accessible resources
recordrecordKeyPost-handler filter for bulk recordsFilters Record<resourceId, data> to only accessible keys

Note: resourceAccess is 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.

AccessDescription
canReadUser can view the resource
canManageUser can modify the resource
teamOnlyOnly team members can access (disables global access)

When checking access to a resource:

  1. Check for grants: Look for resourceTeamAccess entries matching (resourceType, resourceId)
  2. If no grants exist: Resource is unrestricted, allow access if user has the required access rule
  3. If grants exist:
    • Check if user is in any team with access
    • If teamOnly is set on any grant, only team-based access is allowed
    • If checkManage 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
}

Step 1: Add Resource Access Metadata to Contracts

Section titled “Step 1: Add Resource Access Metadata to Contracts”
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) })),
};

Step 2: Update List Endpoint Response Format

Section titled “Step 2: Update List Endpoint Response Format”

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 IDDescriptionDefault
auth.teams.readView teams and membership
auth.teams.manageCreate, 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.system
  • healthcheck.configuration
  • incident.incident
  • maintenance.maintenance

When 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"] })
),
};

”Access denied” for resources without grants

Section titled “”Access denied” for resources without grants”

Check that:

  1. User has the required access rule for the endpoint
  2. No other team has teamOnly set on the resource

Verify:

  1. Response format is { keyName: [...] }, not an array directly
  2. resultKey in createResourceAccessList matches the response key
  3. Items in the array have an id field

Ensure:

  1. Team exists in the database
  2. User has auth.teams.manage access to assign access
  3. Resource type in frontend uses fully qualified name (e.g., catalog.system, not just system)