Access Groups¶
An access group is the single container that binds features, per-resource methods, attribute access, filters and tag scopes together. Every user picks up their permissions by being assigned to one or more groups; Craft Easy never stores permissions directly on the user row.
The model lives at craft_easy.models.access_group.AccessGroup, the reusable templates at AccessGroupTemplate, and the merging logic at craft_easy.core.access.accumulator.AccessAccumulator.
The AccessGroup document¶
from craft_easy.models.access_group import AccessGroup, ResourceAccess
group = AccessGroup(
name="support-tier-1",
description="First-line support agents — ticket handling only.",
features=["tickets.list", "tickets.update", "customers.view"],
access_rights={
"tickets": ResourceAccess(
methods=["GET", "PATCH"],
attribute_access={"internal_notes": "read", "sla_credit": "none"},
filters={"status": ["open", "pending"]},
features=["tickets.escalate"],
),
"customers": ResourceAccess(
methods=["GET"],
attribute_access={"ssn": "none"},
),
},
tag_scopes=[], # populated with Tag IDs for data scoping
)
await group.insert()
Fields¶
| Field | Type | Purpose |
|---|---|---|
name |
indexed string | Human label — shown in the admin UI and audit logs. |
description |
string | Free-form. |
template_id / template_name |
ObjectId / string | Link to an AccessGroupTemplate (optional). |
access_rights |
dict[str, ResourceAccess] |
Per-resource permissions. Key is the resource name. |
features |
list[str] |
Global features granted to members. |
tag_scopes |
list[ObjectId] |
Tag IDs — limits data visibility to rows tagged with any of these tags. Empty = unrestricted. |
ResourceAccess itself carries:
| Field | Purpose |
|---|---|
methods |
Allowed HTTP methods on the resource. Empty = none. |
attribute_access |
Per-field read/write/none map. |
full_attribute_access |
Bypass per-field rules entirely. |
filters |
Mandatory query filters (union with user's where). |
full_filter_access |
Bypass filter restrictions. |
features |
Features granted only when acting on this resource. |
Access groups are tenant-scoped. A group defined in tenant A is invisible to tenant B. Changes use the standard ETag/cascade flow — see Cascade Operations.
Assigning groups to users¶
A user is connected to a group through an entry in user.data_access:
from craft_easy.models.user import UserAccessEntry
from datetime import datetime, UTC, timedelta
user.data_access.append(
UserAccessEntry(
access_group_id=group.id,
valid_from=datetime.now(UTC),
valid_until=datetime.now(UTC) + timedelta(days=90), # optional expiry
)
)
await user.save()
AccessAccumulator treats an entry as active when:
Expired entries are silently ignored — no need to manually prune them. This gives you time-boxed access for free (temporary contractors, vacation cover, incident responders).
Merge rules¶
When a user is in multiple groups, AccessAccumulator.get_access_rights(user_id, resource_name) merges their rights with these rules:
| Kind | Merge rule |
|---|---|
methods |
Union. Any group granting GET means the user has GET. |
attribute_access |
Max level. write beats read beats none. |
filters |
Union. Values are OR'd across groups (more permissive). |
full_attribute_access |
OR. One group bypassing is enough. |
full_filter_access |
OR. Same. |
features (global + per-resource) |
Union. |
tag_scopes |
Union. Empty in any group means unrestricted. |
The net effect: adding a user to an additional group can only expand their access, never shrink it. Tightening access means removing them from a group or rewriting the group itself.
Tag scopes¶
tag_scopes on the group constrains which rows the user can see, even when their feature access is broad. It interacts with the tagging system documented in Tagging System.
group = AccessGroup(
name="region-west",
features=["orders.list"],
tag_scopes=[tag_west.id], # only rows tagged with "region:west"
)
If a user is in several groups with different tag scopes, the scopes are unioned — a user in both region-west and region-east sees everything tagged with either region.
A group with no tag scopes imposes no data filter from tags. A user in any group without tag scopes is unrestricted (because unioning with "everything" yields "everything").
Templates¶
AccessGroupTemplate is a reusable blueprint for standard roles:
from craft_easy.models.access_group import AccessGroupTemplate, ResourceAccess
template = AccessGroupTemplate(
name="support-agent",
description="Standard first-line support permissions",
features=["tickets.list", "tickets.update"],
access_rights={
"tickets": ResourceAccess(
methods=["GET", "PATCH"],
attribute_access={"internal_notes": "read"},
),
},
)
await template.insert()
Templates can be registered at startup so every new tenant gets the same baseline:
from craft_easy.core.access.templates import register_templates
register_templates([
{
"name": "support-agent",
"description": "Standard first-line support permissions",
"features": ["tickets.list", "tickets.update"],
"access_rights": {...},
},
])
Registered templates are picked up by the seeding pipeline and turned into AccessGroupTemplate rows per tenant. When a group is created from a template, it stores template_id and a cached template_name; cascade updates keep the name in sync if the template is renamed.
Endpoints¶
Access groups and templates are registered as regular Craft Easy resources, so the standard CRUD API is available:
| Method | Path | Purpose |
|---|---|---|
GET |
/access-groups/ |
List groups (feature access_groups.list). |
POST |
/access-groups/ |
Create a group (feature access_groups.create). |
GET |
/access-groups/{id} |
Get one. |
PATCH |
/access-groups/{id} |
Update. Requires If-Match ETag. |
DELETE |
/access-groups/{id} |
Delete. Requires If-Match. |
GET |
/access-group-templates/ |
List templates. |
POST |
/access-group-templates/ |
Create a template. |
A realistic seed¶
async def seed_baseline_groups(tenant_id):
admin = AccessGroup(
name="admin",
description="Tenant administrators — full access",
tenant_id=tenant_id,
features=[f.name for f in feature_registry.all_features()],
access_rights={
"*": ResourceAccess(full_attribute_access=True, full_filter_access=True),
},
)
operator = AccessGroup(
name="operator",
description="Day-to-day operators",
tenant_id=tenant_id,
features=["tickets.list", "tickets.update", "customers.view", "reports.view"],
)
viewer = AccessGroup(
name="viewer",
description="Read-only access",
tenant_id=tenant_id,
features=["tickets.list", "customers.view", "reports.view"],
access_rights={
"tickets": ResourceAccess(methods=["GET"]),
"customers": ResourceAccess(methods=["GET"]),
"reports": ResourceAccess(methods=["GET"]),
},
)
await AccessGroup.insert_many([admin, operator, viewer])
Three groups cover the vast majority of tenants. Add more specialised groups as roles emerge — keeping the set small is the easiest way to stop permissions from drifting.