Skip to content
← All notes
TechnicalSeptember 30, 202612 minute read

Declaring Service Access with Manifest Files — Part 4

Building a Permission System for Users and Services — Part 4

An authorized registrar applies a target service declaration. The target receives its own credential and uses its own grants.

In part 3, we had a vocabulary for permissions and a way to assign them. Services defined capabilities. Organizations arranged user grants through groups. Services received their own grants directly.

Then we had to add another service.

What information would Accounts need? Where would a developer record it? How would another developer repeat the setup on a different machine? And who would have authority to apply it?

Those questions led to the manifest, but the file was not the whole solution. We also needed to explain the relationship between what a service declares and what the running system permits.

Let us build that relationship in order.

We will use Example Sync, the fictional service introduced in Part 1. It updates item details through Inventory and exposes a way to read the status of its jobs. All identities, namespaces, and credential labels below are sample values.

Adding a service meant supplying more than its name

Imagine setting up Example Sync for the first time.

Accounts needs a stable identity for it. It needs to know which permissions describe Example Sync's own operations. It also needs to know which existing operations Example Sync wants to call elsewhere.

The service then needs a credential so that it can authenticate.

Those pieces can be entered manually. An administrator could use an internal interface to create the identity, add the permission definitions, assign grants, and provision the credential.

That is a valid registration method. It can work well when the number of services is small and changes are infrequent.

But now ask another developer to set up the same service locally.

Where do they find the complete list? The service source can reveal its outgoing calls, but discovering required access by reading every call site is unnecessary work. It also gives the developer a chance to miss one.

A central hardcoded catalogue is another possible arrangement. It makes the list easy for Accounts to load, but it couples service additions to a central code change. The declaration is also separated from the service change that motivated it.

These are useful comparisons, not a claim that we deployed every option. The requirement I cared about was a reviewable record that could travel with the service.

We chose a declarative file beside the service.

A file makes the intended setup visible

The first useful declaration is small. This fragment gives our teaching service a stable identity and descriptive metadata:

service:
  activate: true
  id: svc:example-sync
  bare-id: example-sync
  name: Example Sync
  description: Scheduled catalogue updates and job status

This is a fragment, not a complete manifest. We will add the permission and credential sections as their purposes become clear.

The service ID is the identity used by the access system. A descriptive name helps a developer understand its purpose. Renaming a description is different from changing the identity that has registered grants and credentials.

I wanted that distinction visible in the file rather than hidden in a setup conversation.

A versioned declaration also makes review easier. When Example Sync adds an outgoing operation, the requested access can change beside the implementation. A reviewer can ask whether the new grant is necessary for the new behavior.

The same file can be used as input to local registration tooling. It does not need to contain local secret values to be useful.

I also had auditing and a possible internal developer portal in mind. Structured metadata can support those tools without making a portal a prerequisite for understanding the service.

There is a limit to what the declaration tells us. It describes intended access. It does not prove which operations the service actually called last week. That is an observation question, which needs runtime evidence.

A manifest is useful because it makes one kind of information explicit. It should not be expected to supply every kind.

The service can define an operation without receiving its grant

Example Sync exposes job status. Ada may need to see whether the last scheduled update completed. Another service may need the same status for its own work.

That means Example Sync defines two permissions in our sample vocabulary:

provides:
  merge: true
  claims:
    - id: user:sync:job:read
      description: Read job status as a user
    - id: service:sync:job:read
      description: Read job status as a service

This fragment belongs under the manifest's permissions section.

The service provides the operation and owns its meaning. It knows which endpoint reads job status and which resource restrictions that endpoint must apply.

Does declaring user:sync:job:read give Example Sync a user permission? No. It publishes a permission definition that organizations can use when assigning user responsibilities.

Does defining service:sync:job:read mean every service can read job status? No. A caller still needs the appropriate grant.

We separated definition from assignment because the two actions have different owners. The provider defines what a capability means. An authorized access-management operation assigns that capability to a caller.

Part 3 used the same distinction between Inventory's operations and an organization's groups. The manifest makes the provider side visible.

Outgoing calls need a second list

Now follow Example Sync's scheduled work.

It needs to edit an item through Inventory. Inventory owns that operation, represented in the series by service:catalog:item:edit.

Example Sync does not redefine item editing. It requests a grant for the existing permission.

That belongs in a different section:

requires:
  merge: false
  claims:
    - service:catalog:item:edit
    - service:identity:permission:read

