> ## Content Index
> Fetch the complete content index at: https://secdoc.tech/llms.txt
> Use this file to discover other available public pages before exploring further.

# Proving Least Privilege with Denial Tests
- URL: https://secdoc.tech/proving-least-privilege-with-denial-tests/
- Published: 2026-10-14T10:00:00.000Z
- Updated: 2026-10-14T09:59:59.000Z
- Description: Least privilege is not proved by showing that approved work succeeds. It is proved by testing the nearby actions, resources, identities, and conditions that policy must deny.
- Author: secdoc
- Tags: identity-access-management, least-privilege, authorization, policy-as-code, open-source, security-testing, cybersecurity, CAH2, packt

An authorization test that proves an administrator can administer tells me the access path works. It does not tell me the administrator has only the authority the job requires.

Least privilege is a boundary claim. To test that claim, I need to exercise both sides of the boundary. The approved identity must complete the required action, and that same identity must fail when it attempts a nearby action that the policy does not authorize. A different identity must fail against the protected resource. An expired entitlement must fail after its deadline. A request with a forged or missing attribute must fail rather than inheriting a permissive default.

This is why I treat denial tests as first-class control evidence. Positive tests establish availability. Negative tests establish containment.

[NIST SP 800-53 Rev. 5 ](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final?ref=secdoc.tech)defines least privilege in AC-6 and calls for auditing the execution of privileged functions in AC-6(9). Those requirements are useful control anchors, but they do not decide what `backup-operator` should be able to do in a particular virtualization cluster or which API verb a deployment worker needs. That work belongs in the authorization design, the test matrix, and the evidence produced by enforcement.

## Turn “least” into a falsifiable statement

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/From_Vague_Claims_to_Verified_Access.png)

Turn least into a falsifiable statement

“Service account has least privilege” is not testable as written. I rewrite it as a set of precise assertions:

- Identity `backup-automation` may read guest metadata in scope `backup-managed`.
- It may start an approved snapshot operation for those guests.
- It may not delete a guest, change a virtual network, open a console, or operate on a guest outside that scope.
- Its credential is accepted only by the intended API audience.
- Its entitlement expires at the recorded time.
- Every allowed and denied privileged request is attributable to the identity and reaches independent audit storage.

The wording identifies five dimensions: subject, action, resource, context, and decision. A sixth dimension, evidence, tells me whether I can prove the decision later.

I use a compact authorization tuple:

```plaintext
(subject, action, resource, context) -> allow | deny
```

For example:

```plaintext
(backup-automation, snapshot.create, guest/app-01,
 {environment: production, change_window: open}) -> allow
```

A useful denial changes one element at a time:

```plaintext
(backup-automation, guest.delete, guest/app-01,
 {environment: production, change_window: open}) -> deny
```

That single-variable change matters. If I change the identity, action, resource, and network path in one test, a denial does not reveal which boundary held. A mature suite includes compound adversarial cases, but it starts with diagnostic tests that isolate policy dimensions.

## Build the matrix before building the role

Role names encourage premature abstraction. `Operator`, `Maintainer`, and `Administrator` sound meaningful until two teams assign different permissions to them. I start with tasks and prohibited outcomes, then derive roles or attributes.

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Authorization_Matrix_for_Zero_Trust.png)

Build the matrix before building the role

A deployment role might begin with this matrix:

