Skip to content
← All notes
TechnicalSeptember 26, 202613 minute read

Designing Permissions and Groups for Different Organizations — Part 3

Building a Permission System for Users and Services — Part 3

Groups combine user grants within an organization. Ada has separate responsibilities in Organizations A and B.

By the end of Part 2, Inventory could verify a token and establish the caller's organization context. Ada could work in Organization A with a token issued for A, then request the appropriate context when she moved to Organization B.

We still had not answered the question that mattered to the item operation: what is Ada allowed to do here?

Membership and permission are different. Two people can belong to the same organization and have different responsibilities. The same person can also have different responsibilities in two organizations.

I already knew I wanted groups and permissions. The design work was deciding what we should define, what an organization should control, and how to make the resulting access understandable.

Let us continue with Ada and Bayo. These people, groups, organizations, and permission strings are teaching examples. Inventory uses the sample namespace catalog.

A fixed role is easy until its meaning changes

Suppose we start with two roles: Viewer and Manager.

A Viewer can inspect items. A Manager can inspect and change them. For a small organization, that could be enough. The role names are simple, and assigning someone a role takes little explanation.

Now give Organization A a purchasing team. Some people need to record stock movements but should not change item descriptions. Add a reporting team that needs history but should not record new movements.

We can create more fixed roles. But each new combination becomes another application decision.

Now consider Organization B. It uses the word "manager" differently. Its store managers can view stock and request a change, while another team approves and applies the change.

Which meaning should our built-in Manager role have?

We were building for organizations of different sizes and structures. I did not want their internal responsibilities to be limited by a role list we had guessed at the start.

Fixed roles are still reasonable when the product has a small, stable set of responsibilities. They can make setup easier and reduce the number of decisions an administrator must make. Our concern was the number of combinations that different organizations would need over time.

We therefore separated the operations our application supports from the way an organization assigns those operations.

That separation is the foundation of the group system.

Services define capabilities; organizations arrange responsibilities

Inventory knows what reading an item means. It knows what changing item details means. Those operations belong to the application.

An organization decides which people should perform them.

For the first example, use these three capabilities:

Capability What it permits
Read items Inspect item details.
Edit items Change item details through the supported operation.
Create stock movements Record a stock movement through its supported operation.

An organization combines those capabilities into groups with names that make sense to its staff.

Organization A could create Store Managers and Item Editors. Organization B could create Catalogue Reviewers. The group name is useful to the organization, but the receiving service evaluates the underlying permissions.

That means the service does not need to recognize every customer's job title.

There is a boundary to this flexibility. An organization cannot invent a new string and make Inventory implement a new operation. Services define the permission catalogue. Organizations choose combinations from the capabilities that exist.

"Static permissions" therefore does not mean the catalogue can never grow. It means the meaning of an operation comes from the application, rather than from an arbitrary label entered by an administrator.

When Inventory adds a capability, its permission definitions can grow. An organization can then decide how that capability fits its responsibilities.

That still leaves an assignment question. Should permissions go directly to users, through groups, or through both?

Direct grants made individual access easy to express

A direct grant is straightforward.

Ada needs to edit items. Give Ada the item-edit permission. Bayo needs only to read items. Give Bayo the item-read permission.

For two people, there is little to explain. Direct assignment can be a good fit when access is mostly individual and there is little reuse.

Now suppose Organization A has ten people doing the same stock-management job. We can repeat the same list for each person. When that job changes, however, an administrator must change ten assignments and know which people should receive the change.

That is where a group helps. Define the responsibility once and reuse it.

We initially considered supporting direct user grants as well as group grants. It sounded like a useful combination: groups for normal responsibilities, direct assignments for exceptions.

But follow an exception through its removal.

Suppose Ada receives item editing from Item Editors and also through a direct assignment. An administrator removes her from Item Editors and expects the editing access to end.

The direct assignment still exists.

Here is a small set example of that alternative. These short labels are temporary so that the assignment problem is visible before we build the full permission names:

group_grants = {"item:read", "item:edit"}
direct_grants = {"item:edit"}

effective_before = group_grants | direct_grants
group_grants = set()
effective_after = group_grants | direct_grants

assert "item:edit" in effective_before
assert "item:edit" in effective_after

The union is doing exactly what we asked. The difficulty is explaining all the sources of authority to the administrator.

Supporting both mechanisms is not inherently incorrect. It means the product must make both visible and make their combined effect understandable. I did not think we needed that extra assignment path for our use case.

We chose groups for user grants.

One grant path can still express an exception

Now let Organization A create two groups.

Store Managers can read items and record stock movements. Item Editors can change item details. We will make the read relationship explicit in these early examples and develop mutation policies in Part 5 (unlinked for now).

The groups initially look like this:

