Skip to content

Subcontractor Access

Subcontractor access lets one tenant (the owner) grant a scoped slice of its data to another tenant (the subcontractor) for a bounded time. It is the mechanism Craft Easy uses for cross-tenant delegation — a parking operator handing a site's control-fee handling to an enforcement company, a property owner letting a cleaning contractor see a specific building's jobs, and anything else where data must cross tenant boundaries without handing over the whole tenant.

The implementation is craft_easy.core.access.subcontractor.SubcontractorAccessService; the stored grant is craft_easy.models.subcontractor_access.SubcontractorAccess.

The grant

A grant ties together:

Field Purpose
owner_tenant_id The tenant that owns the data.
partner_tenant_id The subcontractor tenant that gains access.
scope_tags Tag names limiting which rows are visible.
permissions List of verbs: read, write, delete.
resources Resource names the grant applies to. Empty = all resources.
valid_from / valid_to Time window. Either can be None for open-ended.
granted_by User that created the grant (for audit).
is_active Set to False on revoke.

A grant is one-way. The owner remains in full control of their data; the subcontractor gets exactly what is listed and nothing more.

Granting access

from craft_easy.core.access import SubcontractorAccessService
from datetime import datetime, UTC, timedelta

service = SubcontractorAccessService()

grant = await service.grant_access(
    owner_tenant_id=str(property_owner.id),
    partner_tenant_id=str(cleaning_co.id),
    scope_tags=["site:downtown-office"],
    permissions=["read", "write"],
    resources=["cleaning_jobs", "cleaning_reports"],
    valid_from=datetime.now(UTC),
    valid_to=datetime.now(UTC) + timedelta(days=180),
    granted_by=str(admin_user.id),
)

grant_access refuses to create a second active grant between the same pair of tenants — update the existing one instead.

Updating a grant

Grants are editable while active. Pass only the fields you want to change:

await service.update_access(
    str(grant.id),
    scope_tags=["site:downtown-office", "site:harbor-warehouse"],
    valid_to=datetime.now(UTC) + timedelta(days=365),
)

Revoked grants cannot be updated — create a new one.

Checking access

grant = await service.check_access(
    owner_tenant_id=owner_id,
    partner_tenant_id=partner_id,
    resource="cleaning_jobs",
    permission="write",
)
if grant is None:
    raise AuthorizationError("Subcontractor does not have write access to cleaning_jobs")

check_access walks the owner's active grants, filters out any whose resources list does not contain the requested resource (empty list = all resources), drops any that do not contain the requested permission, and returns the first surviving grant — or None if nothing matches.

Listing grants

Either side can list the grants that apply to them:

# Owner view — "who has access to my stuff?"
outgoing = await service.get_active_grants(owner_tenant_id=owner_id)

# Subcontractor view — "whose stuff can I see?"
incoming = await service.get_active_grants(partner_tenant_id=partner_id)

Both calls return only grants where is_active=True and the current time falls inside [valid_from, valid_to].

Revoking

await service.revoke_access(str(grant.id), revoked_by=str(admin_user.id))

Revocation sets is_active=False, stamps revoked_at, and logs the revoked_by user. The row is kept for audit — there is no hard delete.

Enforcement at query time

Grants are enforced by the CRUD query pipeline. When a subcontractor queries a resource, Craft Easy:

  1. Resolves their active incoming grants for this resource.
  2. Rewrites the query to filter by the union of the owners' tenant IDs intersected with the grant's tag scope.
  3. Applies the usual feature/attribute checks on top — a grant never bypasses feature guards, so the subcontractor must also have the matching features in their own access groups.

This means three conditions must hold before a subcontractor can read (or write) a row:

  1. An active SubcontractorAccess grant covers the resource, permission and tags.
  2. The subcontractor's user has the required feature for that endpoint.
  3. The row carries one of the grant's scope_tags.

Any one of the three failing is a 403.

Endpoints

The grants are exposed as a regular Craft Easy resource at /subcontractor-access (see craft_easy.routes.subcontractor_access):

Method Path Purpose
GET /subcontractor-access/ List grants visible to the caller.
POST /subcontractor-access/ Create a grant (feature subcontractor_access.create).
PATCH /subcontractor-access/{id} Update scope, permissions or validity.
DELETE /subcontractor-access/{id} Revoke the grant.

The owner tenant is the only one allowed to create, update or revoke — the subcontractor can only read.

Audit trail

Every create/update/revoke emits an AuditEntry through the usual CRUD pipeline with:

{
  "resource": "subcontractor_access",
  "operation": "create",
  "user_id": "...",
  "changes": {...}
}

Grants are also logged at INFO level by SubcontractorAccessService so they surface in application logs even before the audit pipeline is queried. See Audit Logging.

When not to use subcontractor access

Subcontractor access is the right tool when:

  • The subcontractor is a separate, paying tenant of the same platform.
  • The collaboration is scoped by tags (a site, a project, a region).
  • The collaboration has a natural end date.

It is the wrong tool when:

  • The collaboration is permanent and you just want to merge tenants.
  • The subcontractor is actually a user of the owner tenant — use an access group instead.
  • You need write access to fields the owner has marked as private — create a dedicated integration user on the owner side with its own access group.