| Test | Subject         | Action          | Resource              | Context                 | Expected | Reason                                      |
| ---- | --------------- | --------------- | --------------------- | ----------------------- | -------- | ------------------------------------------- |
| P1   | release-worker  | artifact.read   | approved repository   | valid workload identity | allow    | Retrieve signed release input               |
| P2   | release-worker  | workload.update | assigned namespace    | approved release        | allow    | Deploy the release                          |
| N1   | release-worker  | artifact.write  | approved repository   | valid workload identity | deny     | Build output cannot replace source artifact |
| N2   | release-worker  | secret.read     | unrelated application | valid workload identity | deny     | Prevent cross-application secret access     |
| N3   | release-worker  | workload.delete | platform namespace    | valid workload identity | deny     | Protect shared control-plane workloads      |
| N4   | release-worker  | role.bind       | any namespace         | valid workload identity | deny     | Prevent privilege delegation                |
| N5   | release-worker  | workload.update | assigned namespace    | expired identity        | deny     | Enforce credential lifetime                 |
| N6   | human-developer | workload.update | assigned namespace    | interactive session     | deny     | Require the deployment path                 |
| N7   | release-worker  | workload.update | assigned namespace    | wrong token audience    | deny     | Prevent token replay across services        |

The positive cases are intentionally narrow. They represent required business operations, not every action that currently happens to work. The negative cases cover privilege expansion, lateral access, destructive operations, expired context, human bypass, and token misuse.

I require each row to name the expected enforcement point and expected audit record. If the API gateway checks the token but the application performs authorization, both boundaries may need evidence. [NIST SP 800-207 ](https://csrc.nist.gov/pubs/sp/800/207/final?ref=secdoc.tech)separates policy decisions from policy enforcement and emphasizes resource-focused access decisions rather than trust based only on network location. A request originating on a management VLAN is not therefore authorized.

## Test the dangerous neighbors

A denial suite should concentrate on operations adjacent to legitimate work. Those are the permissions most likely to arrive through a wildcard, inherited role, group nesting, or broad resource selector.

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Test_the_Dangerous_Neighbors.png)

Test the dangerous neighbors

For a read-only auditor, test:

- list versus create
- read metadata versus read secret values
- view configuration versus export credentials
- query historical logs versus alter retention
- inspect identity membership versus change membership

For a backup operator, test:

- snapshot versus guest deletion
- backup creation versus repository deletion
- restore to an isolated target versus restore over production
- job execution versus schedule modification
- storage read versus encryption-key administration

For an identity administrator, test:

- user lifecycle versus federation-key management
- ordinary group membership versus privileged-group membership
- factor reset versus lowering authentication policy
- client registration versus assigning realm-wide administration
- access review versus self-approval

This is where wildcard permissions become visible. A positive test for `logs.read` passes whether the policy grants one index or every index. A negative test against the security audit index distinguishes those policies.

Resource selection deserves the same scrutiny as actions. Test an in-scope resource, a sibling resource, a parent, a newly created resource, an untagged resource, and a resource whose ownership attribute is missing. If a policy says `resource.team == subject.team`, decide what happens when either attribute is null, stale, duplicated, or supplied by the requester. My safe default is deny on missing decision inputs.

## Policy as code makes denial tests repeatable

[Open Policy Agent](https://www.openpolicyagent.org/docs/policy-testing?ref=secdoc.tech) provides a general-purpose policy engine and a test runner for Rego policies. The `opa test` command recursively loads policies and executes rules prefixed with `test_`. That makes positive and negative assertions suitable for continuous integration.

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Policy_as_Code_-Repeatable_Security_Tests.png)

Policy as code makes denial tests repeatable

A deliberately small policy can express a bounded deployment decision:

```rego
package deployment.authz

import rego.v1

default allow := false

allow if {
    input.subject.kind == "workload"
    input.subject.role == "release-worker"
    input.action == "workload.update"
    input.resource.namespace == input.subject.namespace
    input.resource.environment == "production"
    input.context.release_approved == true
    input.context.token_audience == "deployment-api"
    time.now_ns() < input.context.expires_at_ns
}
```

Its tests should prove both sides:

```rego
package deployment.authz_test

import rego.v1
import data.deployment.authz.allow

base_input := {
    "subject": {
        "kind": "workload",
        "role": "release-worker",
        "namespace": "example-app"
    },
    "action": "workload.update",
    "resource": {
        "namespace": "example-app",
        "environment": "production"
    },
    "context": {
        "release_approved": true,
        "token_audience": "deployment-api",
        "expires_at_ns": 4102444800000000000
    }
}

test_approved_release_is_allowed if {
    allow with input as base_input
}

test_role_binding_is_denied if {
    not allow with input as object.union(base_input, {
        "action": "role.bind"
    })
}

test_other_namespace_is_denied if {
    changed := object.union(base_input.resource, {
        "namespace": "shared-platform"
    })
    not allow with input as object.union(base_input, {
        "resource": changed
    })
}

test_missing_approval_is_denied if {
    changed_context := object.remove(base_input.context, {"release_approved"})
    input_without_context := object.remove(base_input, {"context"})
    changed_input := object.union(input_without_context, {
        "context": changed_context
    })
    not "release_approved" in changed_input.context
    not allow with input as changed_input
}

test_wrong_audience_is_denied if {
    changed := object.union(base_input.context, {
        "token_audience": "metrics-api"
    })
    not allow with input as object.union(base_input, {
        "context": changed
    })
}
```

The example is not a complete deployment policy. It demonstrates important properties: explicit default deny, workload identity, exact action, resource relationship, environmental context, approval, audience, and expiration. In production, I would inject a controlled time into the test boundary instead of depending on the runner’s wall clock, validate the input schema, and add cases for wrong types and oversized or malformed values.

A passing unit suite is still not end-to-end evidence. It proves policy logic against supplied inputs. It does not prove the application asked OPA before every sensitive operation, passed trustworthy attributes, enforced the returned decision, or logged the result. Integration tests must call the real policy enforcement point.

## Test the identity system and the application separately

[Keycloak Authorization Services](https://www.keycloak.org/docs/latest/authorization%5Fservices/index.html?ref=secdoc.tech) can model resources, scopes, permissions, policies, and enforcement modes. That can centralize decisions, but centralization does not eliminate application mistakes. The resource server still has to validate issuer and audience, map the requested operation to the correct scope, and enforce the decision for every path.

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Test_Identity_and_Application_Security_Layers.png)

Test the identity and the application layers separately

For a Keycloak-protected API, I test at least:

1. valid token, correct audience, required scope, in-scope resource: allow
2. valid token, correct audience, missing scope: deny
3. valid token, wrong audience: deny
4. expired token: deny
5. token signed by an untrusted issuer: deny
6. valid token for sibling tenant or project: deny
7. no token: deny
8. valid token sent to an alternate endpoint for the same operation: deny
9. disabled subject with a previously issued token: behavior matches the documented revocation and token-lifetime design
10. policy engine unavailable: fail according to the system’s declared failure mode

That ninth case forces an architectural decision. Short-lived access tokens limit how long a disabled user’s existing token remains useful, but aggressive lifetimes increase refresh traffic and sensitivity to IdP outages. Immediate revocation may require introspection, back-channel mechanisms, or local deny state. The correct balance depends on the resource, but the balance must be explicit and tested.