The second grant represents the Accounts permission-lookup operation in our sample vocabulary. When the service needs to ask Accounts about a caller's grants, its outgoing call needs authority too.

The two sections therefore describe opposite sides of a service contract:

Section What it records
provides Permission definitions for operations this service exposes.
requires Grants this service requests for operations it calls.

Follow one key through the system. Inventory defines service:catalog:item:edit. Example Sync declares that it requires it. Accounts stores the registered grant relationship. Inventory checks the requirement when Example Sync sends a request.

Those steps are connected, but none should silently stand in for another.

A misspelled or unknown required permission cannot become an authorized capability simply because a manifest asks for it. The definition must already be available for assignment. This gives registration an order: establish the provider's definitions before registering a consumer that requires them.

It also gives a review question. If a new service requests a broad catalogue wildcard but only calls item editing, does its job justify the broader authority?

The file makes that question easier to ask. It does not answer it for the reviewer.

Editing the declaration does not change live access

At this point, the file says what Example Sync provides and requests.

We still have not given it authority.

This was an important boundary for me. Anyone able to propose a code change can propose a different permission list. That proposal must not authorize itself.

The manifest is the developer's record of intended configuration. Accounts holds the registered state for an environment.

A registration tool reads the file, validates its structure, and translates it into the service-registration API's request shape. Accounts authenticates the caller applying that request and checks the caller's existing authority.

The tool does not make a permission request trusted by converting YAML to JSON.

Here is the receiving boundary in pseudocode. The request body is the structured declaration produced by registration tooling, not a raw YAML file:

def apply_service_declaration(request):
    registrar = authenticate_service(request.token)
    require_registration_authority(registrar)

    declaration = validate_declaration(request.body)
    validate_requested_permission_definitions(declaration)
    return apply_registered_state(declaration)

The requested grants belong to the target service. The permission to apply them belongs to the registrar.

If the registrar lacks that authority, writing a larger requires list does not help. The protected registration operation stops before applying the requested access.

If the file changes but no authorized update is applied, the registered state does not change. This is why reviewing the declaration and applying the declaration are separate parts of the workflow.

Who registered the first service?

There is an obvious question here. If registration needs an existing service identity and grant, where does the first authorized caller come from?

We used Accounts itself as a known service identity for the initial administrative role.

That identity is established as part of trusted system setup. It gives the registration process a starting point outside ordinary self-service requests. After that, service registration uses the protected operations and the same identity model.

I liked that Accounts did not need an unrelated kind of administrative principal merely because it owned registration. It is also a service, with an identity and explicit authority.

There are still two roles during a registration request:

  1. Accounts, or another authorized registration caller, applies the declaration.
  2. Example Sync is the target described by the declaration.

Do not merge those roles just because both use service tokens.

The credential used to authenticate the registrar must not become Example Sync's runtime credential. The new service receives a credential for its own identity and operates with its own grants.

The diagram follows that separation through registration and first use.

An authorized registrar applies Example Sync's declaration to Accounts. Accounts registers the target identity and grants, and a separate target credential lets Example Sync obtain a token for its Inventory request.

Registration authority belongs to the caller applying the manifest. Runtime authority belongs to the registered target service.

This also explains why "let every service register itself on startup" is not a complete authorization design. A process can submit a declaration, but the authority to approve the requested access must come from an already trusted path.

Automating setup is useful. It does not remove the approval boundary.

A registered identity still needs a credential

Once Example Sync is registered, it needs a way to prove that it is Example Sync.

A client ID identifies the service. A client secret is a credential for that identity. The service authenticates to Accounts with those values and receives an access token for the intended receiver.

Here is an illustrative request body:

{
  "client_id": "svc:example-sync",
  "client_secret": "<runtime-supplied-secret>",
  "audience": "svc:example-inventory"
}

The secret value is supplied at runtime. It does not belong in the versioned manifest.

The manifest can name the credential entry to manage:

secrets:
  name: example-runtime

That name helps identify the credential. It is not the credential value itself.

Keep the three objects separate. The service ID names the principal. The client secret authenticates that principal to Accounts. Accounts' private signing key signs the issued token.

Example Sync does not receive the signing key when it receives a client secret. That is the distribution of authority we developed in Part 2.

Now follow the resulting token into Inventory. Inventory verifies the token, identifies Example Sync, checks the item-edit requirement, and applies the operation within its permitted target context.

Registration prepared the identity and grants. Authentication supplied the token. The receiver still makes the runtime access decision.

