BetterIAM
Governance

Application catalog

A My apps launcher for your organization's tools, with assignments to people and groups, access requests, audited launches, and sign-in enforcement.

new@better-iam/server@better-iam/client@better-iam/reactapplications.mdapplications.tsapplications.tsfederation.tsprovider.tsindex.tsx

People in an organization use a dozen tools: the wiki, the CRM, CI, payroll. They hunt for bookmarks, ask where the expense tool lives, and keep access to tools they stopped using long ago. Administrators, meanwhile, cannot say who is supposed to have which tool, or who still uses it.

The application catalog is the organization's list of tools, and My apps is the launcher where each person opens the ones they have. An app is either an client of this deployment or a plain link to any other tool. Administrators give an app to everyone or assign it to people and , optionally until a date; people who lack an app request it through an ; every launch is recorded and audited; and the OAuth provider refuses people who do not have the app.

const crm = await iam.api.applications.create(admin, {
  tenantId,
  key: 'crm',
  name: 'CRM',
  launchUrl: 'https://crm.acme.test/login',
  category: 'Sales',
});
await iam.api.applications.assign(admin, {
  tenantId,
  appId: crm.id,
  subjectType: 'group',
  subjectId: sales.id,
});
const apps = await iam.api.applications.mine(aliceSession, { tenantId });

Routes: POST /api/iam/applications/create, POST /api/iam/applications/assign, POST /api/iam/applications/mine

Register an app

create (iam:applications:manage) adds an app. key is permanent (lowercase letters, digits, dots, underscores or hyphens, starting with a letter) and launchUrl is where launching sends people: the app's sign-in URL. Launch and logo URLs must be https; while the deployment itself runs on localhost, 127.0.0.1 or [::1], they may also be http on those hosts.

FieldWhat it does
name, description, category, logoUrlWhat the launcher shows. The console groups apps by category.
oauthClientIdThe clientId of an OAuth client of this organization, named by one app at most. The app becomes oidc (otherwise link) and decides who may sign in to the client.
visibilityassigned (the default) or everyone.
enabledfalse takes the app off every launcher and refuses launches and sign-ins to its client, keeping its assignments.
requestPackageIdA requestable access package people without the app may ask for.
ownerIdsUp to 20 people to contact about the app. It grants them nothing.

A tenant holds at most 500 apps. update changes anything but the key (null clears an optional field), and delete removes an app with its assignments and launch history. Changes are audited as app:create, app:update and app:delete.

Releasing an OAuth client is explicit

Without its app, everyone in the organization may sign in to an OAuth client. So deleting an app that governs a client, or changing or clearing its oauthClientId, answers CONFLICT unless you pass releaseClient: true. To keep refusing everyone, disable the app instead.

Who has an app

A person has an app when it is enabled and:

  • its visibility is everyone, or
  • it is assigned to them, or
  • it is assigned to a group they belong to,

counting only assignments and memberships that have not ended. Only active, unexpired people have apps; service accounts and agents never do. The launcher says why each app is there (via): direct, else group, else everyone.

assign (iam:applications:assign on the app) gives an app to one person or one group, with an optional expiresAt up to ten years away; an app holds at most 10,000 assignments, so assign groups at scale. Assigning the same person or group again replaces the assignment. unassign removes one (authorized on its app, so per-app administrators can), and listAssignments shows the assignments of one app, or of every app without appId, with the group's name or, for callers who may read the directory, the person's, and when the person last opened the app. Deleting a person or a group removes their assignments.

A contractor gets the CRM until the end of the year
await iam.api.applications.assign(admin, {
  tenantId,
  appId: crm.id,
  subjectType: 'identity',
  subjectId: contractorId,
  expiresAt: Date.parse('2026-12-31T00:00:00Z'),
});

Route: POST /api/iam/applications/assign

An assignment stops counting the moment it ends, and iam.sweepExpired() deletes it later.

Let people request an app

Give the app a requestPackageId: a requestable access package that grants a group the app is assigned to. The catalog checks that the package is requestable, not what it grants, so point it at the right group.

People without the app then see it under "More apps" with a Request access button. The request is an ordinary package request: it needs iam:packages:request, and the package's approvers decide. Once the person joins the group, the app moves to their launcher.

The launcher

People use the launcher from their own signed-in session, with no permission. API keys, role sessions, session tokens and agents acting for people are refused.

  • mine lists the enabled apps the person has, most recently opened first, then the apps they may request (with requestPackageId and no via). It is not audited.
  • launch checks the person has the app, records the launch (first and last time, and a count), audits app:launch, and returns { url }. It does not sign the person in to the app; the app's own sign-in runs as usual.

An administrator viewing as the person sees their launcher, but cannot open apps from it: launch answers IMPERSONATION_RESTRICTED.

In React, useMyApps from @better-iam/react loads the launcher: apps to open, requestable apps to ask for, and launch(appId), which records the launch, reloads, and resolves to the URL. It does not open the app: by the time it resolves, the browser no longer treats a new tab as the result of the click and blocks it, so open the tab during the click and point it at the URL afterwards.

function Launcher({ tenantId }: { tenantId: string }) {
  const { apps, requestable, launch } = useMyApps({ tenantId });
  async function open(appId: string) {
    // Open the tab during the click (browsers block tabs opened later), then point it at the app.
    const tab = window.open('about:blank', '_blank');
    try {
      const url = await launch(appId);
      if (tab) {
        tab.opener = null;
        tab.location.href = url;
      } else window.location.href = url;
    } catch (error) {
      tab?.close(); // not the person's app, or an administrator viewing as them
      throw error;
    }
  }
  return (
    <>
      {apps.map((app) => (
        <button key={app.id} onClick={() => void open(app.id)}>
          {app.name}
        </button>
      ))}
      {requestable.map((app) => (
        <RequestAccessButton key={app.id} packageId={app.requestPackageId!} />
      ))}
    </>
  );
}