store_managers = {
    "item:read",
    "stock-movement:read",
    "stock-movement:create",
}

item_editors = {
    "item:read",
    "item:edit",
}

Both Ada and Bayo belong to Store Managers. Ada also needs to edit item details.

Adding editing to Store Managers would give it to Bayo too. Instead, the organization adds Ada to Item Editors.

The effective grants are the union of the groups that each person belongs to:

ada_permissions = store_managers | item_editors
bayo_permissions = store_managers.copy()

assert "item:edit" in ada_permissions
assert "item:edit" not in bayo_permissions
assert "item:read" in ada_permissions
assert "item:read" in bayo_permissions

A group can have one member. We do not need direct user assignment just because Ada has a responsibility that nobody else has yet.

If another person later needs that responsibility, the same group can be reused. The exception becomes an ordinary assignment without changing the permission mechanism.

There is a cost. An organization with many individual exceptions can create many small groups. Those groups need useful names and clear ownership. Choosing groups does not remove administrative work.

I preferred that cost to maintaining different ways to assign the same user's access. The administrator follows one kind of relationship: user membership in an organization group, then permissions attached to that group.

The group system remains flexible because the groups belong to the organization.

Removing one source does not remove every source

Groups still need a clear rule when grants overlap.

Ada receives item-read access through Store Managers and Item Editors. Removing her from Item Editors must not remove the access she still receives through Store Managers.

Continue the previous example:

ada_after_removal = store_managers.copy()

assert "item:edit" not in ada_after_removal
assert "item:read" in ada_after_removal

We recompute the effective union. We do not treat the removal of a group as an instruction to subtract every permission that group contained.

That difference becomes important when we cache permissions in Part 7 (unlinked for now). A permission can have more than one source even though the final effective set contains only one copy.

Duplicates do not add more authority. Two sources of item-read access still permit the same operation. But either source can keep that operation available after the other is removed.

Notice what this model does not introduce: a second list of explicit denials that overrides grants. In these examples, access follows the union of positive grants. Removing one source is different from imposing an overriding denial.

The useful question for an administrator is therefore "Which groups still supply this permission?" It is not only "Which group did I just remove?"

Ada's group in A does not follow her into B

Now let Ada switch to Organization B.

The token context from Part 2 changes the organization used for the request. Her grant lookup must use that same organization.

Suppose she belongs to Catalogue Reviewers in B. That group allows reading items, but not changing them.

Her membership in Item Editors in A has no effect on B's grant set.

organization_a = {
    "ada": store_managers | item_editors,
    "bayo": store_managers.copy(),
}

organization_b = {
    "ada": {"item:read"},
}

assert "item:edit" in organization_a["ada"]
assert "item:edit" not in organization_b["ada"]

This is why a global user-to-permission lookup would be the wrong relationship for this example. It would lose the organization in which the responsibility was assigned.

The diagram shows the separate assignments. Group names and memberships belong inside their organization.

Ada and Bayo receive permissions through groups in Organization A. Ada has a separate group assignment in Organization B. Effective permissions are combined only within the selected organization.

One user identity can have different responsibilities in different organizations.

There is another boundary below organization membership. A Store Managers group does not, by itself, assign a branch or establish ownership of a particular item.

An operation permission answers what kind of work the caller may do. Resource restrictions answer which data the work may reach.

If a product needs branch-specific access, its request and data rules must express that restriction. Reusing a group for managers at several branches does not make the group name a branch filter.

Keeping these responsibilities separate lets the permission vocabulary stay reusable without promising that one string describes every data rule.

Build the permission name one question at a time

The short labels above help explain assignment, but they are not the full naming scheme.

Start with an action:

read

Read what? An item, a stock movement, or a report? The action alone is not enough.

Add the resource:

item:read

Within one small application, that may be sufficient. I considered that kind of resource-and-action format because it is short and easy to read.

But we expected more services. An "item" in one domain could mean something different in another. We needed to see which domain defined the operation.

Add the namespace:

catalog:item:read

Now the key says which area owns the meaning. We use catalog as Inventory's example namespace throughout this series.

A namespace is a naming boundary. It should help developers understand the operation even when they encounter the key in another service's manifest or an access review.

We still have the two caller types from Part 1. I wanted a user permission to be visibly different from a service permission.

Add the caller type:

user:catalog:item:read
service:catalog:item:read

That gives us the full structure:

caller-type:namespace:resource:action

Each segment exists because a shorter name left a question unanswered.

The first segment identifies the kind of caller that receives the grant. It is separate from the token audience. The audience identifies an intended token recipient; the permission key describes authority for an operation.

The service identity is separate too. svc:example-sync names a caller. service:catalog:item:read names a capability that a service caller can receive.

