Files
probo/contrib/claude/ownership.md
Sacha Al Himdani 6a4f124adb
Some checks failed
github / Analyze (go) (push) Has been cancelled
github / Analyze (actions) (push) Has been cancelled
github / Analyze (javascript-typescript) (push) Has been cancelled
make / build-apps (push) Has been cancelled
make / probod binary (darwin/amd64) (push) Has been cancelled
make / probod binary (freebsd/amd64) (push) Has been cancelled
make / probod binary (linux/amd64) (push) Has been cancelled
make / probod binary (openbsd/amd64) (push) Has been cancelled
make / probod binary (windows/amd64) (push) Has been cancelled
make / probod binary (darwin/arm64) (push) Has been cancelled
make / probod binary (freebsd/arm64) (push) Has been cancelled
make / probod binary (linux/arm64) (push) Has been cancelled
make / probod binary (openbsd/arm64) (push) Has been cancelled
make / probo-agent (darwin/amd64) (push) Has been cancelled
make / probo-agent (freebsd/amd64) (push) Has been cancelled
make / probo-agent (linux/amd64) (push) Has been cancelled
make / probo-agent (windows/amd64) (push) Has been cancelled
make / probo-agent (darwin/arm64) (push) Has been cancelled
make / probo-agent (freebsd/arm64) (push) Has been cancelled
make / probo-agent (linux/arm64) (push) Has been cancelled
make / probo-agent (windows/arm64) (push) Has been cancelled
make / docker (amd64) (push) Has been cancelled
make / docker (arm64) (push) Has been cancelled
make / snapshot-scan (push) Has been cancelled
make / build-probod (push) Has been cancelled
make / build-probo-agent (push) Has been cancelled
make / lint-go (push) Has been cancelled
make / lint-js (push) Has been cancelled
make / lint-swift (push) Has been cancelled
make / lint-shell (push) Has been cancelled
make / test (push) Has been cancelled
make / test-e2e (push) Has been cancelled
trufflehog / scan (push) Has been cancelled
Replace third-party owners with administrators
Migrate business and security owners into a shared administrators list across GraphQL, MCP, CLI, n8n, and the console.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-31 16:48:36 +02:00

6.6 KiB

Resource ownership

Status: provisional. This documents the pattern the codebase follows today. The team has not formally ratified profile-based ownership over identity-based ownership; treat this guide as the default for new work until that decision is revisited.

Model

Organization-scoped resources name a membership profile as owner — the person's membership in that organization — not the global identity.

Concept GID entity type Scope
Identity Identity Global person (login, sessions)
Membership profile MembershipProfile Person within one organization

Ownership answers: who in this org is responsible for this resource? That is always a membership profile, even when the UX speaks in terms of "people".

Layer conventions

Use the same shape across DB, services, GraphQL, and console pickers.

Layer Convention
Database column owner_profile_id TEXT REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT
Go struct field OwnerID with `db:"owner_profile_id"` (name stays OwnerID; tag names the column)
GraphQL input ownerId: ID on create/update mutations
GraphQL output owner: Profile resolved via profile dataloader
Validation validator.GID(coredata.MembershipProfileEntityType)
Service create/update profile.LoadByID inside the transaction; verify profile belongs to the resource's organization_id when not already enforced by scope. Self-service enroll accepts IdentityID and uses LoadByIdentityIDAndOrganizationID; admin create uses explicit OwnerID only
Console people picker PeopleSelectField — value is Profile.id

Resources that follow this pattern

  • Assets, data (datum), risks, obligations, findings
  • Devices (ITAM) — aligned with compliance resources as of the devices table introduction

GraphQL resolver pattern

Store only the profile GID on the GraphQL type; resolve owner in a field resolver. Authorize the profile GID only — do not re-authorize the parent resource for the owner field (parent access is already established).

Default for new work is a nullable owner (OwnerID *gid.GID, GraphQL owner: Profile). Set the embedded profile only when present so the resolver nil guard is live (device, risk, finding):

// types — set only when present
if resource.OwnerID != nil {
	obj.Owner = &Profile{ID: *resource.OwnerID}
}

// resolver — authorize profile, then load
if obj.Owner == nil {
	return nil, nil
}

if _, err := r.authorize(ctx, obj.Owner.ID, iam.ActionMembershipProfileGet); err != nil {
	return nil, err
}

owner, err := loaders.Profile.Load(ctx, obj.Owner.ID)

For a required owner (OwnerID gid.GID, GraphQL owner: Profile! — asset, datum, obligation), always embed Owner: &Profile{ID: …} in the constructor and omit the obj.Owner == nil guard.

Do not reverse-lookup identity → profile at read time when the profile id is already stored.

Employee and self-service flows

Authenticated users are identities. Device create vs enroll:

  • enrollDevice (employee self-service): resolver passes identity.ID; service resolves the membership profile with LoadByIdentityIDAndOrganizationID and always sets that profile as owner.
  • createDevice (admin): resolver passes only the optional ownerId from the picker. No identity default — omitted/null means unowned.

List/count filters that key on owner_profile_id (e.g. viewer.enrolledDevices) may still resolve identity → profile at the resolver until a list-by-identity helper exists.

IAM when policies compare identity

Some policies match principal.id (identity) to a resource attribute, e.g. ITAM employee access to own devices:

ownerCondition = policy.Equals("principal.id", "resource.owner_id")

Storage is profile-based, but the policy still compares identities. Bridge in AuthorizationAttributes with two entity queries — no cross-entity JOINs (see coredata.md):

// Step 1 — devices table only
SELECT id, organization_id, owner_profile_id
FROM devices
WHERE id = ANY(@resource_ids::text[])

// Step 2 — profiles table only (e.g. MembershipProfile.AuthorizationAttributes)
SELECT id, identity_id
FROM iam_membership_profiles
WHERE id = ANY(@profile_ids::text[])

// Step 3 — map in Go: attrs["owner_id"] = identityByProfileID[ownerProfileID]

Policy code stays unchanged; only the attributer translates profile storage → identity comparison.

Checklist for a new owned resource

  1. Migration: owner_profile_id FK to iam_membership_profiles.
  2. Entity struct: OwnerID with owner_profile_id db tag.
  3. GraphQL: ownerId input, owner: Profile output with forceResolver.
  4. Service: validate MembershipProfileEntityType; load profile in tx. For self-service enroll, accept IdentityID and resolve with LoadByIdentityIDAndOrganizationID. Admin create uses explicit OwnerID only (nil means unowned).
  5. Types: for nullable owners, set Owner only when OwnerID != nil; for required owners (Profile!), always embed Owner: &Profile{ID: …}.
  6. Resolver owner field: nil-guard when nullable; authorize with ActionMembershipProfileGet on the profile GID, then loaders.Profile.Load (no parent-resource authorize).
  7. If employee self-service lists by caller: resolve identity → profile (resolver or service) and query by owner_profile_id.
  8. If IAM compares principal.id to owner: bridge identity in AuthorizationAttributes (see above).

Open question (team review)

An alternative is storing identity GIDs for person-centric resources (e.g. devices tied to a person across org context) and accepting profile ids at the API boundary via normalization. Compliance resources migrated from peoples to membership profiles in 20260203T132700Z.sql; devices were added on the profile model to stay consistent.

When adding ownership to a new resource, default to profile unless there is a documented reason to anchor on identity. Raise identity-based ownership in design review if the resource lifecycle is person-global rather than org-scoped.