OIDC groups implementation

- Add Groups field to User struct with JSON storage
- Include GetGroups() and SetGroups() helper methods
- Extract groups from OIDC claims in FromClaim()
- Add database migration 202509161200 for groups column
- Update config-example.yaml with groups scope
- Add comprehensive documentation and testing
This commit is contained in:
Ryan Malloy 2026-05-21 17:55:31 -06:00
parent 30d12dafed
commit 5abc3c87b2
29 changed files with 5088 additions and 3 deletions

309
docs/ref/api-groups.md Normal file
View file

@ -0,0 +1,309 @@
# API Reference: OIDC Groups
This document describes the API changes related to OIDC group storage and management introduced in Headscale v0.24.0.
## Overview
Headscale now stores OIDC group membership information extracted from authentication claims. This enables external integrations to implement role-based access control based on a user's group membership.
## User API Changes
### User Object Schema
The User object now includes group information when users authenticate via OIDC:
```json
{
"id": "1",
"name": "alice",
"createdAt": "2024-01-01T00:00:00Z",
"displayName": "Alice Smith",
"email": "alice@example.com",
"providerId": "https://provider.com/alice",
"provider": "oidc",
"profilePicUrl": "https://provider.com/avatar/alice.jpg",
"groups": ["admin", "developers", "security-team"]
}
```
### Field Descriptions
| Field | Type | Description |
|-------|------|-------------|
| `groups` | `string[]` | Array of group names extracted from OIDC claims. Empty array for non-OIDC users. |
## API Endpoints
### List Users
Returns all users including their group membership.
**Request:**
```http
GET /api/v1/user
Authorization: Bearer <api-key>
```
**Response:**
```json
{
"users": [
{
"id": "1",
"name": "alice",
"email": "alice@example.com",
"provider": "oidc",
"groups": ["admin", "developers"]
},
{
"id": "2",
"name": "bob",
"email": "",
"provider": "cli",
"groups": []
}
]
}
```
### Get User
Returns a specific user including group membership.
**Request:**
```http
GET /api/v1/user/{name}
Authorization: Bearer <api-key>
```
**Response:**
```json
{
"user": {
"id": "1",
"name": "alice",
"email": "alice@example.com",
"provider": "oidc",
"groups": ["admin", "developers", "security-team"]
}
}
```
## Group Management
### Automatic Group Updates
Groups are automatically updated when OIDC users authenticate:
1. User logs in via OIDC
2. Groups are extracted from ID token and/or UserInfo endpoint
3. User's group membership is updated in the database
4. API responses include updated group information
### Group Sources
Groups can be extracted from multiple sources in OIDC claims:
- **Standard `groups` claim**: Most common format
- **`roles` claim**: Alternative role-based claim
- **Provider-specific claims**: e.g., `cognito:groups` for AWS Cognito
- **Nested claims**: e.g., `resource_access.client.roles` for Keycloak
### Group Validation
- Only string values are accepted as group names
- Empty or null groups are filtered out
- Group names are stored as-is (case-sensitive)
- Maximum reasonable limit of groups per user (no strict limit enforced)
## Integration Examples
### Role-Based Access Control
External applications can query user groups for access control:
```bash
# Get user groups via API
curl -H "Authorization: Bearer $API_KEY" \
https://headscale.example.com/api/v1/user/alice | \
jq '.user.groups[]'
# Check if user has admin group
curl -H "Authorization: Bearer $API_KEY" \
https://headscale.example.com/api/v1/user/alice | \
jq '.user.groups | contains(["admin"])'
```
### Database Queries
For direct database access:
```sql
-- Get all users with their groups
SELECT name, email, groups FROM users WHERE provider = 'oidc';
-- Find users in specific group
SELECT name FROM users
WHERE provider = 'oidc'
AND JSON_EXTRACT(groups, '$') LIKE '%"admin"%';
-- Count users by group membership
SELECT
json_each.value as group_name,
COUNT(*) as user_count
FROM users, json_each(users.groups)
WHERE provider = 'oidc'
GROUP BY json_each.value;
```
## WebUI Integration
### Headplane Integration
When using Headplane as a web interface, the groups information enables:
- **Automatic Role Assignment**: Map OIDC groups to Headplane roles
- **Dynamic Permissions**: Update user capabilities based on current group membership
- **Audit Trails**: Track role assignments based on group changes
Example Headplane API usage:
```javascript
// Fetch user with groups
const response = await fetch('/api/v1/user/alice', {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
const user = await response.json();
// Map groups to roles
const roles = mapGroupsToRoles(user.groups);
console.log(`User ${user.name} has roles:`, roles);
```
## Backward Compatibility
### CLI Users
- Users created via CLI (`headscale users create`) have empty groups array
- No changes to existing CLI user management
- Groups field is optional and defaults to empty array
### API Compatibility
- All existing API endpoints continue to work unchanged
- New `groups` field is additive (doesn't break existing clients)
- Clients can safely ignore the groups field if not needed
### Migration
- Existing OIDC users will have empty groups until next login
- No database migration required for basic functionality
- Groups are populated automatically on subsequent OIDC logins
## Configuration
### Headscale Configuration
Ensure groups scope is included for group extraction:
```yaml
oidc:
issuer: "https://your-provider.com"
client_id: "headscale"
client_secret: "your-secret"
scope: ["openid", "profile", "email", "groups"]
```
### Provider-Specific Configuration
=== "Keycloak"
```yaml
# Keycloak automatically includes groups in standard format
oidc:
scope: ["openid", "profile", "email", "groups"]
```
=== "Azure AD"
```yaml
# Azure AD requires group claims configuration
oidc:
scope: ["openid", "profile", "email"]
# Groups included via claims configuration in Azure AD
```
=== "Okta"
```yaml
# Okta supports groups in tokens
oidc:
scope: ["openid", "profile", "email", "groups"]
```
## Error Handling
### Common Issues
**Groups not appearing:**
- Verify `groups` scope is included in OIDC configuration
- Check identity provider group claim configuration
- Ensure user is member of groups in identity provider
**Invalid group data:**
- Non-string group values are automatically filtered out
- Empty arrays are valid (user has no groups)
- Malformed JSON is handled gracefully with empty array fallback
### Error Responses
Standard HTTP error codes apply:
- `401 Unauthorized`: Invalid or missing API key
- `404 Not Found`: User does not exist
- `500 Internal Server Error`: Server-side processing error
## Security Considerations
### Access Control
- Groups information is available to any client with valid API key
- Consider creating read-only API keys for external integrations
- Audit API key usage for compliance requirements
### Data Privacy
- Group names may contain sensitive organizational information
- Consider data classification for group membership information
- Implement appropriate access controls for group data
### Token Security
- Groups are extracted from verified OIDC tokens only
- Token validation ensures groups cannot be spoofed
- Groups are updated only during successful authentication
## Monitoring and Observability
### Metrics
Monitor group extraction and updates:
- Number of OIDC logins with groups extracted
- Distribution of group membership across users
- Failed group extractions or parsing errors
### Logging
Key log events to monitor:
- Group extraction from OIDC claims
- Group updates during user authentication
- Group parsing errors or validation failures
Example log entries:
```
INFO Groups extracted from OIDC claims user=alice groups=["admin","developers"]
WARN Invalid group value filtered out user=bob value=123 type=number
ERROR Failed to parse groups from OIDC claims user=charlie error="invalid JSON"
```
This API enhancement provides the foundation for implementing sophisticated role-based access control systems while maintaining backward compatibility with existing deployments.

View file

@ -194,13 +194,64 @@ endpoint.
| username | `preferred_username` | Depends on identity provider, eg: `ssmith`, `ssmith@idp.example.com`, `\\example.com\ssmith` |
| profile picture | `picture` | URL to a profile picture or avatar |
| provider identifier | `iss`, `sub` | A stable and unique identifier for a user, typically a combination of `iss` and `sub` OIDC claims |
| | `groups` | [Only used to filter for allowed groups](#authorize-users-with-filters) |
| group membership | `groups` | Used for [access filtering](#authorize-users-with-filters) and stored for external integrations |
## Group Storage and Integration
Starting with Headscale v0.24.0, OIDC group membership is automatically extracted from authentication claims and stored in the database. This enables external integrations (such as web interfaces) to implement role-based access control based on a user's group membership.
### Group Storage
- Groups are extracted from both ID tokens and UserInfo endpoint responses
- Group membership is updated on every successful OIDC login
- Groups are stored as JSON in the user database for external access
- Multiple group claim formats are supported (`groups`, `roles`, provider-specific claims)
### External Integration
External applications can query user group membership for implementing role-based access control:
```bash
# View user groups via Headscale CLI
headscale users list --output json
# Example database query (for direct database access)
SELECT name, email, groups FROM users WHERE provider = 'oidc';
```
### Scope Requirements
To enable group storage, ensure your OIDC configuration includes the `groups` scope:
```yaml
oidc:
issuer: "https://sso.example.com"
client_id: "headscale"
client_secret: "generated-secret"
scope: ["openid", "profile", "email", "groups"]
```
### Headplane Integration
When using [Headplane](https://github.com/tale/headplane) as a web interface for Headscale, OIDC groups enable automatic role-based access control:
- **Automatic Role Assignment**: Users are assigned roles based on their OIDC group membership
- **Zero-Trust Security**: New users receive minimal access until proper groups are assigned
- **Dynamic Updates**: User roles update automatically on each login based on current group membership
- **Configurable Mapping**: Organizations can customize which groups map to which roles
Example Headplane role mapping configuration:
```yaml
role_mapping:
owner: ["ceo", "cto", "headscale-owner"]
admin: ["it-admin", "platform-admin"]
network_admin: ["network-team", "devops"]
auditor: ["compliance", "audit-team"]
```
For detailed Headplane OIDC configuration, see the [Headplane documentation](https://github.com/tale/headplane/docs).
## Limitations
- Support for OpenID Connect aims to be generic and vendor independent. It offers only limited support for quirks of
specific identity providers.
- OIDC groups cannot be used in ACLs.
- OIDC groups cannot be directly used in Headscale ACLs (use external integrations for role-based access control).
- The username provided by the identity provider needs to adhere to this pattern:
- The username must be at least two characters long.
- It must only contain letters, digits, hyphens, dots, underscores, and up to a single `@`.