Liking cljdoc? Tell your friends :D

sidebar_position: 4

Multi-Tenant SaaS

This example implements tenant isolation for a multi-tenant SaaS application. Users belong to organizations (tenants) and can only access resources within their own tenant, with special provisions for platform administrators who need cross-tenant access.

![Overview](./assets/multi-tenant-saas.svg)

<SectionHeader icon="version" level={2}>Overview

Multi-tenant applications must enforce strict boundaries between organizations:

RequirementImplementation
Tenant isolationResources tagged with tenant ID, enforced at policy level
Role hierarchyOwner > Admin > Member > Viewer per tenant
Cross-tenant accessPlatform admins for support/operations
Tenant-scoped resourcesEach resource belongs to exactly one tenant

<SectionHeader icon="settings" level={2}>Design

Tenant Model

Each tenant (organization) has:

  • A unique tenant ID encoded in resource MRNs
  • Users with roles scoped to that tenant
  • Resources that belong to that tenant
Resource MRN: mrn:saas:<tenant-id>:<resource-type>:<resource-id>
Example:      mrn:saas:acme-corp:project:website-redesign

Access Decision Flow

flowchart TD
    A[Request] --> B{Authenticated?}
    B -->|No| C[DENY]
    B -->|Yes| D{Platform Admin?}
    D -->|Yes| E[GRANT - Full Access]
    D -->|No| F{Same tenant?}
    F -->|No| C
    F -->|Yes| G{Has required role?}
    G -->|No| C
    G -->|Yes| H{Operation permitted?}
    H -->|No| C
    H -->|Yes| I[GRANT]

<SectionHeader icon="security" level={2}>Complete PolicyDomain

apiVersion: iamlite.manetu.io/v1beta1
kind: PolicyDomain
metadata:
  name: multi-tenant-saas
