Skip to content

Implementing AttributeResolverService

Binary plugins that declare custom resource_types in their manifest MUST implement the AttributeResolverService gRPC interface. The host calls your resolver whenever a policy needs attribute values for an instance of a resource type your plugin owns.

This page documents the host/plugin contract: what the host guarantees, what your plugin MUST declare, and what happens when the contract is violated.

Your resolver has exactly two RPCs and each has a fixed call site.

RPCWhen the host calls itHow often
GetSchemaOnce at plugin load, after InitOnce per plugin load
ResolveResourceOnce per instance-level authorization check that references one of your resource typesOnce per matching auth request

Nothing else calls your resolver. In particular:

  • Type-level capability pre-flight (e.g. CanPerformAction(subject, "write", "widget", scope)) never calls ResolveResource — it only inspects the subject and environment.
  • Other plugins’ resolvers are never chained into yours. Re-entrance is detected and fail-closed.
  • The host does not call GetSchema at runtime; your schema is cached at load time.

GetSchema returns the set of resource types your plugin owns and, for each type, the set of attributes your plugin will return at runtime.

func (r *widgetResolver) GetSchema(_ context.Context, _ *pluginv1.GetSchemaRequest) (*pluginv1.GetSchemaResponse, error) {
return &pluginv1.GetSchemaResponse{
ResourceTypes: map[string]*pluginv1.ResourceTypeSchema{
"widget": {
Attributes: map[string]pluginv1.AttributeType{
"type": pluginv1.AttributeType_ATTRIBUTE_TYPE_STRING,
"owner": pluginv1.AttributeType_ATTRIBUTE_TYPE_STRING,
},
},
},
}, nil
}

The host uses your schema response for two things:

  1. Load-time policy validation. Every policy in your manifest whose DSL references resource.<type>.<attr> is checked against your schema. If the policy references an attribute you did not declare, the plugin fails to load.
  2. Runtime attribute filtering. When ResolveResource returns, the host drops any attribute not listed in GetSchema and increments a Prometheus counter. Undeclared attributes are silently discarded.
RequirementDescription
MUST declare every runtime attributeAny attribute ResolveResource may ever return MUST appear in the GetSchema response for that resource type
MUST be deterministicThe host caches your response — schemas MUST NOT vary across calls
SHOULD match AttributeType to what you returne.g. don’t declare STRING and return a BoolValue

ResolveResource is called with a concrete resource instance ID and MUST return the attribute values the host needs for policy evaluation.

func (r *widgetResolver) ResolveResource(_ context.Context, req *pluginv1.ResolveResourceRequest) (*pluginv1.ResolveResourceResponse, error) {
if req.GetResourceType() != "widget" {
return nil, status.Errorf(codes.InvalidArgument,
"test-abac-widget only resolves resource type %q, got %q",
"widget", req.GetResourceType())
}
// Look up the real instance by ID. The host never asks about fake IDs.
widget, err := r.store.Get(req.GetResourceId())
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "widget %q not found", req.GetResourceId())
}
return nil, status.Errorf(codes.Internal, "load widget: %v", err)
}
return &pluginv1.ResolveResourceResponse{
Attributes: map[string]*pluginv1.AttributeValue{
"type": {Kind: &pluginv1.AttributeValue_StringValue{StringValue: widget.Type}},
"owner": {Kind: &pluginv1.AttributeValue_StringValue{StringValue: widget.Owner}},
},
}, nil
}

The host invariants you can rely on:

GuaranteeImplication for your code
ResourceId is a real instance ID the host believes existsYou MUST NOT check for sentinel values like __preflight__ — they are never sent
ResourceType is one you declared in GetSchemaYou MAY still reject unknown types as defense in depth
The call is made only for instance-level authorizationType-level capability checks never reach you
Re-entrance is detected and fail-closedYou MUST NOT recursively call other resolvers from inside ResolveResource

Your resolver MUST surface errors through gRPC status codes. Both codes below cause fail-closed authorization — the pending auth request is denied.

ConditiongRPC codeMeaning
Instance does not exist in your backing storeNotFoundThe host treats this as “deny, resource missing”
Database error, timeout, or any infrastructure failureInternalThe host treats this as “deny, resolver infrastructure failure”
RequirementDescription
MUST return NotFound for missing instancesDo not return an empty attribute map — that is ambiguous
MUST return Internal for infrastructure failuresDo not swallow errors and return stale or default values
MUST NOT return OK for unknown instancesSilently returning empty attributes can cause a permit policy to evaluate incorrectly

If any policy in your manifest references an attribute your GetSchema response does not declare, the plugin fails to load with a structured error. The error includes the plugin name, policy name, resource type, the missing attribute name, and the list of valid attribute names for that type.

For example, if your manifest contains:

policies:
- name: widget-owner-read
dsl: |
permit(principal is character, action in ["read"], resource is widget) when {
resource.widget.creator == principal.character.id
};

but your GetSchema only declares type and owner, plugin load fails with an error like:

PLUGIN_SCHEMA_VALIDATION_FAILED: policy "widget-owner-read" references
attribute "creator" on resource type "widget" which is not in the declared
schema
plugin: test-abac-widget
policy: widget-owner-read
resource_type: widget
attribute: creator
schema_keys: [type owner]

The fix is either to rename the attribute in the policy (if it was a typo) or to add creator to your GetSchema response and wire it up in ResolveResource.

The minimal correct reference is plugins/test-abac-widget/main.go.

This fixture is deliberately retained as the project’s lightweight, deterministic reference resolver: it has no upstream dependencies and hardcodes its resolution, so the plugin ABAC integration tests can assert exact permit/forbid outcomes against a real binary over gRPC. The production resolver in core-scenes also implements this contract, but it depends on WorldService, Postgres, and seeded scene data, so it is not a substitute when you want a deterministic, dependency-free example to copy.

It demonstrates three properties every resolver SHOULD have:

  1. Schema/runtime consistencyGetSchema declares exactly the two attributes (type, owner) that ResolveResource returns. There are no extra declared attributes and no extra returned attributes.
  2. Resource-type guardResolveResource rejects any request whose ResourceType is not widget, as defense in depth against host routing bugs. This is not required by the contract (the host only calls you with declared types), but it is cheap and catches regressions.
  3. No sentinel handling — the resolver maps ResourceId directly to attributes with no special cases for synthetic IDs. The post-hardening contract guarantees ResourceId is always a real instance.
Anti-patternWhy it is wrong
Checking for sentinel IDs like __preflight__The host does not send them. Dead code at best, wrong behavior at worst
Returning attributes not in your schemaThey are silently dropped. Your policies will behave as if the attribute does not exist
Swallowing errors and returning empty AttributesA permit policy may evaluate to true on missing attributes and grant access you did not intend
Recursively calling other resolvers from inside ResolveResourceRe-entrance detection fail-closes the call
Making GetSchema dynamicThe host caches it at load time; runtime variation is ignored
Returning a different AttributeType than declaredPolicy evaluation may reject the value or produce unexpected coercions