Authorization
SLOzy implements Role-Based Access Control (RBAC) with fine-grained permissions at organization, team, and resource levels.
RBAC Architecture
The RBAC system is built on three layers:
- Roles — Named collections of permissions (admin, manager, developer, viewer)
- User Roles — Assignments linking users to roles within organizations (optionally scoped to teams)
- Resource Permissions — Fine-grained access control for specific resources
All RBAC logic lives in internal/rbac/service.go and is enforced by internal/middleware/rbac.go.
Permission Check Flow
func (s *RBACService) CheckPermission(ctx context.Context, userID, organizationID int, permission string) (bool, error) {
var hasPermission bool
query := `SELECT check_permission($1, $2, $3)`
err := s.db.QueryRow(ctx, query, userID, organizationID, permission).Scan(&hasPermission)
return hasPermission, nil
}The check_permission() PostgreSQL function (created in migration 000015_rbac_system) efficiently validates access by checking roles and direct resource permissions.
Roles and Permissions
Four default roles are seeded in migration 000015_rbac_system:
| Role | Permissions |
|---|---|
| admin | read, write, delete, manage_users, manage_teams, manage_organizations, view_analytics, export_data |
| manager | read, write, manage_teams, view_analytics, export_data |
| developer | read, write, view_analytics |
| viewer | read, view_analytics |
Custom roles can be created via the API:
{
"name": "custom_role",
"description": "Custom role description",
"permissions": ["read", "write", "view_analytics"]
}Role Assignment
Assign a role to a user within an organization:
func (s *RBACService) AssignRole(ctx context.Context, req *models.AssignRoleRequest) (*models.UserRole, error) {
query := `INSERT INTO user_roles (user_id, organization_id, role_id, team_id, assigned_at, expires_at)
VALUES ($1, $2, $3, $4, NOW(), $5)
ON CONFLICT (user_id, organization_id, role_id, team_id) DO UPDATE SET
expires_at = EXCLUDED.expires_at
RETURNING id, user_id, organization_id, role_id, team_id, assigned_at, expires_at`
// ...
}Role assignments can include:
- Organization scope — Role applies across the entire organization
- Team scope — Role is limited to a specific team (optional
team_id) - Time-bound — Role can have an expiration date (optional
expires_at)
Resource-Level Access
For fine-grained control, resource permissions allow access to specific resources:
type ResourcePermission struct {
ResourceType string // 'slo', 'team', 'organization', 'user'
ResourceID int
UserID *int
RoleID *int
Permissions []string
ExpiresAt *time.Time
}This enables scenarios like "User A can edit SLO #42 but not SLO #43" even within the same team.
API Keys
Programmatic access uses API keys stored in the api_keys table:
CREATE TABLE api_keys (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
organization_id INT NOT NULL REFERENCES organizations(id),
key_hash VARCHAR(255) UNIQUE NOT NULL,
key_prefix VARCHAR(10) NOT NULL,
name VARCHAR(255) NOT NULL,
scopes TEXT[] NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE
);API Key Features
- Hashed storage — Keys are hashed before storage; the plaintext key is shown only once at creation
- Key prefix — Human-readable prefix for identification (e.g.,
slo_abc...) - Scoped permissions — Each API key can have a subset of available permissions
- Expiration — Keys can be time-limited
- Active/inactive — Keys can be deactivated without deletion
- Last used tracking — Monitor API key usage via
last_used_at
Creating an API Key
POST /api/v1/api-keys{
"name": "CI Pipeline Key",
"scopes": ["read", "write"]
}Middleware Enforcement
Routes are protected using middleware chains in internal/middleware/rbac.go:
// Require specific permission
mux.Handle("/api/v1/slos", rbacMiddleware.RequirePermission("write")(handler))
// Require resource-specific permission
mux.Handle("/api/v1/slos/:id", rbacMiddleware.RequireResourcePermission("slo", extractSLOID, "write")(handler))Getting User Roles
Query roles for the current user:
GET /api/v1/roles