BetterIAM
Core concepts

Resources and catalog

The vocabulary policies speak: actions, resource types, application-owned and managed resources, relations, and tenant-defined types.

Authorization in Better IAM always asks the same question: may this (the signed-in caller) perform this on this ? The resource catalog defines both halves: it lists the action names a policy may use and the resource types those actions apply to, with their attributes and relations.

Declaring them up front means a typo in a policy is caught when the policy is saved, not discovered later when access silently fails. This page explains the concepts; the catalog guide covers configuration and validation in depth.

The permission catalog

The is the set of action names a may use. It combines four sources:

SourceExampleWhere it comes from
Built-in actionsiam:identities:create, iam:bindings:create, iam:audit:readThe platform's own operations.
Product actionsdocuments:read, reports:exportpermissions.actions and each resource type's actions.
Plugin actionsprojects:readA plugin's actions list.
Tenant-defined actionsinvoice:approveRegistered by a tenant when permissions.mode is 'tenant-defined'.

Registering an action grants nothing; it only makes the name valid in policies. Policy documents are validated against the catalog when they are stored: an exact action name that does not exist is rejected with INVALID_ACTION. Wildcard patterns such as documents:* are stored as written. Products and plugins cannot declare names under the reserved iam: or tenant/ prefixes, or names containing *, ?, or whitespace.

Resource types

A names a kind of thing your product protects, such as documents or projects, and says which actions apply to it and which attributes policies may test. You declare types in permissions.resourceTypes:

iam.ts
export const iam = betterIam({
  // ...
  permissions: {
    resourceTypes: {
      document: {
        actions: ['documents:read', 'documents:write'],
        attributes: { ownerId: 'string', classification: 'string' },
        relations: ['viewer', 'editor'],
      },
      project: {
        managed: true,
        actions: ['projects:read', 'projects:manage'],
        attributes: { archived: 'boolean' },
      },
      task: { managed: true, parent: 'project', actions: ['tasks:read', 'tasks:write'] },
    },
  },
});

Prop

Type

Type names are lowercase identifiers (a letter, then letters, digits, or hyphens, up to 64 characters). A small set of names is reserved for the platform: iam, role, oauth-client, scim, saml, ssf, tenant, identity, and session. Declaring a reserved name, or the same name twice (including across plugins), fails at construction with INVALID_CONFIG.