Why one identity can have several credentials

I also wanted to avoid copying one permanent credential into every place that needed to use a service identity.

A runtime deployment and a short administrative task have different credential-management needs. Naming credentials lets us manage them separately while preserving the service identity.

Accounts supports multiple credentials for a service and stores secret hashes. A credential can have an expiry and can be revoked independently.

For a registration task, we can provision a credential with a lifetime suited to that task. The exact interval is an operational choice, not part of the permission model. After its expiry or revocation, that credential cannot be used for further successful authentication.

Does a differently named secret give a process different permissions?

No. Two credentials for one identity still authenticate the same identity. Their names do not create separate grant sets. If two workloads need different authority, the identity and grant design must express that difference.

This was a useful limit to make explicit. Multiple credentials help with lifecycle management and attribution. They are not a substitute for separating principals with different responsibilities.

Credential expiry and token expiry also have different effects. Revoking a client credential controls further authentication using that credential. An access token already issued follows its own validation and authorization rules.

Likewise, changing a registered grant changes the authority the access system resolves. It is not the same operation as deleting a credential or changing a signing key.

A service lifecycle contains all of these concerns. Treating them as one "secret rotation" problem would make their effects difficult to explain.

An update needs more meaning than a new list

The first registration is only the start. Services change.

Suppose Example Sync originally reads item details and reads stock movements. A later version still reads items, but it needs item editing instead of stock-movement access.

Here are the old and new lists:

existing = {
    "service:catalog:item:read",
    "service:catalog:stock-movement:read",
}

declared = {
    "service:catalog:item:read",
    "service:catalog:item:edit",
}

What should applying the second declaration do?

One answer is to add its entries and preserve everything already registered. Another is to make the declaration the complete list for that relationship.

We needed both meanings, so the permission sections carry a merge flag.

The difference is visible with set operations:

merged = existing | declared
replaced = declared.copy()

assert "service:catalog:stock-movement:read" in merged
assert "service:catalog:stock-movement:read" not in replaced
assert "service:catalog:item:edit" in merged
assert "service:catalog:item:edit" in replaced

Merge is useful when a change is intentionally additive. Replacement is useful when the file should express the complete intended set.

The cost of merge is that the current file may not contain every entry retained in the environment. Removing a line from an additive declaration is not a request to remove its previously registered relationship.

The cost of replacement is that omissions matter. The person applying it must intend the declared list to be complete.

Those are different update contracts. Neither should be inferred merely from the presence of a list.

The choice applies independently to provided definitions and required grants. Replacing a service's provided list changes its association with those definitions. It does not mean erasing the entire permission catalogue for every other service.

That distinction matters when other callers already refer to a capability. Provider relationships, caller grants, and global definitions must not be described as one undifferentiated list.

Put the pieces together only after their purposes are clear

We can now read a complete example without asking each field to explain itself.

This manifest describes Example Sync's identity, its job-status operations, its outgoing requirements, and a named runtime credential:

service:
  activate: true
  id: svc:example-sync
  bare-id: example-sync
  name: Example Sync
  description: Scheduled catalogue updates and job status

permissions:
  provides:
    merge: true
    claims:
      - id: user:sync:job:read
        description: Read job status as a user
      - id: service:sync:job:read
        description: Read job status as a service
  requires:
    merge: false
    claims:
      - service:catalog:item:edit
      - service:identity:permission:read

secrets:
  name: example-runtime

The file does not contain a private signing key, a runtime secret value, or the authority to approve itself.

An authorized caller applies it. The target then authenticates with its own credential. Receiving services continue to verify tokens and evaluate their own operation requirements.

That gives us a record we can review, reuse for setup, and update with an explicit meaning.

It also creates a consistency responsibility. The declaration, registered state, and endpoint policies describe connected parts of the same contract. A file can be syntactically valid while requesting the wrong capability for the service's job.

That is why I wanted the manifest near the implementation change. It gives the reviewer a concrete place to inspect the access consequences.

We now have identities, grants, and a registration process. The next developer still needs to turn them into a protected endpoint without copying the same checks into every handler.

Part 5 (not yet linked) starts with that repeated work and shows how we moved it into common request components.

Continue the conversation

Comments

Write a comment

Checking this browser…

Use 1 to 80 characters. We save this name with your verified browser session. To choose another name, select Forget this browser.

Plain text only. Maximum 500 characters. Comments cannot be edited. 0/500

Waiting for the security check…

No comments yet. You can start the conversation.