Refuse people without the app

A launcher does not stop anyone from going to an app directly. For apps that sign in through this deployment's OAuth provider, the provider does. iam.protocolHost gives it the optional hook clientAllowed(identityId, tenantId, clientId), which asks the catalog whenever the provider loads a person's account for a client: when it redeems an authorization code, refreshes tokens, and answers userinfo. A person without the app gets no tokens for its client, and removing their app, or disabling it, also stops their refresh tokens. A provider created without iam.protocolHost enforces nothing unless you pass a clientAllowed of your own.

The provider refuses only at the token endpoint, after the person has signed in. To tell them why instead, check in your interaction page before completing it:

const details = await issuer.interactionDetails(req, res);
const { identity } = await iam.authenticate(credential);
const access = await iam.applications.allowed({
  tenantId: details.tenantId,
  identityId: identity.id,
  oauthClientId: details.clientId,
});
if (!access.allowed) {
  // Not assigned: end the flow with access_denied, or show a page to request the app.
  await issuer.completeInteraction(req, res, { credential, consent: false });
  return;
}

iam.applications.allowed takes exactly one of appId and oauthClientId and answers { allowed, governed }:

SituationAnswer
No app in the catalog names the clientallowed: true, governed: false: the catalog does not govern it
The app exists and the person has it nowallowed: true, governed: true
The app exists and the person does not have it, or the app is disabledallowed: false, governed: true

It gives the same answer as the provider's hook, takes no credential and is not audited: it is for the deployment's own code. Over the API the same check is applications.check, which needs iam:applications:read.

Register the client as an app first

Clients without an app are allowed for everyone, so enforcement starts only once you register the OAuth client in the catalog with its oauthClientId. Disabling that app then refuses everyone rather than ungoverning the client, and deleting it needs releaseClient: true.

Clean up unused access

usage (iam:applications:read) reports for every app how many people have it (people), how many opened it in the last 30 days, and unused: direct assignments to people, at least unusedDays old (90 by default), whose person has not opened the app in that time. removeUnused (iam:applications:assign) removes them for one app and audits each removal as app:unassign. Group assignments stay: the app goes with the group membership.

const report = await iam.api.applications.usage(admin, { tenantId, unusedDays: 60 });
for (const app of report.filter((item) => item.unused.length))
  await iam.api.applications.removeUnused(admin, { tenantId, appId: app.appId, unusedDays: 60 });

Routes: POST /api/iam/applications/usage, POST /api/iam/applications/removeUnused

Permissions and audit

ActionMethods
iam:applications:readlist, listAssignments, usage, check
iam:applications:managecreate, update, delete
iam:applications:assignassign, unassign, removeUnused
none (own session)mine, launch

Apps are audited as app:create, app:update and app:delete, assignments as app:assign and app:unassign, and launches as app:launch; subscribe a to app:* to follow them. People's names appear in listAssignments and usage only for callers who may also read the directory (iam:identities:read). The console pages are Access › Applications for administrators (apps, assignments, usage, and a "Remove unused" button) and Home › My apps for everyone.

Options

The catalog has no deployment option: the applications group is always available and its collections are part of the ordinary migration (npx better-iam migrate or iam.initialize()). Each app is configured by the fields in Register an app. The limits are 500 apps per tenant, 10,000 assignments per app, and 20 ownerIds per app. visibility is assigned (the default) or everyone; an app's kind is oidc when it names an oauthClientId, otherwise link.

Schema

CollectionOne record perKey fields
applicationsApp (unique key per tenant)key, name, description, category, launchUrl, logoUrl, kind, oauthClientId, visibility, enabled, requestPackageId, ownerIds
appAssignmentsAssignment to a person or groupappId, subjectType (identity, group), subjectId, assignedBy, assignedAt, expiresAt
appLaunchesApp and personappId, identityId, firstAt, lastAt, count

delete removes an app together with its assignments and launches.

Routes

Every method is a POST under the handler's base path (default /api/iam) and needs a credential. The browser client calls the same routes as client.applications.<method>.

MethodRouteMethodRoute
applications.createPOST /api/iam/applications/createapplications.updatePOST /api/iam/applications/update
applications.deletePOST /api/iam/applications/deleteapplications.listPOST /api/iam/applications/list
applications.assignPOST /api/iam/applications/assignapplications.unassignPOST /api/iam/applications/unassign
applications.listAssignmentsPOST /api/iam/applications/listAssignmentsapplications.removeUnusedPOST /api/iam/applications/removeUnused
applications.usagePOST /api/iam/applications/usageapplications.checkPOST /api/iam/applications/check
applications.minePOST /api/iam/applications/mineapplications.launchPOST /api/iam/applications/launch

iam.applications.allowed is the server-side check sign-in and consent pages use, not a route.

Errors

CodeStatusWhen
INVALID_INPUT400A malformed key, a non-https launch or logo URL, an unknown OAuth client or package, or other invalid fields
CONFLICT409An app with the key exists, another app already governs the OAuth client, or releasing a governed client without releaseClient: true
LIMIT_EXCEEDED409The tenant would hold more than 500 apps, or an app more than 10,000 assignments
ACCESS_DENIED403launch of an app that is not assigned to the caller, or a missing iam:applications:* permission
IMPERSONATION_RESTRICTED403launch from a "view as" session
NOT_FOUND404An unknown app or assignment

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page