Skip to content
Merged
54 changes: 25 additions & 29 deletions docs/content/docs/authz/custom-resources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,17 @@ You register the actions in one of two ways:
- **The admin API** (`CreatePermission`, `CreateRole`). This makes the same change one call at
a time, useful for one-off changes or scripting.

Here is the `compute/machine` example as a reconcile file. Each entry is a namespace and an
action name:
Here is the `compute/machine` example as a reconcile file. Each entry is a key in
`service.resource.verb` form:

```yaml
apiVersion: v1
kind: Permission
spec:
- namespace: compute/machine
name: get
- namespace: compute/machine
name: create
- namespace: compute/machine
name: update
- namespace: compute/machine
name: delete
- key: compute.machine.get
Comment thread
rohilsurana marked this conversation as resolved.
- key: compute.machine.create
- key: compute.machine.update
- key: compute.machine.delete
```

Apply it with a superuser credential (for example the bootstrap service account):
Expand All @@ -63,15 +59,20 @@ permissions in this same format, so you can capture the live state into a file.

A few rules the reconcile flow enforces for permissions:

- A permission is **identity only** (namespace plus name). It is added or deleted, never
updated.
- A permission is **identity only** (its key). It is added or deleted, never updated.
- Nothing is deleted by leaving it out. A permission that is in the server but missing from
the file fails the plan. To remove one, mark its entry with `delete: true`.
- A namespace must be in `service/resource` form: two non-empty parts, each lowercase
alphanumeric. So `compute/machine` works, but `compute/machine-v2` does not (the hyphen is
not alphanumeric). The action name must be alphanumeric too.
- A namespace under `app` or `app/...` is rejected, because the base schema owns those types
(see [Why a separate `user/project` namespace](#why-a-separate-userproject-namespace)).
- A key has three parts joined by dots, `service.resource.verb`. Each part is lowercase,
Comment thread
rohilsurana marked this conversation as resolved.
starts with a letter, and is at least three characters. So `compute.machine.get` works, but
`compute.machine.get-all` does not (the hyphen is not alphanumeric).
- The verb cannot be `owner`, `project`, or `granted`. The generated schema already defines
relations with those names on every resource, so a key like `compute.machine.owner` is
rejected even though it meets the rule above.
- The whole flattened name `service_resource_verb` must be at most 64 characters, the limit
SpiceDB puts on a relation name. Three parts that are each fine on their own can still be
rejected together as too long.
- A key whose service is `app` (an `app.*` key) is rejected, because the base schema owns
those types (see [Why a separate `user/project` namespace](#why-a-separate-userproject-namespace)).

### What happens when a permission is registered

Expand Down Expand Up @@ -276,21 +277,16 @@ apiVersion: v1
kind: Permission
spec:
# Per-item actions live on the resource itself, checked against compute/machine:<id>.
- namespace: compute/machine
name: get
- namespace: compute/machine
name: update
- namespace: compute/machine
name: delete
- key: compute.machine.get
- key: compute.machine.update
- key: compute.machine.delete

# Project-level capabilities live on user/project, a proxy for app/project.
# Checked against app/project:<project_id>, because there is no single
# machine to check against. Do NOT use namespace app/project here: the
# app namespaces are reserved for the base schema and are rejected.
- namespace: user/project
name: createcomputemachine
- namespace: user/project
name: listcomputemachines
# machine to check against. Do NOT use the app service here: the app
# namespaces are reserved for the base schema and are rejected.
- key: user.project.createcomputemachine
- key: user.project.listcomputemachines
---
apiVersion: v1
kind: Role
Expand Down
17 changes: 10 additions & 7 deletions docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,22 +93,25 @@ concerns users. Service users cannot be disabled today, so they are always liste

## The Permission kind

A permission is an identity: a `service/resource` namespace plus a verb. There is nothing
A permission is an identity: a key in `service.resource.verb` form. There is nothing
else to manage on it, so it is either created or deleted.

```yaml
apiVersion: v1
kind: Permission
spec:
- namespace: compute/order
name: get
- namespace: compute/order
name: legacy
- key: compute.order.get
- key: compute.order.legacy
delete: true
```

- The namespace has two parts, `service/resource`. The name is alphanumeric.
- Frontier's own permissions (the `app` namespaces) belong to the base schema. The
- The key has three parts joined by dots, `service.resource.verb`. Each part is lowercase,
starts with a letter, and is at least three characters.
- The verb cannot be `owner`, `project`, or `granted`. The generated schema already defines
relations with those names on every resource.
- The whole flattened name `service_resource_verb` must be at most 64 characters, SpiceDB's
limit on a relation name.
- Frontier's own permissions (the `app` service) belong to the base schema. The
reconciler ignores them and rejects a file entry that names one.
- A custom permission on the server that the file does not list fails the plan. Nothing
is deleted just because it is missing; deleting needs `delete: true` on the entry.
Expand Down
6 changes: 3 additions & 3 deletions docs/rfcs/0001-declarative-reconcile.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ role is a value.
| Kind | Object or value | Identity | A missing entry | How to remove |
|---|---|---|---|---|
| PlatformUser | value | principal + relation | access removed | leave the entry out |
| Permission | object | namespace + name | plan fails | set `delete: true` |
| Permission | object | key | plan fails | set `delete: true` |
| Role, custom | object | name | plan fails | set `delete: true` |
| Role, predefined | value | name | reset to the shipped definition | cannot be removed |
| Preference | value | trait name | reset to the trait default | leave the entry out, it resets |
Expand Down Expand Up @@ -207,8 +207,8 @@ entry removes that access. A user with both relations has two entries. Adding a
email that does not exist creates the user. The bootstrap service account is server-managed:
the flow skips it on the server side and rejects it in the file.

**Permission.** An entry is `{namespace, name}`, for example `compute/order` + `get`. A
permission is an identity only, so it is created or deleted, never updated. Creating one also
**Permission.** An entry is `{key}` in `service.resource.verb` form, for example
`compute.order.get`. A permission is an identity only, so it is created or deleted, never updated. Creating one also
updates the authorization schema, so it is usable in roles right away, on every pod at once. The
base `app` namespaces are server-managed.

Expand Down
81 changes: 40 additions & 41 deletions internal/reconcile/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,21 @@ import (
// KindPermission is the desired-state document kind for custom permissions.
const KindPermission = "Permission"

// PermissionSpec is one desired permission. A permission is identity only
// (namespace + name): it is added or deleted, never updated. Deleting needs
// the explicit flag; a permission that just disappears from the file fails
// the plan instead.
// PermissionSpec is one desired permission, identified by its key in
// service.resource.verb form (for example compute.order.get). A permission is
// identity only: it is added or deleted, never updated. Deleting needs the
// explicit flag; a permission that just disappears from the file fails the plan
// instead.
type PermissionSpec struct {
Namespace string `yaml:"namespace"`
Name string `yaml:"name"`
Delete bool `yaml:"delete,omitempty"`
Key string `yaml:"key"`
Delete bool `yaml:"delete,omitempty"`
}

func (s PermissionSpec) String() string {
return s.Namespace + ":" + s.Name
}
func (s PermissionSpec) String() string { return s.Key }

func (s PermissionSpec) slug() string {
return schema.FQPermissionNameFromNamespace(s.Namespace, s.Name)
// namespaceAndName splits the key into its service/resource namespace and verb.
func (s PermissionSpec) namespaceAndName() (string, string) {
return schema.PermissionNamespaceAndNameFromKey(s.Key)
}

// isBaseNamespace reports whether a namespace belongs to the base schema,
Expand All @@ -36,24 +35,31 @@ func isBaseNamespace(ns string) bool {
}

func validatePermissionSpec(s PermissionSpec) error {
if strings.TrimSpace(s.Namespace) == "" || strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("namespace and name are required")
if strings.TrimSpace(s.Key) == "" {
return fmt.Errorf("key is required")
}
ns, name := s.namespaceAndName()
if ns == "" || name == "" {
return fmt.Errorf("invalid key %q (must be in service.resource.verb form)", s.Key)
}
if isBaseNamespace(s.Namespace) {
return fmt.Errorf("namespace %q is part of the base schema, which the server manages", s.Namespace)
if isBaseNamespace(ns) {
return fmt.Errorf("key %q is part of the base schema, which the server manages", s.Key)
}
// One shared check so the reconcile plan and the CreatePermission API agree on
// what a valid custom permission is: SpiceDB grammar, no reserved verb, and a
// slug that fits SpiceDB's relation-name limit. This stops a plan that passes and
// then fails when the schema compiles.
return schema.ValidateCustomPermission(s.Namespace, s.Name)
if err := schema.ValidateCustomPermission(ns, name); err != nil {
return fmt.Errorf("invalid key %q: %w", s.Key, err)
}
return nil
}

// currentPermission is one custom permission as returned by ListPermissions.
// currentPermission is one custom permission as returned by ListPermissions,
// identified by its key.
type currentPermission struct {
ID string
Namespace string
Name string
ID string
Key string
}

type permissionOp struct {
Expand All @@ -69,39 +75,32 @@ func (o permissionOp) String() string {
return fmt.Sprintf("add permission %s", o.spec)
}

// diffPermissions returns the ops that make the current custom permissions
// match the desired spec. Every custom permission on the server must appear in
// the spec — kept, or marked delete — so nothing is ever removed by omission.
// diffPermissions returns the ops that make the current custom permissions match
// the desired spec. Every custom permission on the server must appear in the
// spec, kept or marked delete, so nothing is ever removed by omission. The key
// is the identity: it is one-to-one with the slug the server enforces, because a
// valid key's namespace parts hold no underscores.
func diffPermissions(desired []PermissionSpec, current []currentPermission) ([]permissionOp, error) {
bySlug := make(map[string]currentPermission, len(current))
byKey := make(map[string]currentPermission, len(current))
for _, c := range current {
bySlug[schema.FQPermissionNameFromNamespace(c.Namespace, c.Name)] = c
byKey[c.Key] = c
}

// The slug is the identity the server enforces (unique in the database), so
// the diff is keyed by it. Distinct namespace+name pairs can flatten to the
// same slug when a namespace part contains underscores; that is a conflict.
seen := map[string]PermissionSpec{}
accounted := map[string]struct{}{}
var adds, removes []permissionOp
for _, s := range desired {
if err := validatePermissionSpec(s); err != nil {
return nil, fmt.Errorf("invalid permission spec %s: %w", s, err)
}
slug := s.slug()
if prev, dup := seen[slug]; dup {
if prev.Namespace != s.Namespace || prev.Name != s.Name {
return nil, fmt.Errorf("permissions %s and %s collide on the same slug %q", prev, s, slug)
}
if prev, dup := seen[s.Key]; dup {
if prev.Delete != s.Delete {
return nil, fmt.Errorf("permission %s is listed both with and without delete", s)
}
continue
}
seen[slug] = s
accounted[slug] = struct{}{}
seen[s.Key] = s

cur, exists := bySlug[slug]
cur, exists := byKey[s.Key]
switch {
case s.Delete && exists:
removes = append(removes, permissionOp{action: opRemove, spec: s, id: cur.ID})
Expand All @@ -111,9 +110,9 @@ func diffPermissions(desired []PermissionSpec, current []currentPermission) ([]p
}

var unaccounted []string
for slug, c := range bySlug {
if _, ok := accounted[slug]; !ok {
unaccounted = append(unaccounted, c.Namespace+":"+c.Name)
for key := range byKey {
if _, ok := seen[key]; !ok {
unaccounted = append(unaccounted, key)
}
}
if len(unaccounted) > 0 {
Expand Down
49 changes: 24 additions & 25 deletions internal/reconcile/permission_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ func NewPermissionReconciler(client PermissionAPI, header string) *PermissionRec

func (r *PermissionReconciler) Kind() string { return KindPermission }

// Validate checks every entry, and the in-file slug conflicts, without touching
// the server, so a bad entry stops the whole file before anything applies.
// Validate checks every entry without touching the server, so a bad entry stops
// the whole file before anything applies.
func (r *PermissionReconciler) Validate(spec []byte) error {
var specs []PermissionSpec
if err := decodeSpec(spec, &specs); err != nil {
Expand All @@ -44,16 +44,12 @@ func (r *PermissionReconciler) Validate(spec []byte) error {
if err := validatePermissionSpec(s); err != nil {
return fmt.Errorf("invalid permission spec %s: %w", s, err)
}
slug := s.slug()
if prev, dup := seen[slug]; dup {
if prev.Namespace != s.Namespace || prev.Name != s.Name {
return fmt.Errorf("permissions %s and %s collide on the same slug %q", prev, s, slug)
}
if prev, dup := seen[s.Key]; dup {
if prev.Delete != s.Delete {
return fmt.Errorf("permission %s is listed both with and without delete", s)
}
}
seen[slug] = s
seen[s.Key] = s
}
return nil
}
Expand Down Expand Up @@ -99,15 +95,9 @@ func (r *PermissionReconciler) Export(ctx context.Context) (any, error) {
}
specs := make([]PermissionSpec, 0, len(current))
for _, c := range current {
specs = append(specs, PermissionSpec{Namespace: c.Namespace, Name: c.Name})
specs = append(specs, PermissionSpec{Key: c.Key})
}
sort.Slice(specs, func(i, j int) bool {
a, b := specs[i], specs[j]
if a.Namespace != b.Namespace {
return a.Namespace < b.Namespace
}
return a.Name < b.Name
})
sort.Slice(specs, func(i, j int) bool { return specs[i].Key < specs[j].Key })
return specs, nil
}

Expand All @@ -119,17 +109,26 @@ func (r *PermissionReconciler) fetchCurrent(ctx context.Context) ([]currentPermi
var current []currentPermission
for _, p := range resp.Msg.GetPermissions() {
ns, name := schema.PermissionNamespaceAndNameFromKey(p.GetKey())
if ns == "" || name == "" {
return nil, fmt.Errorf("permission %s: cannot split key %q into namespace and name", p.GetId(), p.GetKey())
// A key that does not split into service.resource.verb is not a custom
// permission this reconciler manages: it is a base or system permission, or
// comes from an older server that does not set the key. Skip it instead of
// failing the whole list. Export only emits keys it kept here, so a skipped
// permission never shows up as missing from the file either.
if ns == "" || name == "" || isBaseNamespace(ns) {
Comment thread
rohilsurana marked this conversation as resolved.
Comment thread
rohilsurana marked this conversation as resolved.
continue
}
if isBaseNamespace(ns) {
// Also skip a legacy row whose parsed parts break the grammar the reconciler
// enforces: an underscore or uppercase in a part, a reserved verb, or an
// over-long slug. No file entry could name such a row, because
// validatePermissionSpec rejects the same key, so keeping it would wedge every
// plan: it can be neither kept (it counts as unaccounted) nor deleted (a delete
// spec fails validation first). Treat it as out of scope, like a base
// permission. New rows cannot reach this state now that CreatePermission runs
// the same check, but rows created before that tightening can.
if schema.ValidateCustomPermission(ns, name) != nil {
continue
}
current = append(current, currentPermission{
ID: p.GetId(),
Namespace: ns,
Name: name,
})
current = append(current, currentPermission{ID: p.GetId(), Key: p.GetKey()})
}
return current, nil
}
Expand All @@ -139,7 +138,7 @@ func (r *PermissionReconciler) apply(ctx context.Context, op permissionOp) error
case opAdd:
_, err := r.client.CreatePermission(ctx, authReq(&frontierv1beta1.CreatePermissionRequest{
Bodies: []*frontierv1beta1.PermissionRequestBody{{
Key: schema.PermissionKeyFromNamespaceAndName(op.spec.Namespace, op.spec.Name),
Key: op.spec.Key,
}},
}, r.header))
return err
Expand Down
Loading
Loading