[Authentik](https://docs.goauthentik.io/?ref=secdoc.tech) can provide identity, federation, groups, and application access control in an open-source stack. I use the same test discipline regardless of IdP: test the emitted claims, the application’s claim mapping, group removal, disabled users, stale sessions, and direct access that bypasses the proxy or outpost. An identity provider can deny its path while an exposed application origin quietly permits another.

For Linux host access, [FreeIPA](https://freeipa.readthedocs.io/en/stable/workshop/8-sudorule.html?ref=secdoc.tech) provides host-based access control and central sudo rules. Its `ipa hbactest` command can evaluate whether a user, host, and service combination matches the [HBAC](https://freeipa.readthedocs.io/en/stable/workshop/4-hbac.html?ref=secdoc.tech) rules. I pair that static evaluation with an actual authentication attempt against a non-production acceptance host. The static test proves rule evaluation. The live test covers SSSD state, PAM service naming, cache behavior, host enrollment, and the SSH path.

A FreeIPA matrix should include:

| User                | Host                      | PAM service | Expected                  |
| ------------------- | ------------------------- | ----------- | ------------------------- |
| authorized operator | assigned application host | sshd        | allow                     |
| authorized operator | directory server          | sshd        | deny                      |
| ordinary user       | assigned application host | sshd        | deny                      |
| authorized operator | assigned application host | login       | deny unless required      |
| removed operator    | assigned application host | sshd        | deny after cache deadline |
| authorized operator | unknown host identity     | sshd        | deny                      |

Do not leave a broad `allow_all` rule enabled while claiming the narrower HBAC tests prove restriction. An allow rule that matches anywhere can invalidate the intended boundary.

## Denial is an outcome, not an HTTP status

A `403 Forbidden` response is not enough by itself. I verify that the denied operation had no effect.

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Denial_Is_an_Outcome_Cybersecurity_Infographic.png)

Denial is an outcome not an HTTP status

For a denied user creation, query the identity store and confirm the account does not exist. For a denied secret read, ensure the response, logs, traces, and error object did not disclose the value. For a denied deployment, verify no new workload, partial configuration, queued job, or asynchronous side effect appeared. For a denied delete, read back the exact resource and its dependent objects.

Different failure modes also need careful handling:

- Authentication failures should not reveal whether a protected resource exists.
- Authorization failures should not return sensitive object metadata.
- Batch APIs should define whether one forbidden item rejects the batch or produces per-item results.
- GraphQL and search endpoints need field-level and result-filtering tests, not only endpoint tests.
- Background workers must recheck authorization or use a constrained delegated capability rather than trusting an old front-end decision indefinitely.
- Caches need revocation and expiry tests.

The policy decision, enforcement result, and resource read-back form one evidence chain.

## Test inheritance, composition, and precedence

Authorization failures often live in composition rather than a single rule. A user may receive access from a direct assignment, group membership, nested group, default role, resource ownership, service account mapping, or emergency exception.

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Test_Inheritance_and_Access_Paths.png)

Test inheritance, composition and precedence

I create test identities that isolate each path:

```yaml
subjects:
  direct_only:       [direct-assignment]
  group_only:        [team-membership]
  nested_group_only: [nested-team-membership]
  default_only:      [default-role]
  conflict_case:     [allow-group, deny-attribute]
  no_attributes:     []
```

Then I document precedence. Does explicit deny override allow? Does a realm-level role imply client roles? Are permissions additive? Does removing one group leave equivalent access through another? Is a default role attached to every new account? A test should answer each question against the running platform.

This also applies to infrastructure policy. Kubernetes RBAC permissions are additive, so a second binding can preserve access after the first is removed. Cloud IAM systems may combine identity and resource policies with organization constraints or explicit denies. Unix access can combine directory groups, local groups, sudoers fragments, file mode bits, POSIX ACLs, capabilities, and daemon-specific rules. Revoking the visible grant is not proof of revocation.

## Exercise time and lifecycle boundaries

Least privilege includes duration. A permission needed for thirty minutes should not exist for thirty days.

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Time_Limited_Access_Lifecycle.png)

Exercise time and lifecycle boundaries

Every temporary grant needs:

- activation time
- absolute expiration time
- non-renewable or bounded-renewal behavior
- approver and business reason
- subject and exact scope
- revocation method
- owner for cleanup failures
- audit correlation identifier

The denial suite tests before activation, during the valid window, immediately after expiry, after explicit revocation, after group removal, and after credential rotation. Where caches exist, define a maximum propagation time. “Eventually denied” is not a test result until eventually has a deadline.

For long-lived service credentials that cannot yet be replaced with workload identity, rotation needs overlapping tests. Prove the new credential works, revoke the old credential, then prove the old one fails. Avoid a window where both remain valid without a measured reason. Never print either credential in test output.

