Access Control
Hugr provides a comprehensive role-based access control (RBAC) system that allows fine-grained permissions management for GraphQL types and fields. Access control is managed through the roles and permissions tables in the core database module.
Overview
The access control system works by:
- Assigning roles to users based on the authentication method
- Defining permissions for each role to access specific GraphQL types and fields
- Optionally applying filters and default values to enforce data-level security
All permissions are managed through the standard GraphQL API in the core module.
Default Roles
When Hugr is deployed, three default roles are automatically created:
- admin - Full access to all types and fields (no restrictions)
- public - Limited access for anonymous users (no default permissions; configure as needed)
- readonly - Can query all data but all mutations are disabled
The readonly role is created with a single permission entry:
INSERT INTO permissions (role, type_name, field_name, hidden, disabled)
VALUES ('readonly', 'Mutation', '*', false, true);
This configuration:
- Allows all queries (no restrictions needed due to "open by default" behavior)
- Blocks all mutations by targeting the
Mutationroot type with a wildcard field
You can customize these roles or create new ones based on your security requirements.
Role Assignment
Roles are assigned to users based on the configured authentication scheme:
1. API Keys
For API key authentication, roles are configured through:
- Request headers that identify the username and user ID
- Role assignment in the API key configuration
See Authentication Setup for details on configuring API keys.
2. Anonymous Users
Anonymous (unauthenticated) requests are automatically assigned a role defined in the configuration settings.
3. OAuth2/JWT Tokens
For OAuth2 and JWT authentication, roles are determined from:
- Token claims
- Token scopes
4. OIDC Tokens
For OpenID Connect authentication, roles are extracted from:
- Token claims
- Token scopes
Schema Definition
Roles Table
The roles table defines available roles in the system:
"""
Roles are the permissions that can be assigned to users
"""
type roles @module(name: "core")
@table(name: "roles") {
"""
Role name
"""
name: String! @pk
"""
Role description
"""
description: String!
"""
Role disabled flag
"""
disabled: Boolean @default(value: false)
}
Permissions Table
The role_permissions table defines what each role can access:
"""
Role permissions to visible and allowed types and their fields
that can be assigned to roles
"""
type role_permissions @module(name: "core")
@table(name: "permissions") {
"Role name"
role: String! @pk @field_references(
references_name: "roles",
field: "name",
query: "role_info"
references_query: "permissions"
)
"Type name, can be * for all types"
type_name: String! @pk
"Field name, can be * for all fields"
field_name: String! @pk
"Hidden flag"
hidden: Boolean @default(value: false)
"Disabled flag"
disabled: Boolean @default(value: false)
"Required filter values for the field"
filter: JSON
"Required field values for the data mutation"
data: JSON
}
Permission Behavior and Wildcards
Default Access Behavior
Important: If a type or field is not found in the permissions table for a role, it is accessible by default. This "open by default" approach allows you to create targeted restrictions without explicitly listing every allowed field.
# If no permissions exist for role "viewer", all types and fields are accessible
# To restrict access, you must explicitly add permission entries
This design enables two permission strategies:
- Deny-by-default - Block everything with wildcards, then allow specific items
- Allow-by-default - Start with full access, then block specific items
Wildcard Matching
You can use * (asterisk) in place of type names or field names to apply rules broadly:
type_name: "*"- Applies to all GraphQL typesfield_name: "*"- Applies to all fields of a type- Both wildcards - Applies globally to everything
Default readonly Role Example
When Hugr creates the core database, the readonly role is configured to disable all mutations:
INSERT INTO permissions (role, type_name, field_name, hidden, disabled)
VALUES ('readonly', 'Mutation', '*', false, true);
This single entry:
- Targets the
Mutationtype (the root mutation type in GraphQL) - Uses
*for field_name to match all mutation fields - Sets
disabled: trueto block all mutations
Result: readonly role can query data but cannot make any mutations.
Permission Priority
When checking access, Hugr applies the most specific permission that matches:
Priority order (highest to lowest):
- Exact match:
(type_name: "users", field_name: "email") - Type with wildcard field:
(type_name: "users", field_name: "*") - Wildcard type with exact field:
(type_name: "*", field_name: "email") - Both wildcards:
(type_name: "*", field_name: "*") - No match: Allowed by default
Example: Layered Permissions
mutation {
core { # Mutation type for the core module
insert_roles(data: { # Mutation field
name: "limited_editor"
description: "Can edit most things except sensitive data"
permissions: [
# 1. Allow all types and fields by default (least specific)
{
type_name: "*"
field_name: "*"
disabled: false
}
# 2. Hide all email fields across all types (more specific)
{
type_name: "*"
field_name: "email"
hidden: true
}
# 3. Completely block ssn field in users type (most specific)
{
type_name: "users"
field_name: "ssn"
disabled: true
}
# 4. Block all mutations (specific type, all fields)
{
type_name: "Mutation"
field_name: "*"
disabled: true
}
# 5. But allow specific update mutation (most specific wins)
{
type_name: "Mutation"
field_name: "update_users"
disabled: false
}
]
}) {
name
}
}
}
Result:
- Most fields are accessible
- All
emailfields are hidden but can be explicitly requested users.ssnis completely blocked- All mutations are blocked except
update_users
Targeted Restrictions Strategy
The most efficient approach is to use wildcards for broad rules and specific entries for exceptions:
Example: Read-only with specific write permissions
mutation {
core {
insert_roles(data: {
name: "content_contributor"
description: "Can only create articles, not edit or delete"
permissions: [
# Block all mutations
{
type_name: "Mutation"
field_name: "*"
disabled: true
}
# Except insert_articles
{
type_name: "Mutation"
field_name: "insert_articles"
disabled: false
data: {
author_id: "[$auth.user_id]"
status: "pending"
}
}
]
}) {
name
}
}
}
Example: Hide sensitive fields by default
mutation {
core {
insert_roles(data: {
name: "public"
description: "Public access with hidden PII"
permissions: [
# Hide email across all types
{
type_name: "*"
field_name: "email"
hidden: true
}
# Hide phone across all types
{
type_name: "*"
field_name: "phone"
hidden: true
}
# Completely block SSN across all types
{
type_name: "*"
field_name: "ssn"
disabled: true
}
]
}) {
name
}
}
}
Example: Type-level restrictions
mutation {
core {
insert_roles(data: {
name: "external_api"
description: "External API with limited access"
permissions: [
# Block entire admin types
{
type_name: "admin_settings"
field_name: "*"
disabled: true
}
{
type_name: "internal_logs"
field_name: "*"
disabled: true
}
# Block all mutations on sensitive table
{
type_name: "Mutation"
field_name: "insert_user_credentials"
disabled: true
}
{
type_name: "Mutation"
field_name: "update_user_credentials"
disabled: true
}
{
type_name: "Mutation"
field_name: "delete_user_credentials"
disabled: true
}
]
}) {
name
}
}
}
Creating Roles and Permissions
There are two approaches to setting up roles and permissions:
- Create role with nested permissions - Create a role and all its permissions in a single mutation
- Create separately - Create a role first, then add permissions individually
Approach 1: Create Role with Nested Permissions (Recommended)
The most efficient way is to create a role with all its permissions at once using nested relations:
mutation {
core {
insert_roles(data: {
name: "editor"
description: "Content editor with limited permissions"
permissions: [
{
type_name: "Query"
field_name: "articles"
}
{
type_name: "Mutation"
field_name: "insert_articles"
data: {
author_id: "[$auth.user_id]"
}
}
{
type_name: "Mutation"
field_name: "update_articles"
filter: {
author_id: { eq: "[$auth.user_id]" }
}
}
]
}) {
name
description
permissions {
type_name
field_name
}
}
}
}
This approach:
- Creates the role and all permissions in a single transaction
- Cleaner and more maintainable
- Reduces the number of API calls
Approach 2: Create Role and Permissions Separately
Alternatively, create a role first, then add permissions individually:
mutation {
core {
# Step 1: Create the role
insert_roles(data: {
name: "editor"
description: "Content editor with limited permissions"
}) {
name
}
# Step 2: Add permissions for this role
perm1: insert_role_permissions(data: {
role: "editor"
type_name: "Query"
field_name: "articles"
}) {
role
type_name
}
perm2: insert_role_permissions(data: {
role: "editor"
type_name: "Mutation"
field_name: "insert_articles"
}) {
role
type_name
}
}
}
This approach is useful when:
- Adding permissions to an existing role
- Managing permissions incrementally
- Different team members manage roles vs. permissions
Managing Roles
Creating Roles
Create a new role using the insert mutation:
mutation {
core {
insert_roles(data: {
name: "editor"
description: "Can edit content but not delete"
disabled: false
}) {
name
description
}
}
}
Updating Roles
Update existing roles:
mutation {
core {
update_roles(
filter: { name: { eq: "editor" } }
data: {
description: "Content editors with limited permissions"
}
) {
success
affected_rows
}
}
}
Disabling Roles
Disable a role without deleting it:
mutation {
core {
update_roles(
filter: { name: { eq: "editor" } }
data: { disabled: true }
) {
success
}
}
}
Deleting Roles
Remove a role completely:
mutation {
core {
delete_roles(
filter: { name: { eq: "old_role" } }
) {
success
affected_rows
}
}
}
Managing Permissions
Note: The data argument in insert mutations accepts a single object, not an array. To create multiple permissions at once, either:
- Create a role with nested permissions (see "Creating Roles and Permissions" above)
- Use multiple mutation calls with aliases in a single request
Granting Basic Access
Grant a role access to a specific type:
mutation {
core {
insert_role_permissions(data: {
role: "editor"
type_name: "Query"
field_name: "articles"
hidden: false
disabled: false
}) {
role
type_name
field_name
}
}
}
Field-Level Permissions
Grant access to specific fields only. You can create permissions individually:
mutation {
core {
# Create permission for id field
perm1: insert_role_permissions(data: {
role: "public"
type_name: "users"
field_name: "id"
}) {
role
type_name
field_name
}
# Create permission for name field
perm2: insert_role_permissions(data: {
role: "public"
type_name: "users"
field_name: "name"
}) {
role
type_name
field_name
}
# Create permission for email field (hidden)
perm3: insert_role_permissions(data: {
role: "public"
type_name: "users"
field_name: "email"
hidden: true # Hidden but accessible if explicitly requested
}) {
role
type_name
field_name
}
}
}
Or create a role with all permissions at once:
mutation {
core {
insert_roles(data: {
name: "public"
description: "Public access role"
permissions: [
{
type_name: "users"
field_name: "id"
}
{
type_name: "users"
field_name: "name"
}
{
type_name: "users"
field_name: "email"
hidden: true
}
]
}) {
name
permissions {
type_name
field_name
hidden
}
}
}
}
Wildcard Permissions
Use wildcards to grant broad access:
mutation {
core {
insert_role_permissions(data: {
role: "admin"
type_name: "*" # All types
field_name: "*" # All fields
}) {
role
}
}
}
Data-Level Security
Row-Level Filters
Apply filters that restrict which rows a role can access. Filters are automatically applied to queries, updates, and deletes.
Example: Users can only see their own records
mutation {
core {
insert_role_permissions(data: {
role: "user"
type_name: "Query"
field_name: "orders"
filter: {
user_id: { eq: "[$auth.user_id]" }
}
}) {
role
type_name
}
}
}
The filter uses the same format as GraphQL query filters. See Queries for filter syntax.
Example: Region-based access
mutation {
core {
insert_role_permissions(data: {
role: "regional_manager"
type_name: "Query"
field_name: "stores"
filter: {
region: { eq: "[$auth.user_region]" }
}
}) {
role
type_name
}
}
}
Example: Complex filters
mutation {
core {
insert_role_permissions(data: {
role: "moderator"
type_name: "Query"
field_name: "comments"
filter: {
_or: [
{ author_id: { eq: "[$auth.user_id]" } }
{ status: { eq: "pending_review" } }
]
}
}) {
role
type_name
}
}
}
Default Values for Mutations
Enforce default field values in insert and update mutations. These values are applied automatically and cannot be overridden by the user.
Example: Auto-set ownership
mutation {
core {
insert_role_permissions(data: {
role: "user"
type_name: "Mutation"
field_name: "insert_articles"
data: {
author_id: "[$auth.user_id]"
created_by: "[$auth.user_name]"
}
}) {
role
type_name
}
}
}
Example: Enforce status on updates
mutation {
core {
insert_role_permissions(data: {
role: "editor"
type_name: "Mutation"
field_name: "update_articles"
data: {
status: "pending_review"
reviewed_by: null
}
}) {
role
type_name
}
}
}
The data field uses the same format as GraphQL mutation input types.
Authentication Variables
Use authentication variables in filters and default values to create dynamic, user-specific permissions:
| Variable | Description | Example Value |
|---|---|---|
[$auth.user_name] | Username | "john.doe" |
[$auth.user_id] | User ID (string) | "12345" |
[$auth.user_id_int] | User ID (integer) | 12345 |
[$auth.role] | User's role | "editor" |
[$auth.auth_type] | Authentication type | "jwt", "apikey", "oidc" |
[$auth.provider] | Auth provider | "google", "auth0" |
Custom Claim Variables
You can also access custom claims from JWT/OIDC tokens:
mutation {
core {
insert_role_permissions(data: {
role: "employee"
type_name: "Query"
field_name: "departments"
filter: {
department_id: { eq: "[$auth.department_id]" }
}
}) {
role
type_name
}
}
}
Permission Flags
Hidden Flag
When hidden: true, the field is not returned in queries by default but can be explicitly requested:
# Without explicit request - email field not returned
query {
users {
id
name
}
}
# With explicit request - email field is returned
query {
users {
id
name
email # Must be explicitly requested
}
}
Disabled Flag
When disabled: true, the field or type is completely inaccessible:
mutation {
core {
insert_role_permissions(data: {
role: "readonly"
type_name: "Mutation"
field_name: "delete_users"
disabled: true # Delete mutation blocked
}) {
role
type_name
}
}
}
Complete Examples
Example 1: Multi-tenant Application
mutation {
core {
# Create role with permissions for multi-tenant access
insert_roles(data: {
name: "tenant_user"
description: "User within a tenant organization"
permissions: [
{
type_name: "Query"
field_name: "customers"
filter: {
tenant_id: { eq: "[$auth.tenant_id]" }
}
}
{
type_name: "Mutation"
field_name: "insert_customers"
data: {
tenant_id: "[$auth.tenant_id]"
}
}
{
type_name: "Mutation"
field_name: "update_customers"
filter: {
tenant_id: { eq: "[$auth.tenant_id]" }
}
}
{
type_name: "Mutation"
field_name: "delete_customers"
filter: {
tenant_id: { eq: "[$auth.tenant_id]" }
}
}
]
}) {
name
description
permissions {
type_name
field_name
}
}
}
}
Example 2: Hierarchical Permissions
mutation {
core {
# Viewer role - read only
viewer: insert_roles(data: {
name: "viewer"
description: "Read-only access to articles"
permissions: [
{
type_name: "Query"
field_name: "articles"
}
{
type_name: "Mutation"
field_name: "insert_articles"
disabled: true
}
{
type_name: "Mutation"
field_name: "update_articles"
disabled: true
}
{
type_name: "Mutation"
field_name: "delete_articles"
disabled: true
}
]
}) {
name
description
}
# Editor role - can create and edit own content
editor: insert_roles(data: {
name: "editor"
description: "Can create and edit own articles"
permissions: [
{
type_name: "Query"
field_name: "articles"
filter: {
author_id: { eq: "[$auth.user_id]" }
}
}
{
type_name: "Mutation"
field_name: "insert_articles"
data: {
author_id: "[$auth.user_id]"
status: "draft"
}
}
{
type_name: "Mutation"
field_name: "update_articles"
filter: {
author_id: { eq: "[$auth.user_id]" }
}
}
{
type_name: "Mutation"
field_name: "delete_articles"
filter: {
author_id: { eq: "[$auth.user_id]" }
}
}
]
}) {
name
description
}
}
}
Example 3: Sensitive Field Protection
mutation {
core {
insert_roles(data: {
name: "public"
description: "Public access with field-level restrictions"
permissions: [
# Public can see basic user info
{
type_name: "users"
field_name: "id"
}
{
type_name: "users"
field_name: "name"
}
{
type_name: "users"
field_name: "avatar"
}
# Email hidden but accessible if explicitly requested
{
type_name: "users"
field_name: "email"
hidden: true
}
# Phone completely blocked
{
type_name: "users"
field_name: "phone"
disabled: true
}
# SSN completely blocked
{
type_name: "users"
field_name: "ssn"
disabled: true
}
]
}) {
name
permissions {
type_name
field_name
hidden
disabled
}
}
}
}
Querying Permissions
List All Roles
query {
core {
roles {
name
description
disabled
permissions {
type_name
field_name
hidden
disabled
}
}
}
}
Check Specific Role Permissions
query {
core {
role_permissions(
filter: { role: { eq: "editor" } }
) {
type_name
field_name
hidden
disabled
filter
data
}
}
}
Find Permissions for a Type
query {
core {
role_permissions(
filter: {
type_name: { eq: "articles" }
}
) {
role
field_name
hidden
disabled
}
}
}
Permission Caching
Hugr automatically caches role permissions to improve authorization performance. When a user makes a request, their role permissions are fetched once and cached, reducing database queries on subsequent requests.
How Permission Caching Works
Hugr caches role permissions using a specific pattern:
Cache Key Format: RolePermissions:{role_name}
Cache Tag: $role_permissions
When checking permissions for a role:
- First request: Query the database for role permissions
- Cache the result: Store permissions with the role-specific key
- Subsequent requests: Serve permissions from cache (1-5ms vs 50-100ms)
- Cache until TTL: Default TTL of 1 hour (configurable)
Internal Permission Query
Here's the internal query Hugr uses to fetch and cache role permissions:
query ($role: String!, $cacheKey: String) {
core {
info: roles_by_pk(name: $role) @cache(key: $cacheKey, tags: ["$role_permissions"]) {
name
disabled
permissions {
type_name
field_name
hidden
disabled
filter
data
}
}
}
}
# Called with:
# { role: "editor", cacheKey: "RolePermissions:editor" }
Invalidating Permission Cache
After modifying roles or permissions, invalidate the cache to ensure users get updated permissions immediately.
Method 1: Invalidate All Role Permissions (Recommended)
When you update permissions, invalidate all role caches:
mutation UpdatePermissions {
# Update role permissions
core {
update_role_permissions(
filter: { role: { eq: "editor" } }
data: { disabled: false }
) {
success
affected_rows
}
# Invalidate ALL role permissions cache
invalidateCache: function {
core {
cache {
invalidate(tags: ["$role_permissions"]) {
success
affected_rows
message
}
}
}
}
}
}
Method 2: Invalidate Specific Role
Use @invalidate_cache directive to refresh a specific role:
query RefreshEditorPermissions {
core {
editor: roles_by_pk(name: "editor")
@invalidate_cache
@cache(
key: "RolePermissions:editor",
tags: ["$role_permissions"]
) {
name
permissions {
type_name
field_name
disabled
}
}
}
}
Method 3: Automatic Invalidation in Mutations
Combine permission updates with cache invalidation in a single mutation:
mutation CreateRoleWithPermissions {
core {
# Create new role
insert_roles(data: {
name: "content_manager"
description: "Can manage content"
permissions: [
{
type_name: "articles"
field_name: "*"
}
{
type_name: "insert_articles"
field_name: "*"
data: {
author_id: "[$auth.user_id]"
}
}
]
}) {
name
}
# Invalidate cache so new role is immediately available
invalidateCache: function {
core {
cache {
invalidate(tags: ["$role_permissions"]) {
success
affected_rows
}
}
}
}
}
}
When to Invalidate Permission Cache
Always invalidate cache when:
- Creating new roles
- Updating role permissions
- Deleting roles
- Changing
hiddenordisabledflags - Modifying
filterordataconstraints - Enabling/disabling roles
Performance Impact
Without Caching:
- Permission check on every request: 50-100ms
- High database load for multi-user systems
- Slower API response times
With Caching:
- Permission check from cache: 1-5ms
- Reduced database load by 90%+
- Faster API response times
- Scales to thousands of concurrent users
Example: Complete Permission Update Workflow
mutation UpdateRoleAndInvalidateCache {
core {
# 1. Update role permissions
update_role_permissions(
filter: {
role: { eq: "viewer" }
type_name: { eq: "articles" }
}
data: {
hidden: false
disabled: false
}
) {
success
affected_rows
}
# 2. Invalidate permission cache
invalidatePermissions: function {
core {
cache {
invalidate(tags: ["$role_permissions"]) {
success
affected_rows
message
}
}
}
}
}
}
Best Practices for Permission Caching
- Always Invalidate After Changes: Immediately invalidate cache after any permission modifications
- Use Tag-Based Invalidation: Use
$role_permissionstag to invalidate all roles at once - Monitor Cache Effectiveness: Track
affected_rowsto ensure cache is being cleared - Test After Updates: Verify permissions work correctly after invalidation
- Consider Security: Stale permissions can lead to unauthorized access or denial of valid requests
Security Considerations
⚠️ Important: Stale permission cache can cause security issues:
- Privilege Escalation: Users may retain permissions after they've been revoked
- Access Denial: Users may be blocked from resources they should access
- Compliance Violations: Cached permissions may not reflect current security policies
Always verify invalidation succeeds:
mutation CriticalPermissionUpdate {
core {
update_role_permissions(...) {
success
}
# Verify invalidation succeeded
invalidate: function {
core {
cache {
invalidate(tags: ["$role_permissions"]) {
success
message
}
}
}
}
}
}
# Check: invalidate.function.core.cache.invalidate.success === true
For more details on caching, see Cache Directives.
Best Practices
Permission Strategy
- Understand Default Behavior: Remember that types/fields are accessible by default if not found in permissions table
- Choose Your Strategy:
- Deny-by-default: Use wildcards to block everything, then explicitly allow specific items (more secure for sensitive data)
- Allow-by-default: Only add permission entries to block or hide specific items (simpler for most cases)
- Use Wildcards Effectively: Wildcards are powerful for broad rules with specific exceptions
- Block all mutations:
(type_name: "Mutation", field_name: "*", disabled: true) - Hide PII fields:
(type_name: "*", field_name: "email", hidden: true) - Then add specific exceptions with higher priority rules
- Block all mutations:
Security and Maintenance
- Principle of Least Privilege: Grant only the minimum permissions required for each role
- Leverage Priority System: Use layered permissions - broad wildcard rules with specific overrides
- Test Permissions: Always test with different roles to ensure permissions work as expected
- Audit Regularly: Periodically review role assignments and permissions
- Use Authentication Variables: Leverage
[$auth.*]variables for dynamic, user-specific permissions - Combine Filters: Use both row-level filters and field-level permissions for defense in depth
- Document Roles: Maintain clear descriptions for each role's purpose and permissions
- Version Control: Track permission changes in version control alongside schema changes
Common Patterns
Read-only access:
permissions: [
{ type_name: "Mutation", field_name: "*", disabled: true }
]
Hide sensitive fields globally:
permissions: [
{ type_name: "*", field_name: "ssn", disabled: true }
{ type_name: "*", field_name: "password", disabled: true }
{ type_name: "*", field_name: "email", hidden: true }
]
Allow specific mutations only:
permissions: [
{ type_name: "Mutation", field_name: "*", disabled: true }
{ type_name: "Mutation", field_name: "insert_articles", disabled: false }
{ type_name: "Mutation", field_name: "update_articles", disabled: false }
]
Troubleshooting
Permission Not Applied
- Verify the role is not disabled:
roles.disabled = false - Check the permission is not disabled:
role_permissions.disabled = false - Ensure the user has the correct role assigned
- Verify authentication is working correctly
Filter Not Working
- Check filter syntax matches GraphQL filter format
- Verify authentication variables are available and correctly named
- Test the filter independently in a query
- Check for typos in field names
Default Values Not Applied
- Ensure the permission targets the mutation type (e.g.,
insert_articles, notarticles) - Verify the
datafield uses correct input type format - Check that authentication variables exist and have values
- Confirm the field is not explicitly set by the user (defaults don't override)
See Also
- Authentication Setup - Configure user authentication
- Cache Directives - Role permission caching and cache invalidation
- Queries - Query syntax including filters
- Mutations - Mutation operations and input types
- Schema Definition - Define types and fields