BetterIAM
Governance

Terms of use

Versioned agreements such as acceptable-use policies that members accept, with enforcement through ordinary policy statements.

@better-iam/server@better-iam/react@better-iam/vuegovernance.mdpolicies.mdagreements.tsindex.tsx

Many organizations must show that people agreed to the rules before they got access: an acceptable-use policy, an NDA, data-handling rules for customer data. Emailing a PDF proves nothing, and tracking acceptance in a separate tool means access and agreement drift apart.

put the terms next to the access. An agreement is a versioned text a tenant asks its members to accept. Better IAM records who accepted which version and exposes that to every authorization decision, so enforcing it is an ordinary statement, such as "deny documents until the terms are accepted", that combines with everything else.

Publish an agreement

agreements.create (iam:agreements:manage) publishes an agreement at version 1. Members are asked to accept it from then on.

await iam.api.agreements.create(credential, {
  tenantId,
  name: 'Acceptable use',
  content: 'Use company systems for work. Report incidents within 24 hours.',
  reacceptAfterDays: 365,
});

Route: POST /api/iam/agreements/create

Prop

Type

A tenant holds at most 50 agreements.

Manage agreements

  • agreements.update (iam:agreements:manage) edits an agreement. With newVersion: true, the change becomes the next version and everyone must accept again; use it when the rules changed. Without it, for a typo fix, existing acceptances stay valid. reacceptAfterDays: null removes the lapse.
  • agreements.list (iam:agreements:read) lists the tenant's agreements with their current version.
  • agreements.status({ tenantId, agreementId }) (iam:agreements:read) reports who accepted the current version and which active people still owe it (an outdated version, a lapsed acceptance, or none). Use it for compliance reports and to chase people who have not accepted.
  • agreements.delete (iam:agreements:manage) removes an agreement together with its acceptances.

Accept

Members accept for themselves, and need no permission for it:

const mine = await iam.api.agreements.listMine({ token }, { tenantId });
for (const agreement of mine.filter((item) => item.required && !item.accepted))
  await iam.api.agreements.accept(
    { token },
    { tenantId, agreementId: agreement.id, version: agreement.version },
  );

Routes: POST /api/iam/agreements/listMine, POST /api/iam/agreements/accept

  • agreements.listMine({ tenantId }) returns every agreement of the tenant with its text and whether the caller has accepted its current version. Use it to show the terms.
  • agreements.accept({ tenantId, agreementId, version }) records acceptance of the version the person was shown. Passing the version means nobody accepts text they never saw: once the agreement changed, the call fails with VERSION_CONFLICT. It is audited as agreement:accept.
  • Both need an ordinary session of the tenant. An administrator using cannot accept for someone (IMPERSONATION_RESTRICTED), and accept nothing (nothing is pending for them).

The console shows members the required agreements they owe at the top of every page with an "I accept" button, and its Terms of use page publishes, edits, and tracks them.

In React and Vue

To show the same banner in your own application, use the useAgreements hook. It returns the person's agreements, the pending ones (required and not accepted in their current version), and an accept function that records acceptance and reloads.

components/terms-banner.tsx
import { useAgreements } from 'better-iam/react';

export function TermsBanner({ tenantId }: { tenantId: string }) {
  const { pending, accept } = useAgreements({ tenantId });
  if (!pending.length) return null;
  return (
    <div role="alert">
      {pending.map((agreement) => (
        <p key={agreement.id}>
          {agreement.name} <button onClick={() => accept(agreement)}>I accept</button>
        </p>
      ))}
    </div>
  );
}

The Vue composable of the same name, from better-iam/vue, accepts its input as a ref or getter and returns the same fields with the data as refs. Both take enabled: false to skip loading. See React and Vue.

Enforce with a policy

Recording acceptance is only half the job; access should wait for it. Instead of a special switch, Better IAM gives policies two facts about each person, so you decide exactly which access depends on which terms. Every evaluation for a person in their own tenant carries these context keys:

  • principal.agreements: the names of the agreements accepted in their current version.
  • principal.pendingAgreements: how many required ones are still owed.

A deny holds back access until every required agreement is accepted:

Hold back documents until the terms are accepted
{
  "effect": "deny",
  "actions": ["documents:*"],
  "resources": ["*"],
  "conditions": { "NumericGreaterThan": { "principal.pendingAgreements": 0 } }
}

A condition on an allow statement can grant something only to people who accepted an optional agreement, such as a beta program:

{ "ArrayContains": { "principal.agreements": ["Beta program"] } }

When a person is denied because of pending terms, access paths report an accept-agreements path listing what to accept.

Agreements as code

Agreements are part of configuration as code: name, content, url, required (default true), and reacceptAfterDays, matched by name regardless of case. A changed content publishes a new version, so everyone accepts the new text; other edits keep acceptances. Changing them needs iam:agreements:manage.

Publishing agreements is guarded by enforced (change safety). Members accepting agreements is not, so schedule the invariant monitor if an invariant depends on them.

Options

Agreements have no deployment option: the agreements group is always available and its collections are part of the ordinary migration (npx better-iam migrate or iam.initialize()). Each agreement takes these fields (agreements.create, agreements.update, or configuration as code):

FieldTypeDefaultDescription
namestringnoneRequired. Unique per tenant regardless of case, up to 100 characters.
contentstringnoneRequired. The text members accept, up to 50 000 characters.
urlstringnoneAn http(s) link to the full document.
requiredbooleantrueWhether it counts toward principal.pendingAgreements until accepted.
reacceptAfterDaysnumbernoneAcceptance lapses after this many days (1 to 3650); null in update removes the lapse.
newVersionbooleanfalseupdate only: publish the change as the next version, so everyone accepts again.

Schema

CollectionOne record perKey fields
agreementsAgreementname, content, url, version, required, reacceptAfterDays, createdAt, updatedAt
agreementAcceptancesPerson and agreement (ID {agreementId}:{identityId})agreementId, identityId, version, acceptedAt

An acceptance counts only while its version equals the agreement's current version and, with reacceptAfterDays, while it is younger than that. Deleting an agreement deletes its acceptances.

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.agreements.<method>.

MethodRouteMethodRoute
agreements.createPOST /api/iam/agreements/createagreements.updatePOST /api/iam/agreements/update
agreements.listPOST /api/iam/agreements/listagreements.statusPOST /api/iam/agreements/status
agreements.deletePOST /api/iam/agreements/deleteagreements.listMinePOST /api/iam/agreements/listMine
agreements.acceptPOST /api/iam/agreements/accept

Errors

CodeStatusWhen
INVALID_INPUT400A missing name, invalid content, a non-http(s) url, a non-boolean required, or a service account accepting
CONFLICT409Another agreement of the tenant has the same name
LIMIT_EXCEEDED409The tenant already holds 50 agreements
VERSION_CONFLICT409accept named a version that is no longer current
ACCESS_DENIED403Missing iam:agreements:* permission, or listMine and accept from a role session, session token, or another tenant
IMPERSONATION_RESTRICTED403accept from a "view as" session
NOT_FOUND404No agreement with that ID in the tenant

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page