Overview
Ubicloud implements Attribute-Based Access Control (ABAC) to provide fine-grained permissions for users and resources. ABAC uses three core concepts: subjects (who), actions (what), and objects (which resources) to define flexible access policies.
From the README:
Attribute-Based Access Control (ABAC): With ABAC, you can define attributes, roles, and permissions for users and give them fine-grained access to resources. You can read more about our ABAC design here.
Core Components
ABAC in Ubicloud consists of three types of tags and access control entries:
- Subject Tags: Represent who is performing the action (users, accounts, API keys)
- Action Tags: Represent what operation is being performed (view, create, delete)
- Object Tags: Represent which resources are being accessed (VMs, subnets, endpoints)
- Access Control Entries: Connect subjects, actions, and objects to grant permissions
Access Control Entry Model
class AccessControlEntry < Sequel::Model
many_to_one :project, read_only: true
plugin ResourceMethods
def_column_alias :object_id, :object_id # Use __id__ for internal object id
end
Attributes
| Attribute | Type | Description |
|---|
id | UUID | Unique identifier |
project_id | UUID | Associated project |
subject_id | UUID | Subject tag or entity performing the action |
action_id | UUID | Action tag or action type (optional) |
object_id | UUID | Object tag or resource being accessed (optional) |
When action_id or object_id are null, the entry grants access to all actions or all objects respectively.
Subject tags represent entities that can perform actions:
class SubjectTag < Sequel::Model
plugin ResourceMethods
include AccessControlModelTag
module Cleanup
def before_destroy
AccessControlEntry.where(subject_id: id).destroy
DB[:applied_subject_tag].where(subject_id: id).delete
super
end
end
end
Valid Subject Members
def self.valid_member?(project_id, subject)
case subject
when SubjectTag
subject.project_id == project_id
when Account
!DB[:access_tag].where(project_id: project_id, hyper_tag_id: subject.id).empty?
when ApiKey
subject.owner_table == "accounts" && subject.project_id == project_id
end
end
Subjects can be:
- Subject Tags: Groups of users or API keys
- Accounts: Individual user accounts
- API Keys: Personal access tokens
Admin Tag
Every project has a special “Admin” subject tag with full permissions:
def self.admin_tag?(id)
!where(id: id, name: "Admin").empty?
end
The Admin subject tag cannot be included in other tags or deleted.
Action tags represent operations that can be performed:
class ActionTag < Sequel::Model
plugin ResourceMethods
include AccessControlModelTag
MEMBER_ID = "ffffffff-ff00-834a-87ff-ff828ea2dd80"
dataset_module do
where :global, project_id: nil
order :by_name, :name
def global_by_name
global.by_name
end
end
plugin :subset_static_cache
cache_subset :global_by_name
end
Global vs Project-Specific Actions
Actions can be:
- Global:
project_id is nil, available across all projects
- Project-specific:
project_id is set, only available in that project
def self.valid_member?(project_id, action)
case action
when ActionTag
action.project_id == project_id || !action.project_id
when ActionType
true
end
end
Action Types
Predefined action types include:
view - Read access
create - Create new resources
edit - Modify existing resources
delete - Remove resources
update - Update resource configuration
Object tags represent resources that can be accessed:
class ObjectTag < Sequel::Model
plugin ResourceMethods
include AccessControlModelTag
module Cleanup
def before_destroy
AccessControlEntry.where(object_id: id).destroy
DB[:applied_object_tag].where(object_id: id).delete
super
end
end
end
Valid Object Members
def self.valid_member?(project_id, object)
case object
when ObjectTag, ObjectMetatag, SubjectTag, ActionTag, InferenceEndpoint, Vm, PrivateSubnet, PostgresResource, Firewall, LoadBalancer
object.project_id == project_id
when Project
object.id == project_id
when ApiKey
object.owner_table == "project" && object.owner_id == project_id
end
end
Objects can be:
- Object Tags: Groups of resources
- Direct Resources: VMs, subnets, databases, endpoints, etc.
- Project: The project itself
- API Keys: Inference endpoint API keys
- Other Tags: Subject tags, action tags (for meta-permissions)
Object tags have metatags for controlling access to the tag itself:
def metatag
ObjectMetatag.new(self)
end
def metatag_ubid
ObjectMetatag.to_meta(ubid)
end
def metatag_uuid
UBID.to_uuid(metatag_ubid)
end
Granting access to a tag’s metatag allows managing the tag itself, not the objects it contains.
Tag Membership Management
Tags can contain other tags or entities, creating hierarchies:
module AccessControlModelTag
def add_member(member_id)
applied_dataset.insert(:tag_id => id, applied_column => member_id)
end
def member_ids
applied_dataset.where(tag_id: id).select_map(applied_column)
end
def add_members(member_ids)
applied_dataset.import([:tag_id, applied_column], member_ids.map { [id, it] })
end
def remove_members(member_ids)
applied_dataset.where(:tag_id => id, applied_column => member_ids).delete
end
end
Recursive Tag Inclusion
Tags can include other tags, but recursion is prevented:
def currently_included_in
DB[:tag]
.with_recursive(:tag,
DB[applied_table].where(applied_column => id).select(:tag_id, 0),
DB[applied_table].join(:tag, tag_id: applied_column)
.select(Sequel[applied_table][:tag_id], Sequel[:level] + 1)
.where { level < Config.recursive_tag_limit },
args: [:tag_id, :level])
.select_map(:tag_id)
end
The recursive tag limit is configured via Config.recursive_tag_limit to prevent infinite loops.
Validation Rules
def check_members_to_add(to_add)
issues = []
current_members = member_ids
# Prevent simple recursion
if to_add.include?(id)
to_add.delete(id)
issues << "cannot include tag in itself"
end
# Remove duplicates
size = to_add.size
to_add -= current_members
if to_add.size != size
issues << "#{size - to_add.size} members already in tag"
end
# Prevent complex recursion
size = to_add.size
to_add -= currently_included_in
if to_add.size != size
issues << "#{size - to_add.size} members already include tag directly or indirectly"
end
# Validate member types
proposed_additions = {}
to_add.each { proposed_additions[it] = nil }
UBID.resolve_map(proposed_additions)
to_add = []
proposed_additions.each_value do |it|
if is_a?(SubjectTag) && it.is_a?(SubjectTag) && it.name == "Admin"
issues << "cannot include Admin subject tag in another tag"
next
end
to_add << it if it && self.class.valid_member?(project_id, it)
end
if proposed_additions.size != to_add.size
issues << "#{proposed_additions.size - to_add.size} members not valid"
end
to_add.map!(&:id)
to_add.uniq!
[to_add, issues]
end
Creating Access Control Entries
Basic ACE Creation
Grant a subject permission to perform an action on an object:
ace = AccessControlEntry.create(
project_id: project.id,
subject_id: subject_tag.id,
action_id: action_tag.id,
object_id: object_tag.id
)
Wildcard Permissions
Grant access to all actions:
ace = AccessControlEntry.create(
project_id: project.id,
subject_id: subject_tag.id,
action_id: nil, # All actions
object_id: inference_endpoint.id
)
Grant access to all objects:
ace = AccessControlEntry.create(
project_id: project.id,
subject_id: subject_tag.id,
action_id: view_action.id,
object_id: nil # All objects
)
Using UBIDs
Update an ACE using UBIDs instead of UUIDs:
ace.update_from_ubids({
subject_id: "st8abc123def456",
action_id: "at8xyz789ghi012",
object_id: "ot8uvw345jkl678"
})
Implementation:
def update_from_ubids(hash)
update(hash.transform_values { UBID.to_uuid(it) if it })
end
Validation
ACEs are validated to ensure all IDs belong to the project:
def validate
if project_id
{subject_id: subject_id, action_id: action_id, object_id: object_id}.each do |field, value|
next unless value
ubid = UBID.from_uuidish(value).to_s
model = case field
when :subject_id
SubjectTag
when :action_id
ActionTag
else
ObjectTag
end
object = ubid.start_with?("et") ? ApiKey.with_pk(value) : UBID.decode(ubid)
unless model.valid_member?(project_id, object)
errors.add(field, "is not related to this project")
end
end
end
super
end
Check field presence
Skip validation if the field is nil
Decode UBID
Convert UUID to UBID string for decoding
Determine model type
Select the appropriate tag model based on the field
Validate membership
Ensure the entity is valid for the project
Tag Naming Rules
All tags must follow naming conventions:
def validate
validates_format(
%r{\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z}i,
:name,
message: "must only include ASCII letters, numbers, and dashes, and must start and end with an ASCII letter or number"
)
super
end
Valid names:
Invalid names:
-admin (starts with dash)
team_1 (contains underscore)
a * 64 (too long)
Tag Cleanup
When a tag is destroyed, all related entries are cleaned up:
def before_destroy
meta_cond = {object_id: respond_to?(:metatag_uuid) ? metatag_uuid : id}
applied_dataset.where(tag_id: id).or(applied_column => id).delete
DB[:applied_object_tag].where(meta_cond).delete
AccessControlEntry.where(applied_column => id).or(meta_cond).destroy
super
end
This ensures:
- Applied tag entries are removed
- Object tag references are cleaned up
- Access control entries are destroyed
API Key Integration
API keys can be used as subjects in ABAC:
Unrestricted Access
def unrestrict_token_for_project(project_id)
AccessControlEntry.where(project_id: project_id, subject_id: id).destroy
DB[:applied_subject_tag]
.insert_ignore
.insert(subject_id: id, tag_id: SubjectTag.where(project_id: project_id, name: "Admin").select(:id))
end
This adds the API key to the Admin subject tag, granting full access.
Restricted Access
def restrict_token_for_project(project_id)
unrestricted_project_access_dataset(project_id).delete
end
def unrestricted_token_for_project?(project_id)
!unrestricted_project_access_dataset(project_id).empty?
end
private
def unrestricted_project_access_dataset(project_id)
DB[:applied_subject_tag]
.where(subject_id: id, tag_id: SubjectTag.where(project_id: project_id, name: "Admin").select(:id))
end
Example Use Cases
Read-Only Access
Create a read-only role for a team:
# Create subject tag for the team
team_tag = SubjectTag.create(project_id: project.id, name: "read-only-team")
team_tag.add_members([user1.id, user2.id, api_key.id])
# Create action tag for read operations
read_tag = ActionTag.create(project_id: project.id, name: "read-ops")
read_tag.add_members([view_action.id, list_action.id])
# Grant access to all VMs
vm_tag = ObjectTag.create(project_id: project.id, name: "all-vms")
vm_tag.add_members(project.vms.map(&:id))
AccessControlEntry.create(
project_id: project.id,
subject_id: team_tag.id,
action_id: read_tag.id,
object_id: vm_tag.id
)
Inference Endpoint Access
Grant an API key access to specific inference endpoints:
# Create object tag for production endpoints
prod_endpoints = ObjectTag.create(project_id: project.id, name: "production-endpoints")
prod_endpoints.add_members([endpoint1.id, endpoint2.id])
# Create subject for the API key
api_key_tag = SubjectTag.create(project_id: project.id, name: "inference-api-keys")
api_key_tag.add_subject(api_key.id)
# Grant view and use permissions
AccessControlEntry.create(
project_id: project.id,
subject_id: api_key_tag.id,
action_id: use_action.id,
object_id: prod_endpoints.id
)
Nested Teams
Create hierarchical team structures:
# Create parent team
engineering = SubjectTag.create(project_id: project.id, name: "engineering")
# Create child teams
frontend = SubjectTag.create(project_id: project.id, name: "frontend-team")
backend = SubjectTag.create(project_id: project.id, name: "backend-team")
# Add child teams to parent
engineering.add_members([frontend.id, backend.id])
# Add users to child teams
frontend.add_members([user1.id, user2.id])
backend.add_members([user3.id, user4.id])
# Grant permissions to parent team (inherited by children)
AccessControlEntry.create(
project_id: project.id,
subject_id: engineering.id,
action_id: nil, # All actions
object_id: project.id
)
Resource Paths
Tags have consistent URL paths:
def path
"/user/access-control/tag/#{base}/#{ubid}"
end
Examples:
- Subject tag:
/user/access-control/tag/subject/st8abc123def456
- Action tag:
/user/access-control/tag/action/at8xyz789ghi012
- Object tag:
/user/access-control/tag/object/ot8uvw345jkl678
Best Practices
Instead of creating ACEs for individual users, group them into subject tags:
# Good
team_tag.add_members([user1.id, user2.id, user3.id])
AccessControlEntry.create(subject_id: team_tag.id, ...)
# Avoid
AccessControlEntry.create(subject_id: user1.id, ...)
AccessControlEntry.create(subject_id: user2.id, ...)
AccessControlEntry.create(subject_id: user3.id, ...)
Principle of Least Privilege
Grant only the minimum necessary permissions:
# Good - specific action
AccessControlEntry.create(
subject_id: subject_tag.id,
action_id: view_action.id,
object_id: object_tag.id
)
# Avoid - wildcard action unless necessary
AccessControlEntry.create(
subject_id: subject_tag.id,
action_id: nil,
object_id: object_tag.id
)
Organize resources by environment:
production = ObjectTag.create(project_id: project.id, name: "production")
staging = ObjectTag.create(project_id: project.id, name: "staging")
development = ObjectTag.create(project_id: project.id, name: "development")
production.add_members([prod_vm1.id, prod_vm2.id, prod_endpoint.id])
staging.add_members([staging_vm1.id, staging_endpoint.id])