spec:
  # ============================================================
  # Policy Libraries
  # ============================================================
  policy-libraries:
    - mrn: &lib-utils "mrn:iam:library:utils"
      name: utils
      description: "Common utility functions"
      rego: |
        package utils

        import rego.v1

        # Check if request has a valid principal (authenticated)
        has_principal if {
            input.principal != {}
            input.principal.sub != ""
        }

    - mrn: &lib-tenant-helpers "mrn:iam:library:tenant-helpers"
      name: tenant-helpers
      description: "Multi-tenant helper functions"
      rego: |
        package tenant_helpers

        import rego.v1

        # Extract tenant ID from a resource MRN
        # mrn:saas:acme-corp:project:website -> acme-corp
        extract_tenant(mrn) := tenant if {
            parts := split(mrn, ":")
            parts[0] == "mrn"
            parts[1] == "saas"
            tenant := parts[2]
        }

        # Check if principal belongs to the given tenant
        is_tenant_member(principal, tenant_id) if {
            principal.mannotations.tenant_id == tenant_id
        }

        # Check if principal is a platform admin (cross-tenant access)
        is_platform_admin(principal) if {
            "mrn:iam:role:platform-admin" in principal.mroles
        }

        # Role hierarchy levels for comparison
        role_level("viewer") := 1
        role_level("member") := 2
        role_level("admin") := 3
        role_level("owner") := 4

        # Check if principal has at least the required role level
        has_role_level(principal, required_role) if {
            some role in principal.mannotations.tenant_roles
            role_level(role) >= role_level(required_role)
        }

        # Map operations to required role levels
        required_role_for_operation(operation) := "viewer" if {
            some suffix in {":read", ":list"}
            endswith(operation, suffix)
        }

        required_role_for_operation(operation) := "member" if {
            some suffix in {":create", ":update"}
            endswith(operation, suffix)
        }

        required_role_for_operation(operation) := "admin" if {
            some suffix in {":delete", ":manage"}
            endswith(operation, suffix)
        }

        required_role_for_operation(operation) := "owner" if {
            some suffix in {":transfer", ":billing"}
            endswith(operation, suffix)
        }

  # ============================================================
  # Policies
  # ============================================================
  policies:
    # Operation phase - require authentication
    - mrn: &policy-require-auth "mrn:iam:policy:require-auth"
      name: require-auth
      description: "Require authentication for all operations"
      dependencies:
        - *lib-utils
      rego: |
        package authz

        import rego.v1
        import data.utils

        # Tri-level: negative=DENY, 0=GRANT, positive=GRANT Override
        # Default deny - only grant if authenticated
        default allow = -1

        # Grant authenticated requests
        allow = 0 if utils.has_principal

    # Identity phase - any authenticated user proceeds
    - mrn: &policy-authenticated "mrn:iam:policy:authenticated"
      name: authenticated
      description: "Allow any authenticated user"
      dependencies:
        - *lib-utils
      rego: |
        package authz

        import rego.v1
        import data.utils

        default allow = false

        # Allow authenticated users
        allow if utils.has_principal

    # Resource phase - tenant isolation
    - mrn: &policy-tenant-isolation "mrn:iam:policy:tenant-isolation"
      name: tenant-isolation
      description: "Enforce tenant boundaries"
      dependencies:
        - *lib-tenant-helpers
      rego: |
        package authz

        import rego.v1
        import data.tenant_helpers

        default allow = false

        # Platform admins can access any tenant
        allow if {
            tenant_helpers.is_platform_admin(input.principal)
        }

        # Regular users must be in the same tenant
        allow if {
            # Extract tenant from resource
            resource_tenant := tenant_helpers.extract_tenant(input.resource.id)

            # Check principal is in same tenant
            tenant_helpers.is_tenant_member(input.principal, resource_tenant)

            # Check principal has required role for this operation
            required_role := tenant_helpers.required_role_for_operation(input.operation)
            tenant_helpers.has_role_level(input.principal, required_role)
        }

    # Resource phase - shared resources (cross-tenant by design)
    - mrn: &policy-shared-resources "mrn:iam:policy:shared-resources"
      name: shared-resources
      description: "Access control for shared/public resources"
      rego: |
        package authz

        import rego.v1

        default allow = false

        # Allow read access to shared resources for any authenticated user
        allow if {
            input.resource.annotations.shared == true
            some suffix in {":read", ":list"}
            endswith(input.operation, suffix)
        }

        # Only the owning tenant can modify shared resources
        allow if {
            input.resource.annotations.shared == true
            input.principal.mannotations.tenant_id == input.resource.annotations.owner_tenant
        }

    # Resource phase - billing resources (owner only)
    - mrn: &policy-billing "mrn:iam:policy:billing"
      name: billing
      description: "Billing access control"
      dependencies:
        - *lib-tenant-helpers
      rego: |
        package authz

        import rego.v1
        import data.tenant_helpers

        default allow = false

        # Only tenant owners can access billing
        allow if {
            resource_tenant := tenant_helpers.extract_tenant(input.resource.id)
            tenant_helpers.is_tenant_member(input.principal, resource_tenant)
            tenant_helpers.has_role_level(input.principal, "owner")
        }

        # Platform admins can view billing for support
        allow if {
            tenant_helpers.is_platform_admin(input.principal)
            endswith(input.operation, ":read")
        }

  # ============================================================
  # Roles
  # ============================================================
  roles:
    # Tenant roles (assigned per tenant)
    - mrn: &role-tenant-viewer "mrn:iam:role:tenant-viewer"
      name: tenant-viewer
      description: "Read-only access within tenant"
      policy: *policy-authenticated

    - mrn: &role-tenant-member "mrn:iam:role:tenant-member"
      name: tenant-member
      description: "Standard member access within tenant"
      policy: *policy-authenticated

    - mrn: &role-tenant-admin "mrn:iam:role:tenant-admin"
      name: tenant-admin
      description: "Administrative access within tenant"
      policy: *policy-authenticated

    - mrn: &role-tenant-owner "mrn:iam:role:tenant-owner"
      name: tenant-owner
      description: "Full owner access including billing"
      policy: *policy-authenticated

    # Platform roles (cross-tenant)
    - mrn: &role-platform-admin "mrn:iam:role:platform-admin"
      name: platform-admin
      description: "Platform administrator with cross-tenant access"
      policy: *policy-authenticated

    - mrn: &role-platform-support "mrn:iam:role:platform-support"
      name: platform-support
      description: "Platform support with limited cross-tenant read access"
      policy: *policy-authenticated

  # ============================================================
  # Groups - Example tenant groups
  # ============================================================
  groups:
    # Acme Corp tenant groups
    - mrn: "mrn:iam:group:acme-corp:owners"
      name: acme-corp-owners
      description: "Acme Corp tenant owners"
      roles:
        - *role-tenant-owner
      annotations:
        - name: tenant_id
          value: acme-corp
        - name: tenant_roles
          value:
            - owner
            - admin
            - member
            - viewer

    - mrn: "mrn:iam:group:acme-corp:admins"
      name: acme-corp-admins
      description: "Acme Corp tenant administrators"
      roles:
        - *role-tenant-admin
      annotations:
        - name: tenant_id
          value: acme-corp
        - name: tenant_roles
          value:
            - admin
            - member
            - viewer

    - mrn: "mrn:iam:group:acme-corp:members"
      name: acme-corp-members
      description: "Acme Corp team members"
      roles:
        - *role-tenant-member
      annotations:
        - name: tenant_id
          value: acme-corp
        - name: tenant_roles
          value:
            - member
            - viewer

    - mrn: "mrn:iam:group:acme-corp:viewers"
      name: acme-corp-viewers
      description: "Acme Corp read-only users"
      roles:
        - *role-tenant-viewer
      annotations:
        - name: tenant_id
          value: acme-corp
        - name: tenant_roles
          value:
            - viewer

    # Globex Corp tenant groups
    - mrn: "mrn:iam:group:globex-corp:members"
      name: globex-corp-members
      description: "Globex Corp team members"
      roles:
        - *role-tenant-member
      annotations:
        - name: tenant_id
          value: globex-corp
        - name: tenant_roles
          value:
            - member
            - viewer

    # Platform team
    - mrn: "mrn:iam:group:platform-team"
      name: platform-team
      description: "Platform administrators"
      roles:
        - *role-platform-admin

  # ============================================================
  # Resource Groups
  # ============================================================
  resource-groups:
    # Default tenant resources
    - mrn: &rg-tenant "mrn:iam:resource-group:tenant"
      name: tenant
      description: "Tenant-scoped resources"
      default: true
      policy: *policy-tenant-isolation

    # Shared resources
    - mrn: &rg-shared "mrn:iam:resource-group:shared"
      name: shared
      description: "Cross-tenant shared resources"
      policy: *policy-shared-resources

    # Billing resources
    - mrn: &rg-billing "mrn:iam:resource-group:billing"
      name: billing
      description: "Billing and subscription resources"
      policy: *policy-billing

  # ============================================================
  # Resources - Route by MRN pattern
  # ============================================================
  resources:
    - name: billing-resources
      description: "Route billing resources"
      selector:
        - "mrn:saas:.*:billing:.*"
        - "mrn:saas:.*:subscription:.*"
        - "mrn:saas:.*:invoice:.*"
      group: "mrn:iam:resource-group:billing"

    - name: shared-templates
      description: "Route shared templates"
      selector:
        - "mrn:saas:shared:.*"
      group: "mrn:iam:resource-group:shared"

  # ============================================================
  # Operations
  # ============================================================
  operations:
    - name: all-operations
      selector:
        - ".*"
      policy: *policy-require-auth