Those strings look related because they participate in the same system, but they do not do the same job.

Longer permission names take more space. They also give us a vocabulary that can be understood away from the endpoint that enforces it. I accepted the length because the names make ownership and intended use easier to inspect.

The naming convention helps people notice mistakes. Validation and evaluation still enforce the rules; readability is not a replacement for those checks.

Why service grants take a different assignment path

We could have extended the group system to services.

A group of services with the same responsibility could be useful in another system. I did not see enough of that pattern in our own use case.

Notifications, Inventory, and a scheduled processor usually have different jobs. We already have a stable identity for each job, and we want its access to be visible.

So services receive grants directly.

Example Sync might need to update item details. Its grant list contains:

service:catalog:item:edit

That grant belongs to the service identity. Ada's Item Editors group is not involved when Example Sync performs its own scheduled work.

This does mean that two services needing the same capability each declare that need. We accepted the repetition because it makes each service's access relationship explicit.

Users still receive grants through organization groups. Services receive grants through their registered service identity. Both are evaluated using the same permission vocabulary, but the assignment paths reflect different administrative needs.

Do not confuse a service defining a permission with receiving that permission. Inventory defines item operations for users and services. Example Sync requests permission to call one of those operations.

The provider owns the meaning. The caller receives the grant.

That distinction will become the two main lists in the manifest in Part 4.

Exact grants became repetitive for broad responsibilities

Suppose Organization A wants a group responsible for every supported operation on items.

It could list read, create, edit, delete, and any other item action individually. Exact lists are useful when each new capability should require another assignment decision.

But a broad responsibility can make those lists repetitive. I wanted a way to say "all user operations on this resource" while keeping the naming structure.

That is what a trailing wildcard expresses:

user:catalog:item:*

It covers user actions on catalogue items. It does not cover stock movements, and it does not turn a user grant into a service grant.

The rule can widen by replacing more trailing segments:

Grant Intended coverage
user:catalog:item:read One named user operation.
user:catalog:item:* User operations on catalogue items.
user:catalog:*:* User operations in the catalogue namespace.
user:*:*:* User operations across namespaces.

The word "trailing" matters. Once a wildcard appears, the remaining segments must also be wildcards for the grant to cover a required key.

A key such as user:catalog:*:read does not mean "search all resources for a read action" in this matcher. It is not a general pattern language.

Why choose that restriction? The hierarchy stays predictable. A broader grant describes a subtree of the naming structure. The evaluator does not need a second set of rules for matching arbitrary combinations of segments.

Here is the behavior using our shared Python evaluator. This is a real library interface with sample permission strings:

from leysir_permissions import PermissionExpression

read_item = PermissionExpression.any([
    "user:catalog:item:read",
])

assert read_item.evaluate(["user:catalog:item:read"])
assert read_item.evaluate(["user:catalog:item:*"])
assert not read_item.evaluate(["service:catalog:item:*"])
assert not read_item.evaluate(["user:catalog:stock-movement:*"])
assert not read_item.evaluate(["user:catalog:*:read"])
assert not read_item.evaluate([])

We will examine the evaluator and its framework integrations in Parts 5 and 6. At this point, the examples establish the permission contract.

A caller needs a covering grant. Having a valid identity or a similarly spelled key is not enough.

A wildcard also makes a decision about future actions

Now suppose Inventory adds a new item action called archive.

An exact group list containing read and edit does not automatically contain archive. The administrator must decide whether to add it.

A group with user:catalog:item:* already expresses all user actions under that resource. The new action can fall within its scope.

That is both the convenience and the cost of the wildcard.

If the group represents broad responsibility for items, the behavior may be exactly what the organization wants. If access should remain a fixed list of reviewed capabilities, exact grants are a better expression of that intent.

The implementation cannot decide that administrative preference for us.

This also explains why a permission namespace needs care. Moving an operation under an existing broad scope can affect who satisfies its requirement. The name is part of the access contract, not only a label for the developer.

We chose a flexible vocabulary, but flexibility still requires decisions about where capabilities belong.

We had an assignment model; we still needed a declaration

We can now follow Ada's access without skipping a relationship.

Her verified request selects Organization A. Accounts resolves her groups in A. The group grants form an effective set. Inventory evaluates the requirement for the operation and applies the relevant resource restrictions.

Example Sync follows another assignment path. Its registered identity receives service grants directly. It uses the service requirement for the operation.

The model gives organizations room to define responsibilities while services keep ownership of their capabilities. Its costs are visible too: group administration, permission naming, broad-grant decisions, and clear resource boundaries.

We still need Accounts to learn which permissions a service defines and which grants it requests. Developers need to review that information when the service changes.

Part 4 follows a new service through that process. We will start with the information registration needs, then build the manifest that carries it.

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.