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 });

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'),
});

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 });

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.

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page