Resource patterns in policies match type/id within an already-resolved tenant, for example document/* or folder/home-${principal.id}. Once resourceTypes is configured, an exact type in a pattern must exist (INVALID_RESOURCE_TYPE).

Application-owned and managed resources

Every resource type is either application-owned or managed. The difference is where Better IAM finds a resource's tenant, owner, and attributes at decision time.

Application-ownedManaged
Declared asA type without managedmanaged: true, and every tenant-defined type
Records live inYour product's databaseIAM's resources collection
Resolved byYour resolveResource callbackThe registry; no callback
Registered withNothing; your product owns the dataThe resources group (see below)
Extra contextWhatever your resolver returnsresource.ownerId, resource.parentId, resource.parentType
Reverse queriesNot enumerable; use authorizeMany with IDs you already havelistAccessible lists what a caller may act on

For application-owned types, resolveResource must load ownership and attributes from trusted storage:

iam.ts
resolveResource: async ({ tenantId, type, id }) => {
  const document = await documents.find(id); // your database
  return { tenantId: document.tenantId, type, id, attributes: { ownerId: document.ownerId } };
},

Resolve from trusted storage

Never copy the request's tenant ID into a fetched record to satisfy the resolver. The tenant the resolver returns is what authorization checks the caller against.

The resolver must return the same type and id it was asked about, and the record's tenant must be the tenant the check names; anything else fails with RESOURCE_MISMATCH (403). Without a resolveResource option, checks on application-owned types fail with RESOURCE_RESOLVER_REQUIRED.

Choose managed resources when you would rather not write a resolver, or when you need such as "which projects may this person open?". You then tell Better IAM about each resource with the resources group:

  • resources.register records one resource with its attributes, an optional owner (ownerId), and an optional parent (parentId), for example when a project is created in your product.
  • resources.registerMany records up to 100 in one transaction, for imports. A denied item rejects the batch.
  • resources.update changes a resource's attributes or owner, and resources.delete removes it, together with its relationship tuples, when the product deletes it.
  • resources.get reads one registration, and resources.list lists them by type, parent, or owner.
await iam.api.resources.register(credential, {
  tenantId,
  type: 'project',
  id: project.id,
  ownerId: creatorIdentityId,
  attributes: { archived: false },
});

Attribute values are validated against the declared schema, ownerId must be an identity of the same tenant, and a parent must be a registered resource of the declared parent type. Registering requires iam:resources:create on iam/{type}/{id}, so policies can scope who may register which resources. Managed registrations are trusted authorization inputs: the iam:resources:* permissions decide who may create and edit them.

Relations and relationship tuples

Roles answer "what can editors do?", but sharing needs "who may edit this document?". Writing a policy per document does not scale. Relations express sharing and ownership without listing resource IDs in policies.

A tuple records that an identity or a group stands in a named relation to one resource: alice is an editor of document/plans, the design group are viewers of folder/specs. Only relations the resource type declares are accepted.

  • relationships.create adds a tuple, optionally with an expiresAt, for example when someone shares a document.
  • relationships.list finds tuples by resource, relation, or subject, for a "shared with" panel.
  • relationships.delete removes one, when access is unshared.

During evaluation, the principal's live relations on the resource (held directly or through a group) appear as resource.relations, and those on its registered parent as resource.parentRelations:

{
  effect: 'allow',
  actions: ['documents:read'],
  resources: ['document/*'],
  conditions: { ArrayContains: { 'resource.relations': ['viewer', 'editor'] } },
}

A relation still held by someone cannot be dropped from its type. See relationships.

Identity attributes

Policies often depend on facts about the person rather than the resource: their department, clearance level, or region. permissions.identityAttributes declares these as typed attributes. Administrators set them with identities.update (or serviceAccounts.update), federation and SCIM can fill them from a directory, and policies read them as principal.{name}:

permissions: {
  identityAttributes: { department: 'string', clearance: 'number' },
},

Attribute names cannot shadow the principal keys the server derives itself, such as id, tenantId, mfa, mfaTime, kind, owner, rootAdmin, groups, roles, sessionId, sessionKind, authMethod, impersonated, and impersonatorId, so an attribute can never impersonate a fact the server vouches for.

Tenant-defined resource types

Platforms whose customers model their own data, such as a workflow or low-code product, cannot declare every resource type in advance. With permissions.mode: 'tenant-defined', each tenant can extend the catalog for itself. An organization owner registers a type, with its actions, attributes, and relations, using resourceTypes.register:

await iam.api.resourceTypes.register(ownerCredential, {
  tenantId,
  name: 'invoice',
  actions: ['read', 'approve'], // registered as invoice:read and invoice:approve
  attributes: { amount: 'number' },
  relations: ['approver'],
});
  • Tenant-defined types are always managed, and exist only in the tenant that registered them.
  • Their actions are namespaced under the type as {type}:{verb}. The actions list on resourceTypes.register creates them, actions.register adds one later, and actions.list shows the whole catalog a tenant can use.
  • resourceTypes.update changes a type's description, attributes, or relations and adds verbs; resourceTypes.delete and actions.unregister remove what is no longer used.
  • Names cannot collide with reserved names, platform resource types, or the namespace of any platform action (INVALID_RESOURCE_TYPE, INVALID_ACTION), so tenant-created actions cannot redefine platform namespaces.
  • Deleting a type or action is refused while resources, relationships, child types, or policies still use it (RESOURCE_IN_USE).
  • In the default 'catalog' mode, only developer-defined names are allowed and these calls fail with CATALOG_LOCKED.

From request to decision

Whichever way a resource is resolved, its attributes, owner, parent, and relations become resource.* context keys, and the policy engine evaluates the principal's roles and policies against them. Continue with policies and conditions.

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page