## Preserve evidence without preserving secrets

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Authorization_Testing_Without_Exposed_Secrets.png)

Preserve evidence without preserving secrets

A useful authorization test record contains:

```json
{
  "test_id": "AUTHZ-N7",
  "policy_revision": "reviewed-policy-revision",
  "subject_class": "release-workload",
  "action": "workload.update",
  "resource_class": "assigned-namespace",
  "context_case": "wrong-audience",
  "expected": "deny",
  "observed": "deny",
  "enforcement_point": "deployment-api",
  "decision_id": "generated-correlation-value",
  "started_at": "example-timestamp",
  "completed_at": "example-timestamp",
  "side_effect_check": "none-observed"
}
```

I do not store bearer tokens, session cookies, password values, raw secret responses, or full personal data. Subject classes and synthetic test identities are usually enough. Policy revision, decision correlation, timestamps, and resource class make the result reproducible without turning the evidence bundle into a credential cache.

Audit must include denials, but logging every low-value denial at maximum detail can create cost and privacy problems. I separate decision records needed for accountability from application diagnostics, rate-limit repetitive unauthenticated noise, protect log integrity, and alert on patterns such as repeated cross-scope access, privilege-assignment attempts, or use after revocation. [AC-6(9) ](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final?ref=secdoc.tech)is specifically concerned with auditing privileged functions, while [AU-family controls](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final?ref=secdoc.tech) govern the larger audit program.

## Make denial tests a release gate

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/make_denial_tests_a_release_gate.png)

Make denial tests a release gate

I run denial tests at four points:

1. **Policy unit tests** on every policy change.
2. **Integration tests** against the real decision and enforcement path before release.
3. **Post-deployment canaries** using synthetic identities and harmless resources.
4. **Periodic entitlement tests** to catch drift outside the application release cycle.

A release fails if a required positive case is denied or a prohibited negative case is allowed. It also fails if the decision is unknown, the enforcement point is bypassed, the side-effect check is inconclusive, or required audit evidence is missing.

The gate should report changed authorization surface, not just pass count:

```plaintext
policy revision: reviewed-policy-revision
positive cases: 14 passed, 0 failed
negative cases: 37 passed, 0 failed
new permissions: 1 expected
removed permissions: 2 expected
unknown decisions: 0
missing audit correlations: 0
```

A large number of passing tests can still hide an untested wildcard. I therefore compare the effective permission set before and after a change where the platform can enumerate it, and I require explicit review for new actions, broader resource selectors, new group inheritance, or longer duration.

This is the practical connection to the authorization and validation work I discuss in [*Cybersecurity Architect’s Handbook, Second Edition*](https://www.amazon.com/Cybersecurity-Architects-Handbook-architects-enterprise/dp/180610539X/ref=cm%5Fcr%5Farp%5Fd%5Fproduct%5Ftop?ie=UTF8&ref=secdoc.tech): a control statement becomes architecture only when its trust boundary, enforcement behavior, failure mode, and evidence are defined.

## The claim I am willing to make

After these tests, I will not claim that an identity has “no excessive privilege.” That is an absolute claim a finite suite cannot prove.

![](https://storage.ghost.io/c/b9/e8/b9e8be96-d607-455c-b908-45c0663214eb/content/images/2026/10/Least_Privilege_in_Practice.png)

Least privilege in practice

I will make a bounded statement:

> At the recorded policy revision and test time, the named subject class completed the required operations, the enumerated prohibited operations were denied at the intended enforcement points, denied operations produced no observed side effects, temporary access expired within the defined deadline, and decision records reached the audit destination.

That statement exposes what was tested and what remains outside scope. It can be repeated after a role change, application release, IdP migration, group update, or policy-engine upgrade.

**Least privilege** is not a *role name* and not *a screenshot* of a permissions page. **It is a maintained boundary.** Positive tests show that work can cross where intended. Denial tests show that authority stops where intended.