Skip to content

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:

(valid_from is None or valid_from <= now) and (valid_until is None or valid_until > now)

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.