Add device data model and ITAM service
Introduce device, posture, and enrollment-token entities with ITAM service policies for agent-managed fleet inventory. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -509,6 +509,17 @@ json.Unmarshal(plaintext, &c.Connection)
|
||||
- **Return plaintext tokens once.** For SHA-256-hashed tokens, return the raw token to the caller at creation time only. After that, the application only ever sees the hash.
|
||||
- **Migration columns.** When adding a new sensitive column, always use `BYTEA`. Never add `DEFAULT` on sensitive columns.
|
||||
|
||||
## Resource ownership
|
||||
|
||||
Org-scoped owners are stored as `owner_profile_id` → `iam_membership_profiles`,
|
||||
not identity GIDs. GraphQL uses `ownerId`; resolvers load `owner: Profile` via
|
||||
dataloader. Employee self-service resolves identity → profile at the API
|
||||
boundary; IAM may bridge profile storage back to identity in
|
||||
`AuthorizationAttributes`.
|
||||
|
||||
See [`ownership.md`](ownership.md) for the full pattern (provisional — pending
|
||||
team review).
|
||||
|
||||
## New entity checklist
|
||||
|
||||
1. **Entity file** (`entity.go`) — struct with `db` tags, slice type alias, `LoadByID`, `Insert`, `Update`, `Delete`, `CursorKey`, `AuthorizationAttributes`
|
||||
|
||||
158
contrib/claude/ownership.md
Normal file
158
contrib/claude/ownership.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 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`](../../apps/console/src/components/form/PeopleSelectField.tsx) — value is `Profile.id` |
|
||||
|
||||
### Resources that follow this pattern
|
||||
|
||||
- Assets, data (datum), risks, obligations, findings
|
||||
- Third parties (`business_owner_profile_id`, `security_owner_profile_id`)
|
||||
- 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, third-party owners):
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```go
|
||||
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`](coredata.md#no-cross-entity-joins)):
|
||||
|
||||
```go
|
||||
// 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`](../../pkg/coredata/migrations/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.
|
||||
|
||||
## Related
|
||||
|
||||
- [`coredata.md`](coredata.md) — entity structs, migrations, `AuthorizationAttributes`
|
||||
- [`authorization.md`](authorization.md) — policy conditions and attributers
|
||||
- [`validation.md`](validation.md) — `validator.GID` entity-type checks
|
||||
- [`graphql.md`](graphql.md) — `@goField(forceResolver: true)` for `owner`
|
||||
Reference in New Issue
Block a user