<SectionHeader icon="test" level={2}>Test Cases

Test 1: Member Can Read Own Tenant Resources

An Acme Corp member can read resources in their tenant:

{
  "principal": {
    "sub": "alice@acme.com",
    "mroles": ["mrn:iam:role:tenant-member"],
    "mgroups": ["mrn:iam:group:acme-corp:members"],
    "mannotations": {
      "tenant_id": "acme-corp",
      "tenant_roles": ["member", "viewer"]
    }
  },
  "operation": "project:read",
  "resource": {
    "id": "mrn:saas:acme-corp:project:website-redesign",
    "group": "mrn:iam:resource-group:tenant"
  }
}

Expected:

Test 2: Member Can Create in Own Tenant

An Acme Corp member can create resources in their tenant:

{
  "principal": {
    "sub": "alice@acme.com",
    "mroles": ["mrn:iam:role:tenant-member"],
    "mgroups": ["mrn:iam:group:acme-corp:members"],
    "mannotations": {
      "tenant_id": "acme-corp",
      "tenant_roles": ["member", "viewer"]
    }
  },
  "operation": "project:create",
  "resource": {
    "id": "mrn:saas:acme-corp:project:new-project",
    "group": "mrn:iam:resource-group:tenant"
  }
}

Expected: (members can create)

Test 3: Member Cannot Delete (Admin Required)

An Acme Corp member cannot delete resources:

{
  "principal": {
    "sub": "alice@acme.com",
    "mroles": ["mrn:iam:role:tenant-member"],
    "mgroups": ["mrn:iam:group:acme-corp:members"],
    "mannotations": {
      "tenant_id": "acme-corp",
      "tenant_roles": ["member", "viewer"]
    }
  },
  "operation": "project:delete",
  "resource": {
    "id": "mrn:saas:acme-corp:project:old-project",
    "group": "mrn:iam:resource-group:tenant"
  }
}

Expected: (delete requires admin role)

Test 4: Admin Can Delete

An Acme Corp admin can delete resources:

{
  "principal": {
    "sub": "bob@acme.com",
    "mroles": ["mrn:iam:role:tenant-admin"],
    "mgroups": ["mrn:iam:group:acme-corp:admins"],
    "mannotations": {
      "tenant_id": "acme-corp",
      "tenant_roles": ["admin", "member", "viewer"]
    }
  },
  "operation": "project:delete",
  "resource": {
    "id": "mrn:saas:acme-corp:project:old-project",
    "group": "mrn:iam:resource-group:tenant"
  }
}

Expected:

Test 5: Cross-Tenant Access Denied

An Acme Corp member cannot access Globex Corp resources:

