Skip to content

Simplify policy import model: replace entriesAdditions and importsAliases with entry-level references #2423

@thjaeckle

Description

@thjaeckle

Summary

The Ditto 3.9.0 policy import model (not yet released) introduces several new concepts to enable multi-level policy template hierarchies:

  • entriesAdditions on imports — merge additional subjects/resources/namespaces into imported entries
  • allowedImportAdditions on template entries — control what importing policies may add
  • transitiveImports on imports — explicit multi-level resolution (Support transitive resolution of policy imports via transitiveImports #2420)
  • importsAliases on the policy — fan out subject operations to multiple entries additions targets

While powerful, this results in a complex model with multiple interacting concepts spread across different parts of the policy. This issue proposes a simplification that achieves the same functionality with fewer, more intuitive building blocks.

Proposal

Replace entriesAdditions, importsAliases, and importReference/alias with a single entry-level references array.

references on policy entries

An entry can declare a references array containing objects that point to other entries. Each reference is either:

  • Import reference ({"import": "policyId", "entry": "label"}) — references an entry in an imported policy. The referencing entry inherits resources and namespaces from the referenced template entry, while defining its own local subjects (and optionally resources/namespaces if the template permits via allowedImportAdditions).
  • Local reference ({"entry": "label"}) — references another entry within the same policy. The referencing entry inherits subjects, resources, and namespaces from the referenced local entry. This replaces importsAliases without introducing a separate concept: shared subjects live in a single entry, referenced by others.

An entry can have multiple references (both import and local), and all are resolved additively at enforcement time.

What changes

Removed concept Introduced in Replaced by
entriesAdditions on imports #2221, PR #2347 local subjects/resources/namespaces on entry
allowedImportAdditions validation for aliases PR #2419 pre-enforcer validates import references in references array
importsAliases on policy #2402, PR #2403 local references pointing to a shared-subjects entry

What stays

Concept Reason
entries filter on import backward-compatible entry selection for policies not using references
importable (implicit/never) template author controls which entries are importable and which are never available
transitiveImports explicit control over multi-level resolution depth (no surprises)
allowedImportAdditions template author retains control over what consumers may customize

Example: 3-level policy hierarchy

A fleet management scenario with three levels:

  • Template defines roles with resources (what a driver can do)
  • Intermediate assigns regional drivers (who is a driver)
  • Leaf is the actual thing's policy (combines both)

Current approach (Ditto 3.9.0 as initially implemented)

Template (acme:fleet-roles):

{
  "policyId": "acme:fleet-roles",
  "entries": {
    "driver": {
      "subjects": {},
      "resources": {
        "thing:/features/location": { "grant": ["READ"], "revoke": [] },
        "thing:/features/fuel": { "grant": ["READ"], "revoke": [] },
        "message:/features/fuel/inbox": { "grant": ["WRITE"], "revoke": [] }
      },
      "namespaces": ["acme.vehicle"],
      "allowedImportAdditions": ["subjects"],
      "importable": "implicit"
    }
  }
}

Intermediate (acme:fleet-west):

{
  "policyId": "acme:fleet-west",
  "imports": {
    "acme:fleet-roles": {
      "entriesAdditions": {
        "driver": {
          "subjects": {
            "oauth2:alice@acme.com": { "type": "employee" },
            "oauth2:bob@acme.com": { "type": "employee" }
          }
        }
      }
    }
  },
  "entries": {}
}

The intermediate has no inline entries — it only defines entriesAdditions on its import. The subjects and the entry they target are nested inside the import declaration. The intermediate's structure is opaque: reading it doesn't reveal what entries it provides.

Simplified approach (using references)

Template (acme:fleet-roles) — unchanged:

{
  "policyId": "acme:fleet-roles",
  "entries": {
    "driver": {
      "subjects": {},
      "resources": {
        "thing:/features/location": { "grant": ["READ"], "revoke": [] },
        "thing:/features/fuel": { "grant": ["READ"], "revoke": [] },
        "message:/features/fuel/inbox": { "grant": ["WRITE"], "revoke": [] }
      },
      "namespaces": ["acme.vehicle"],
      "allowedImportAdditions": ["subjects"],
      "importable": "implicit"
    }
  }
}

Intermediate (acme:fleet-west):

{
  "policyId": "acme:fleet-west",
  "imports": {
    "acme:fleet-roles": {}
  },
  "entries": {
    "driver": {
      "references": [
        { "import": "acme:fleet-roles", "entry": "driver" }
      ],
      "subjects": {
        "oauth2:alice@acme.com": { "type": "employee" },
        "oauth2:bob@acme.com": { "type": "employee" }
      }
    }
  }
}

The intermediate declares an explicit entry "driver" with a references entry pointing to the template. Resources and namespaces are inherited; subjects are local. The policy is self-describing — reading it reveals it has a "driver" entry linked to the template.

Leaf (acme.vehicle:truck-42):

{
  "policyId": "acme.vehicle:truck-42",
  "imports": {
    "acme:fleet-west": {
      "transitiveImports": ["acme:fleet-roles"]
    }
  },
  "entries": {
    "driver": {
      "references": [
        { "import": "acme:fleet-west", "entry": "driver" }
      ],
      "subjects": {
        "oauth2:charlie@acme.com": { "type": "temp-driver" }
      }
    },
    "owner": {
      "subjects": { "oauth2:fleet-admin@acme.com": { "type": "admin" } },
      "resources": { "policy:/": { "grant": ["READ", "WRITE"], "revoke": [] } }
    }
  }
}

Same resolved result: resources from template + subjects from intermediate (alice, bob) + subjects from leaf (charlie).

Multi-namespace fan-out example (using local references)

A power plant template with entries scoped to different namespaces:

{
  "policyId": "energy:plant-roles",
  "entries": {
    "reactor-operator": {
      "subjects": {},
      "resources": { "thing:/features/reactor": { "grant": ["READ", "WRITE"], "revoke": [] } },
      "namespaces": ["plant.reactor"],
      "allowedImportAdditions": ["subjects"]
    },
    "turbine-operator": {
      "subjects": {},
      "resources": { "thing:/features/turbine": { "grant": ["READ", "WRITE"], "revoke": [] } },
      "namespaces": ["plant.turbine"],
      "allowedImportAdditions": ["subjects"]
    }
  }
}

Consuming policy with local reference for shared subjects:

{
  "imports": { "energy:plant-roles": {} },
  "entries": {
    "operators": {
      "subjects": { "alice": { "type": "engineer" } }
    },
    "reactor-op": {
      "references": [
        { "import": "energy:plant-roles", "entry": "reactor-operator" },
        { "entry": "operators" }
      ]
    },
    "turbine-op": {
      "references": [
        { "import": "energy:plant-roles", "entry": "turbine-operator" },
        { "entry": "operators" }
      ]
    }
  }
}

PUT /entries/operators/subjects/alice adds alice as a standard single-entry operation. At enforcement time, both reactor-op and turbine-op inherit alice's subject via local reference — each retaining its own namespace scope.

No fan-out logic, no alias concept, no special REST semantics. Standard CRUD on the "operators" entry, with references resolved at enforcement time.

Side-by-side comparison

Old (entriesAdditions) New (references)
Subject declaration nested in imports.*.entriesAdditions.*.subjects directly in entries.*.subjects
Entry visibility intermediate has no entries (opaque until resolution) intermediate has explicit entries (self-describing)
Entry selection entries filter on the import + entriesAdditions keys entries filter (backward compat) or references on entries
Fan-out (multi-entry) separate importsAliases top-level concept local references to a shared-subjects entry
Subject API path PUT /entries/{alias}/subjects/... (ambiguous REST) PUT /entries/{shared}/subjects/... (standard CRUD)
Reference multiplicity importReference is singular references array supports N references (import + local)
Concepts to learn entriesAdditions, allowedImportAdditions, importsAliases, entries filter, importable, importReference, alias references, allowedImportAdditions, entries filter, importable

Resolution semantics

  • Each import reference in references inherits resources, namespaces, allowedImportAdditions, and importable from the referenced entry
  • Each local reference inherits subjects, resources, and namespaces from the referenced entry
  • Local subjects (and optionally resources/namespaces if allowedImportAdditions permits) are merged with the inherited values
  • transitiveImports on the import controls whether the imported policy's own reference chains are resolved (explicit opt-in, no surprises)
  • Cycle detection via visited set + depth limit (same as current implementation)
  • Entries with no subjects or no resources after resolution are filtered out from the enforcer (optimization: they contribute nothing to access decisions)

REST API

The references field is exposed at:

  • GET /api/2/policies/{policyId}/entries/{label}/references — retrieve the references array
  • PUT /api/2/policies/{policyId}/entries/{label}/references — set the references array
  • DELETE /api/2/policies/{policyId}/entries/{label}/references — remove all references

No special alias endpoints. No fan-out REST semantics. Standard CRUD.

Metadata

Metadata

Assignees

Projects

Status

Done

Relationships

None yet

Development

No branches or pull requests

Issue actions