{
  "principal": {
    "sub": "alice@acme.com",
    "mroles": ["mrn:iam:role:tenant-member"],
    "mgroups": ["mrn:iam:group:acme-corp:members"],
    "mannotations": {
      "tenant_id": "acme-corp",
      "tenant_roles": ["member", "viewer"]
    }
  },
  "operation": "project:read",
  "resource": {
    "id": "mrn:saas:globex-corp:project:secret-project",
    "group": "mrn:iam:resource-group:tenant"
  }
}

Expected: (tenant boundary violation)

Test 6: Platform Admin Cross-Tenant Access

A platform admin can access any tenant's resources:

{
  "principal": {
    "sub": "ops@saas-platform.com",
    "mroles": ["mrn:iam:role:platform-admin"],
    "mgroups": ["mrn:iam:group:platform-team"],
    "mannotations": {}
  },
  "operation": "project:read",
  "resource": {
    "id": "mrn:saas:acme-corp:project:website-redesign",
    "group": "mrn:iam:resource-group:tenant"
  }
}

Expected: (platform admin bypasses tenant boundaries)

Test 7: Only Owner Can Access Billing

A member cannot access billing:

{
  "principal": {
    "sub": "alice@acme.com",
    "mroles": ["mrn:iam:role:tenant-member"],
    "mgroups": ["mrn:iam:group:acme-corp:members"],
    "mannotations": {
      "tenant_id": "acme-corp",
      "tenant_roles": ["member", "viewer"]
    }
  },
  "operation": "billing:read",
  "resource": {
    "id": "mrn:saas:acme-corp:billing:subscription",
    "group": "mrn:iam:resource-group:billing"
  }
}

Expected: (billing requires owner role)

Test 8: Owner Can Access Billing

A tenant owner can access billing:

{
  "principal": {
    "sub": "ceo@acme.com",
    "mroles": ["mrn:iam:role:tenant-owner"],
    "mgroups": ["mrn:iam:group:acme-corp:owners"],
    "mannotations": {
      "tenant_id": "acme-corp",
      "tenant_roles": ["owner", "admin", "member", "viewer"]
    }
  },
  "operation": "billing:read",
  "resource": {
    "id": "mrn:saas:acme-corp:billing:subscription",
    "group": "mrn:iam:resource-group:billing"
  }
}

Expected:

Test 9: Shared Resources Cross-Tenant Read

Any authenticated user can read shared resources:

{
  "principal": {
    "sub": "alice@acme.com",
    "mroles": ["mrn:iam:role:tenant-member"],
    "mgroups": ["mrn:iam:group:acme-corp:members"],
    "mannotations": {
      "tenant_id": "acme-corp",
      "tenant_roles": ["member", "viewer"]
    }
  },
  "operation": "template:read",
  "resource": {
    "id": "mrn:saas:shared:template:standard-contract",
    "group": "mrn:iam:resource-group:shared",
    "annotations": {
      "shared": true,
      "owner_tenant": "platform"
    }
  }
}

Expected: (shared resources allow cross-tenant read)

<SectionHeader icon="version" level={2}>Key Concepts Demonstrated

1. Tenant ID Embedded in MRNs

Resource MRNs include the tenant ID, making tenant extraction straightforward:

mrn:saas:acme-corp:project:website
         ^^^^^^^^^ tenant ID

2. Role Hierarchy with Numeric Levels

Instead of listing specific roles for each operation, we use a numeric hierarchy:

role_level("viewer") := 1
role_level("member") := 2
role_level("admin") := 3
role_level("owner") := 4

This allows has_role_level(principal, "member") to return true for admins and owners too.

3. Tenant Isolation as Default

The default resource group uses tenant isolation. Every resource is protected by tenant boundaries unless explicitly placed in a different group (like shared or billing).

4. Cross-Tenant Access Pattern

Platform admins have a dedicated check that bypasses tenant isolation:

allow if {
    tenant_helpers.is_platform_admin(input.principal)
}

5. Annotations for Tenant Context

Rather than encoding roles in MRNs, we use principal annotations:

annotations:
  - name: tenant_id
    value: acme-corp
  - name: tenant_roles
    value:
      - member
      - viewer

<SectionHeader icon="build" level={2}>Extending This Example

Adding Tenant Quotas

Add quota checking using resource annotations:

allow if {
    count_projects := input.context.tenant_project_count
    quota := input.principal.mannotations.project_quota
    count_projects < quota
}

Adding Tenant-Scoped API Keys

Create a scope for API keys that limits operations:

scopes:
  - mrn: "mrn:iam:scope:api-key"
    name: api-key
    policy: "mrn:iam:policy:api-key-restrictions"

Adding Audit Logging

Enhance the policy to include audit-relevant metadata:

# Add to allow rule
audit_context := {
    "tenant_id": resource_tenant,
    "cross_tenant": tenant_helpers.is_platform_admin(input.principal),
    "role_used": input.principal.mannotations.tenant_roles[0]
}

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close