The previous lesson closed with twelve prioritised measures for Nimbus, and number one was MFA. That was no accident: several of the others also revolve around the same question — who you are and what you can do. When half the headcount works outside the office, the data is in the cloud, a consultancy has remote access and the API is consumed by a mobile app, the perimeter stops being a line on the network. What is left between an attacker and the clinics' diaries is a credential and an authorisation decision. This lesson develops both: the life cycle of an identity and orphaned accounts, what the current guidance says about passwords, the forms of MFA ordered by their real phishing resistance, SSO and the identity protocols with the exact difference between OAuth 2.0 and OpenID Connect, sessions and JWTs with their correct validation as against the naive one, the five authorisation models with Nimbus's concrete design, and privileged accounts with just-in-time access and recertification.

Contents

  1. Identity as the new perimeter
  2. The identity life cycle and orphaned accounts
  3. Authentication factors and their weaknesses
  4. Passwords: what the guidance says today
  5. MFA ordered by phishing resistance
  6. SSO, federation and identity protocols
  7. Sessions and tokens: cookies and JWTs
  8. Authorisation models and Nimbus's RBAC
  9. Multi-tenancy and isolation
  10. Privileged, service and third-party accounts
  11. Recertification: the periodic access review

  1. Identity as the new perimeter

The classic model divided the world into inside and outside, and put the control on the boundary. That model describes Nimbus's reality less and less well:

Element Where it is Does a network perimeter cover it?
Database and buckets (A-01, A-02, A-03) The provider's cloud (A-05) No
19 remote employees Homes, cafés, trains No
Customers' mobile app Thousands of other people's devices No
Repository and CI/CD (A-06) External SaaS No
External consultancy (A-19) Its own network No
Mail, payment gateway, transactional e-mail External SaaS No

The only thing that crosses all of those contexts is identity. If an attacker obtains Lucía's credentials, they do not need to get through any firewall: they walk in through the front door of every one of those systems, from anywhere in the world, and everything they do will look legitimate.

Hence the formulation that sums up the Zero Trust approach of 01-03 applied to this domain:

Identity is the new perimeter. A request is not trusted because it comes "from inside"; it is trusted because you can verify who is making it, with what device, for which resource and under what conditions, on every request.

Three concepts that must be separated precisely, because they are constantly confused:

Concept Question Example at Nimbus
Identification Who do you say you are? Typing in lucia@nimbusreservas.example
Authentication Can you prove it? Password + FIDO2 key
Authorisation What can you do? Lucía can deploy; she cannot see payroll

And the recurring trap: most serious incidents are not authentication failures, they are authorisation failures. The IDOR of 01-01 happened with a perfectly valid session. Authenticating well and authorising badly is the number one pattern in the OWASP Top 10.


  1. The identity life cycle and orphaned accounts

An identity is not a static record: it is a process with three moments, and it is the third that almost always fails.

flowchart LR
    A["JOINING\nWho comes in, with what\naccess and who approves it"] --> B["CHANGES\nRole change, project,\ntemporary permissions"]
    B --> C["LEAVING\nComplete revocation\nacross every system"]
    B -->|"if nobody withdraws\nthe previous access"| D["PRIVILEGE\nACCUMULATION"]
    C -->|"if some system\nis forgotten"| E["ORPHANED\nACCOUNT"]
    D --> F["A user with more\npermissions than any\nadministrator"]
    E --> G["Valid access with no\nowner and no oversight"]

2.1 Joining

The four requirements of a correct joining process:

  1. Based on the role, not on a copy. The classic mistake is "give them the same permissions as Iván". That also copies the permissions Iván accumulated on old projects, and privilege spreads like an infection. There must be a predefined access profile per role.
  2. With a recorded approval from the asset owner (the "owner" field of the 01-04 inventory exists for exactly this).
  3. With an expiry date if it is temporary. An intern, a three-month contract or a consultancy must have an end date in the system, not in somebody's memory.
  4. With a named identity. No shared accounts: without a named identity there is no traceability, and without traceability there is no non-repudiation (01-01).

2.2 Changes: privilege accumulation

This is the silent problem. Rubén joined support, helped out in billing for six months, took part in the data migration and now coordinates the team. If at each change permissions were added without withdrawing the previous ones, Rubén today has more access than anybody else at Nimbus, and nobody ever decided that.

The rule: every role change is a removal of the old role and a grant of the new one, not an addition. And every temporary permission is created with an end date.

2.3 Leaving: the Nimbus case

A developer leaves Nimbus. Lucía deactivates his mail account the same day and considers the process finished. Six months later, during the inventory review, this turns up:

System State after the "leaving" Risk
Corporate e-mail Deactivated
Cloud provider account (A-05) Active, with read permissions on the buckets Access to customer data from anywhere
GitHub Active as an outside collaborator on the repository Access to the code and the history, including old secrets
VPN Deactivated
Database Personal user dev_carlos active Direct access to production data
SSH key on the application server Present in authorized_keys Access to the server without going through any central authentication
Ticketing tool (SaaS) Active Customer data in the tickets
Support WhatsApp group Present Screenshots containing customer data
Personal GitHub token he created Active and with no expiry Access to the code even if the account is deactivated

Eight live accesses belonging to somebody who no longer works here. And none of them shows up on a firewall, because technically all of them are legitimate.

Why orphaned accounts are especially dangerous, beyond the obvious:

  • Nobody watches them. Anomalous use of Lucía's account might get noticed; nobody misses dev_carlos.
  • Nobody maintains them. Their password is not rotated, MFA is not added when it is rolled out, their permissions are not reviewed.
  • They are the preferred target of credential stuffing. If that person reused the password and it appears in another service's breach, the door is still open.
  • There is not always malicious intent. The ex-employee who keeps logging in "to help" creates an equally serious problem: access with no current authorisation and no control.

The correct leaving procedure for Nimbus:

LEAVER CHECKLIST - carried out on the LAST DAY, not afterwards
[ ] Deactivate the central identity (SSO) - blocks everything federated
[ ] Revoke active sessions and tokens (deactivating the account is not
    enough: a live session keeps working)
[ ] Go through the list of NON-federated systems, one by one:
    [ ] Cloud provider   [ ] GitHub and personal tokens
    [ ] Database         [ ] SSH keys in authorized_keys
    [ ] Ticketing SaaS   [ ] Payment gateway  [ ] Transactional e-mail
[ ] Remove from messaging groups and channels
[ ] Reassign ownership of their inventory assets (01-04)
[ ] Rotate the shared secrets they knew
[ ] Recover the laptop and the encryption recovery key
[ ] Record the leaving as completed, with date and responsible person

The two points most often forgotten are the revocation of sessions and tokens — deactivating an account does not invalidate an already-issued session, as you saw with pass-the-cookie in 02-03 — and the rotation of shared secrets: if that person knew the NAS password or the gateway key, those credentials are compromised by definition.

The structural measure that halves the problem: the more systems are federated with a single identity provider, the fewer boxes there are to tick. That is the main reason SSO is a security measure and not merely a convenience.


  1. Authentication factors and their weaknesses

A factor is a category of proof. Multi-factor authentication requires factors of different categories; two passwords are not two factors.

Factor What it is Examples Weaknesses
Something you know Knowledge Password, PIN, answer to a question Can be stolen, guessed, reused, phished and shared without leaving a trace
Something you have Possession FIDO2 key, authenticator app, card, phone Gets lost, gets stolen; SMS can be intercepted; codes can be phished
Something you are Biometrics Fingerprint, face Cannot be changed if compromised; false positives and negatives; in practice it unlocks a device, it does not authenticate to the service
(Contextual) Where and how IP, country, known device, time Not a factor on its own: it is a risk signal that modulates how much is demanded

Three clarifications that correct frequent misunderstandings:

  1. Biometrics almost never travel to the server. When Iván unlocks his phone with his fingerprint, the biometric data does not leave the device: it unlocks a private key stored in the secure hardware. What the service receives is a cryptographic proof. That distinction is what makes passkeys robust rather than a privacy problem.
  2. Security questions are not a factor, they are a worse password: the answer is usually public or guessable, and you cannot change the name of your childhood school.
  3. Context does not replace a factor, but it is enormously valuable in combination: requiring reauthentication when the country or the device changes is what turns a stolen token into a useless one.

  1. Passwords: what the guidance says today

The recommendations changed substantially over the last decade, and many organisations are still applying the 2005 ones — which make security worse. The current reference is the NIST SP 800-63B guidance.

Traditional practice Current recommendation Why it changed
Minimum 8 characters with upper case, lower case, digits and symbols Minimum 12-15 characters, with no composition requirement Mandatory complexity produces Company2026! predictably; length provides far more resistance
Expiry every 90 days No forced expiry, unless there is an indication of compromise Mandatory rotation generates trivial variations (…2026!…2027!) and increases the use of written notes
Forbid pasting into the field Always allow pasting Forbidding it prevents the use of password managers, which is what really helps
Security questions as a fallback Remove them Public or guessable answers
Password hints Remove them They are a partial leak of the password
Check against lists of leaked passwords It cuts off credential stuffing and dictionary attacks at the root
Allow all characters, including spaces and unicode Makes long passphrases easier
Maximum length ≥ 64 characters A low maximum gives away that the password might not be stored properly

Checking against leaked lists is the single most effective measure in this table, and the least implemented. It stops somebody choosing a password that is already in the dictionaries attackers use. It is implemented without sending the password anywhere: the first characters of the hash are sent and the rest is compared locally (the k-anonymity model).

Password managers. For Nimbus they are mandatory, and the argument is simple: they make it possible to do the one thing that really matters — a long, random, per-service password — without memorising anything. The usual objection ("what if the manager is compromised?") ignores the real alternative, which is not memorising 40 strong passwords: it is reusing three weak ones. A manager with a strong master password and MFA is, by an enormous margin, the safest option. An added and little-known benefit: the manager does not fill in credentials on a domain that does not match, which turns it into a passive phishing detector.

The secure storage of passwords on the server — why they are never kept in the clear or with a plain hash, and what bcrypt, scrypt and Argon2 are, with their cost factor and their salt — is studied in detail in 03-04. Here the rule is enough: Nimbus never stores recoverable passwords, and no legitimate process can give a customer back their current password.


  1. MFA ordered by phishing resistance

MFA is the most cost-effective measure in the lesson, but not all forms of MFA are worth the same. The decisive difference is whether the second factor can be captured and reused by an interposed attacker.

Method How it works Phishing resistance Other weaknesses Use at Nimbus
SMS A 6-digit code by message Very low: the code is typed in and can be relayed in real time SIM swapping, interception, dependence on coverage Only as a last resort; better than nothing
E-mail A code or link to the mailbox Very low If the mail falls, everything falls Avoid
TOTP (authenticator app) A 6-digit code derived from a shared secret and the time Low-medium: it is still a code that can be asked for The secret can be copied at set-up; 30 s window The acceptable minimum for normal accounts
Simple push A notification with "Approve / Deny" Low: vulnerable to MFA fatigue (02-03) Approval with no context Insufficient on its own
Push with number matching Shows a number on the screen that has to be entered in the app Medium: it requires looking at the original device Still phishable with effort Acceptable
FIDO2 / WebAuthn and passkeys Public key cryptography bound to the domain High: phishing-resistant by design Cost of the physical key; backup management The target for all administrative accounts
Client certificate A certificate on the device High PKI management (03-06) For service-to-service

5.1 Why FIDO2 is qualitatively different

All the previous methods share one underlying defect: they produce a secret the user transmits. If the user is on a fake website, they transmit the secret to the attacker, who relays it to the real site within seconds. That is exactly what reverse-proxy phishing kits do, and it is why "we have MFA" stopped being a sufficient answer.

FIDO2 breaks that scheme with two properties:

  1. The private key never leaves the device. There is no secret the user could hand over, not even if they wanted to.
  2. The signature is bound to the domain (origin binding). The browser includes the page's real origin in the operation. If the user is on nimbusreservas.example.portal-facturas.example, the key produces a signature for that domain, which the legitimate server rejects. The attack does not fail because the user notices: it fails because it is cryptographically invalid.

Put another way: with TOTP or SMS, the defence depends on the user spotting the fake site. With FIDO2, the defence works even if the user spots nothing. That is the difference between a measure that depends on human judgement and one that does not. Remember the principle from 02-03: if your defence requires 38 people to get it right every time, you have no defence.

Passkeys are FIDO2 with the key synchronised between the user's devices through their ecosystem, which removes the main friction (losing the key) in exchange for trusting the synchroniser.

5.2 TOTP verification, explained

Even if the target is FIDO2, TOTP remains the realistic minimum for many accounts. It is worth understanding how it works inside.

import hmac, hashlib, struct, time

def totp_code(secret: bytes, at_time: int | None = None,
              step: int = 30, digits: int = 6) -> str:
    """Generates the 6-digit TOTP code (RFC 6238)."""
    # 1. The "counter" is the number of 30 s intervals since 1970.
    #    Issuer and verifier compute it separately: nothing is transmitted.
    counter = int((at_time or time.time()) // step)

    # 2. HMAC-SHA1 of the counter with the shared secret.
    #    HMAC guarantees that only someone who knows the secret can
    #    generate the value (03-04).
    digest = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()

    # 3. "Dynamic truncation": the last 4 bits say where to start reading.
    offset = digest[-1] & 0x0F
    value = struct.unpack(">I", digest[offset:offset + 4])[0]
    value &= 0x7FFFFFFF                      # the sign bit is discarded

    # 4. Reduced to 6 digits.
    return str(value % (10 ** digits)).zfill(digits)


def verify_totp(secret: bytes, code: str, user_id: int,
                window: int = 1) -> bool:
    """Correct verification: clock tolerance, constant time and
       protection against reuse."""
    now = time.time()

    # (a) The current interval and one before/after are accepted: clocks
    #     are not perfectly synchronised. Larger windows weaken it.
    for delta in range(-window, window + 1):
        expected = totp_code(secret, now + delta * 30)
        # (b) Constant-time comparison: prevents deducing the code by
        #     measuring how long the comparison takes to fail.
        if hmac.compare_digest(expected, code):
            # (c) A code may be used ONCE only: without this, a captured
            #     code works for the next 30-90 s.
            if cache.get(f"totp:{user_id}:{code}"):
                return False
            cache.set(f"totp:{user_id}:{code}", "1", expire=120)
            return True
    return False

The four decisions that separate this implementation from a naive one:

  • (a) A bounded tolerance window. With no tolerance, users whose clock is slightly off cannot get in. With a large window (say ±5), the code is valid for almost five minutes and the real-time attack becomes trivial. ±1 is the usual balance.
  • (b) hmac.compare_digest. A normal string comparison stops as soon as it finds a difference, and that time is measurable. In constant time nothing leaks.
  • (c) Single-use consumption. It is the control most often forgotten: without it, a code captured by phishing remains valid for the rest of the window, which is exactly what the interposed attacker needs.
  • The secret is stored encrypted in the database, not in the clear: anyone who reads it can generate codes indefinitely.

And the honest conclusion: however well implemented it is, TOTP is still phishable, because the user types a code into a page. That is why the direction of travel for Nimbus is TOTP as the general minimum and FIDO2 for anything administrative.

5.3 MFA fatigue and its fix

Connecting with 02-03: notification bombing exploits the fact that approving is a button with no context. The fixes, in order of effectiveness:

  1. Number matching: it forces you to look at the originating screen and enter a number, which makes approving out of inertia impossible.
  2. Context in the notification: application, approximate location, device. A request from another country is obvious.
  3. Attempt limits: after 3 denied or ignored requests, the account is locked and an alert fires.
  4. Alert to security on bursts: a burst of MFA requests means somebody already has the password, whether it gets approved or not. It is a high-value signal that hardly anybody watches.

  1. SSO, federation and identity protocols

6.1 Why SSO is a security measure

With single sign-on, the user authenticates once to an identity provider and reaches every federated application.

Advantage Why it matters at Nimbus
One leaving process deactivates everything Drastically shortens the list of boxes in section 2.3
MFA configured once, applied everywhere No need to set it up service by service
A single point of logging Every log-in in one log, correlatable
Centralised policies Requiring a known device or risk-based reauthentication, in one place
Fewer passwords Less reuse, less effective phishing

The counterweight, which has to be accepted consciously: the identity provider becomes the most critical asset of all. If it goes down, nobody gets into anything; if it is compromised, everything is compromised. Hence: mandatory FIDO2 MFA for its administrators, exhaustive logging, and an emergency access procedure with credentials kept offline for the case of unavailability.

6.2 OAuth 2.0 versus OpenID Connect: the exact difference

It is the most widespread confusion in the domain, and it produces insecure systems.

OAuth 2.0 OpenID Connect (OIDC)
What it is for Authorisation: giving an application limited access to resources on the user's behalf Authentication: establishing who the user is
Question it answers "May this app read your calendar?" "Who are you?"
What it hands over An access token: a key with permissions An ID token (a signed JWT) with the identity
Who consumes it The API that receives the token The application doing the log-in
Relationship It is the foundation It is a layer on top of OAuth 2.0

The sentence to fix in your mind: OAuth 2.0 is not an authentication protocol. Using an access token as proof of identity is a classic mistake: that token says what can be done, not who you are, and it might have been issued for a different application. If what you need is "who is this user", use OIDC and validate the ID token.

SAML is the veteran: the same objective as OIDC (federated authentication), based on XML and signed assertions, very widely deployed in the corporate world. For new integrations OIDC is preferred for its simplicity and its natural fit with mobile applications and SPAs; SAML is still unavoidable when a large corporate customer demands it, and that is a common request for a B2B SaaS like Nimbus.

6.3 The correct flow for the Nimbus SPA: Authorization Code + PKCE

sequenceDiagram
    participant U as User
    participant SPA as Nimbus SPA
    participant IDP as Identity provider
    participant API as Nimbus API

    SPA->>SPA: Generates a random code_verifier<br/>and code_challenge = SHA256(verifier)
    SPA->>U: Redirects to the IdP with code_challenge
    U->>IDP: Authenticates (password + FIDO2)
    IDP->>IDP: Validates credentials and consent
    IDP->>SPA: Redirects with a single-use CODE
    SPA->>IDP: Exchanges code + code_verifier
    IDP->>IDP: Checks SHA256(verifier) == challenge
    IDP->>SPA: ID token (who you are) + Access token (what you can do)
    SPA->>API: GET /api/v1/reservas<br/>Authorization: Bearer <access token>
    API->>API: Validates signature, iss, aud, exp and scope
    API->>SPA: 200 with the data of the user's tenant

Why PKCE is essential and not an optional extra. The authorisation code travels through the browser, and in a public application (an SPA or a mobile app) there is no way of keeping a client secret: anyone can decompile the app or read the JavaScript. If an attacker intercepts the code, they could exchange it for tokens.

PKCE prevents this with a simple idea: the application generates a random value (code_verifier), sends only its hash when starting the flow, and presents the original value when exchanging. Whoever does not have the code_verifier cannot use the stolen code, and that value never leaves the browser that started the flow.

The four common mistakes in this flow:

  • Using the implicit flow (tokens in the URL): obsolete and discouraged.
  • Storing the tokens in localStorage: reachable from JavaScript, and therefore stealable with an XSS. A HttpOnly cookie managed by a server-side intermediary is preferable.
  • Not validating aud in the API: accepting a token issued for another application.
  • Not validating the state parameter: it is the CSRF protection of the authentication flow itself.

  1. Sessions and tokens: cookies and JWTs

7.1 Secure cookies

Set-Cookie: session=aB3xK9...; HttpOnly; Secure; SameSite=Lax;
            Path=/; Max-Age=3600; Domain=app.nimbusreservas.example
Attribute What it does Which attack it stops
HttpOnly JavaScript cannot read it Session theft through XSS
Secure Only sent over HTTPS Capture in transit
SameSite=Lax Not sent on requests originating from another site CSRF (02-02)
Max-Age=3600 Short life with renewal Pass-the-cookie: reduces the window of a stolen cookie
Narrow Path and Domain Minimal scope Stops the cookie travelling to unnecessary subdomains
The __Host- prefix in the name Forces Secure, Path=/ and the absence of Domain Cookie fixation from a compromised subdomain

SameSite=Strict versus Lax: Strict is safer but breaks navigation from external links (the user arrives at the panel and appears not to be logged in). Lax is the usual balance; for sensitive operations it is combined with an anti-CSRF token.

7.2 JWT: structure and what not to put inside

A JWT has three parts separated by dots: header, payload and signature, encoded in base64url.

eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDMifQ.eyJpc3MiOiJodHRwczovL2lkc...
└──── header ────┘ └──────── payload ────────┘ └── signature ──┘
{
  "iss": "https://identidad.nimbusreservas.example",
  "sub": "usr_8812",
  "aud": "https://api.nimbusreservas.example",
  "exp": 1774000000,
  "iat": 1773999100,
  "jti": "9f2a4c1e-77bb-4a10-9c31-7d2e5a6b1c04",
  "tenant_id": 42,
  "roles": ["reception"]
}

The first thing to understand, and the most misinterpreted: a JWT is signed, not encrypted. Anyone holding it can read its contents with no key at all. The signature guarantees integrity and origin, not confidentiality.

What must NEVER go inside a JWT:

Do not include Why
Passwords or secrets It is text readable by anyone
Sensitive personal data (patient name, medical service) It travels on every request, is written to logs and proxies, and stays in the browser
Card or account numbers Same reason, with specific obligations attached
Data that changes often The token is a snapshot: if a role is revoked, the old token keeps saying the opposite
Large structures The JWT travels on every request: it costs performance

What it should carry: a subject identifier (sub), the tenant, the minimal roles or scopes, and always iss, aud, exp, iat and jti.

7.3 Correct validation versus naive validation

# ============ NAIVE AND VULNERABLE VALIDATION ============
import jwt

def user_from_token_BAD(token: str):
    # (1) No signature verification: anyone can forge any token they like
    claims = jwt.decode(token, options={"verify_signature": False})
    # (2) No check of expiry, issuer or audience
    # (3) The roles inside are simply trusted
    return User(id=claims["sub"], tenant=claims["tenant_id"],
                roles=claims["roles"])

The three flaws and why each is exploitable:

  1. With no signature verification, an attacker composes a JWT with "roles": ["admin"] and "tenant_id": 7 and walks in as administrator of any clinic. The historical variant of this flaw is accepting the none algorithm that the token itself declares in its header: the token's alg must never be trusted to decide how to validate it.
  2. With no exp check, a token stolen a year ago still works. Without iss and aud, a valid token issued by another system or for another application is accepted here.
  3. Blindly trusting the roles in the token means that a permission change or a leaver has no effect until the token expires.
# ============ CORRECT VALIDATION ============
import jwt
from jwt import PyJWKClient
from fastapi import HTTPException

JWKS = PyJWKClient("https://identidad.nimbusreservas.example/.well-known/jwks.json")
ISSUER   = "https://identidad.nimbusreservas.example"
AUDIENCE = "https://api.nimbusreservas.example"

def user_from_token(token: str):
    try:
        # (1) The public key is fetched from the issuer according to the
        #     header's 'kid'. It allows keys to be ROTATED without
        #     redeploying the API.
        key = JWKS.get_signing_key_from_jwt(token).key

        claims = jwt.decode(
            token,
            key,
            algorithms=["RS256"],        # (2) algorithm FIXED by us,
                                         #     never the one the token says
            issuer=ISSUER,               # (3) who issued it
            audience=AUDIENCE,           # (4) who it was issued for
            options={"require": ["exp", "iat", "iss", "aud", "sub", "jti"],
                     "verify_exp": True},
            leeway=30,                   # (5) clock tolerance: 30 s
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "Session expired")
    except jwt.InvalidTokenError:
        raise HTTPException(401, "Invalid token")

    # (6) REVOCATION: a valid signature is not enough. We check that the
    #     token is not on the revoked list (leaver, log-out, theft).
    if cache.get(f"revoked:{claims['jti']}"):
        raise HTTPException(401, "Session revoked")

    # (7) SENSITIVE permissions are resolved against the source of truth,
    #     not against whatever a token issued 50 minutes ago says.
    permissions = load_current_permissions(claims["sub"], claims["tenant_id"])

    return User(id=claims["sub"], tenant=claims["tenant_id"],
                roles=permissions, jti=claims["jti"])

The seven decisions, explained:

  1. Keys from JWKS by kid. It allows the signing key to be rotated without touching the API. Hard-coding the public key turns rotation into a deployment, and what is hard does not get done.
  2. algorithms=["RS256"] fixed. It closes off the algorithm confusion attack (for example, passing the RSA public key off as an HMAC secret).
  3. and 4. issuer and audience. Without aud, a legitimate token for another service from the same issuer would be accepted by the Nimbus API.
  4. leeway. It avoids spurious failures caused by clock drift; 30 s is reasonable, minutes are not.
  5. Revocation by jti. This is the JWT's structural weak point: being self-contained, it is valid until it expires even if the user has left. A revocation list with the same lifetime as the token solves the problem at minimal cost.
  6. Current permissions from the source of truth. The roles in the token serve as a quick hint; sensitive decisions are taken with up-to-date data.

7.4 Token lifetimes and refresh

Token Recommended life Where it is kept Notes
Access token 5-15 minutes The application's memory Short because it is hard to revoke
Refresh token Hours or days, with rotation HttpOnly Secure SameSite cookie Each use issues a new one and invalidates the previous one
Service token (API) 90 days maximum, automated rotation Secrets manager Never without an expiry

Refresh rotation with reuse detection, which is the elegant piece: if a refresh token that has already been used is presented again, it means somebody holds a stolen copy. The correct response is to invalidate the whole token family of that session and force reauthentication. It is a theft detection mechanism that works with no extra infrastructure.

7.5 The non-expiring token of 01-04, fixed

Recall the finding: the nimbus-deploy-bot service account with a personal token with no expiry and write permissions on every repository. It was also the first link of the chained attack of 02-02.

Everything that is wrong with it:

Problem Consequence
No expiry Stolen once, it works forever
Total scope over every repository The compromise is not bounded
Tied to a personal bot-type account There is no clear owner and no traceability
Stored in the CI configuration and probably on laptops Multiple copies, multiplied surface
No reviewed usage log Nobody would notice anomalous use
No rotation It has never changed since it was created

The fix, in order of preference:

  1. Remove it and use short-lived credentials from the CI itself (a short-lived federated identity issued for each run). The best secret is the one that does not exist.
  2. If that is not possible: an application token with per-repository scope, a 90-day expiry, automated rotation and storage in the secrets manager.
  3. In any case: an alert on any use from an unexpected IP and a quarterly review of live tokens.
-- Hygiene query: tokens with no expiry or with no recent use.
-- It should run monthly and ALWAYS return zero rows.
SELECT id, name, owner, created_at, last_used, scope
  FROM api_tokens
 WHERE expires_at IS NULL                            -- never expire
    OR expires_at > now() + interval '90 days'       -- excessive life
    OR last_used < now() - interval '30 days'        -- created and forgotten
 ORDER BY created_at;

Tokens unused for 30 days are as dangerous as eternal ones, and for the same reason as orphaned accounts: nobody would miss them if somebody started using them.


  1. Authorisation models and Nimbus's RBAC

Authentication answers "who you are". Authorisation answers "what you can do", and it is where most serious incidents happen.

Model How it decides Advantage Drawback Example
DAC (discretionary) The resource's owner decides who gets access Flexible, intuitive Ungovernable at scale; the origin of "anyone with the link" sharing The payroll sheet shared by Sara (01-04)
MAC (mandatory) A central, immutable policy, based on classification labels Very strict Rigid and expensive Military environments; SELinux
RBAC (role-based) Permissions attached to roles, roles assigned to people Simple, auditable, scalable It does not express conditions (time, location, ownership) The Nimbus model
ABAC (attribute-based) Rules over user, resource and context attributes Very expressive and granular Hard to reason about and to audit "Only from Spain, during working hours, if the resource belongs to their tenant"
ReBAC (relationship-based) Based on the relationship between subject and object in a graph Natural for sharing and hierarchies Requires specific infrastructure "They can see it because they are the practitioner assigned to that appointment"

What suits Nimbus: RBAC as the base, with attributes for the conditions. It is the hybrid model almost everybody uses: roles define the bulk of the permissions and a few contextual rules cover what roles do not express (the tenant, working hours, the device).

8.1 Nimbus's RBAC design

Customer roles (inside a clinic or gym):

Role Can Cannot
Centre administrator Manage their centre's users, see the whole diary, configure services, see billing Leave their tenant; reach another centre's data
Reception Create, modify and cancel appointments; look up contact details See clinical notes; export in bulk; manage users
Practitioner See their diary and the notes of their patients See other practitioners' diaries; manage users; billing

Nimbus internal roles:

Role Can Cannot
Support (Rubén) See ticket metadata; impersonate a user only with justification and approval, with logging Reach customer data without a ticket; export; see clinical notes
Development (Iván) Full access to pre-production with pseudonymised data Access production without explicit, temporary approval
Systems (Lucía) Administer infrastructure; access production with MFA and logging Read business data routinely; delete audit records
Administration (Sara) Billing and HR Reach end-customer data
# roles.yaml - declarative definition of the permission matrix.
# The benefit of having it in the repository: it is reviewed like code,
# it is versioned and any change leaves a trace in the history.
roles:
  centre_admin:
    inherits: [reception]
    permissions:
      - users:read
      - users:create
      - users:deactivate
      - billing:read
      - config:write
    conditions:
      tenant: own                 # never outside their tenant

  reception:
    permissions:
      - appointments:read
      - appointments:create
      - appointments:modify
      - appointments:cancel
      - customers:read_contact    # contact YES, clinical notes NO
    conditions:
      tenant: own
      mass_export: denied

  practitioner:
    permissions:
      - appointments:read_own
      - clinical_notes:read_own
      - clinical_notes:write_own
    conditions:
      tenant: own
      scope: assigned_diary       # attribute: only THEIR appointments

  nimbus_support:
    permissions:
      - tickets:read
      - tenant_metadata:read
      - impersonate_user          # high-risk action
    conditions:
      impersonate_user:
        requires_ticket: true
        requires_approval: second_operator
        max_duration: 30m
        logging: mandatory
        notify_customer: true

  nimbus_systems:
    permissions:
      - infrastructure:administer
      - deployment:execute
    conditions:
      mfa: fido2
      production_access: just_in_time     # requested, expires by itself
      session_logging: mandatory
    denied:
      - audit:delete                      # NOBODY may delete audit records

Four decisions in this design that deserve attention:

  1. practitioner uses an attribute (assigned_diary), not a role. "Their" patients cannot be expressed with roles alone: it is a relationship between the user and the resource. This is where pure RBAC falls short and ABAC/ReBAC gets added.
  2. impersonate_user is an explicit permission with conditions, not an implicit capability of the support role. It is the fix for the "view as customer" feature that allowed access to 40 clinics in the 02-02 exercise. And the customer is notified: transparency is itself a control.
  3. audit:delete appears as an explicit denial for everybody, including systems. It is the separation of duties from 01-03: whoever administers cannot delete the proof of what they did, consistent with the INSERT without UPDATE for nimbus_api.
  4. The file lives in the repository. A permission change goes through review and stays in the history. Permissions changed by hand in a console leave no comprehensible trace.

And in the database, the counterpart of the same design, consistent with the roles from 01-03:

-- Read-only role for the support panel: it never sees clinical notes
CREATE ROLE nimbus_support_role NOLOGIN;
GRANT USAGE ON SCHEMA public TO nimbus_support_role;

-- A view that EXCLUDES the sensitive columns: least privilege is
-- implemented in the schema, not in trusting the code
CREATE VIEW v_support_tickets AS
SELECT b.id, b.tenant_id, b.date_time, b.status, c.name AS customer
  FROM bookings b JOIN end_customers c ON c.id = b.customer_id;
-- deliberately without b.clinical_notes and without c.dni

GRANT SELECT ON v_support_tickets TO nimbus_support_role;
REVOKE ALL ON bookings, end_customers, payroll FROM nimbus_support_role;

  1. Multi-tenancy and isolation

Nimbus is multi-tenant: a single database with the data of every clinic. Isolation between customers is the product: if it fails, there is no business.

You have already worked on the two pieces — the tenant_id filter that fixed the IDOR in 01-01 and the row level security (RLS) that makes it mandatory at the engine in 01-03. What belongs here is how isolation fits into access control:

Layer What it guarantees What happens if it alone fails
Token with tenant_id The API knows which tenant the request belongs to If it is tampered with, the following layers stop it
Filter in the query Every query is limited to the tenant One oversight exposes somebody else's data
RLS in PostgreSQL The engine enforces it even if the code forgets It is the safety net
Least-privilege DB role Bounds the damage of an injection It limits the reach
Auditing with tenant_id Makes cross-tenant access detectable It is the detective layer

The design rule, in one sentence: the tenant_id never comes from a parameter sent by the client; it always comes from the token validated on the server. An endpoint that accepts ?tenant_id= from the client is an IDOR by another name.

And the check worth automating: a test in CI that, with one tenant's token, tries to reach another tenant's resource and requires a 404. It is a five-line test that would have caught the original IDOR before it reached production.


  1. Privileged, service and third-party accounts

Accounts with elevated permissions are the ultimate objective of almost any attack. They deserve different treatment from the rest.

10.1 Separating the administrative account

The problem: if Lucía browses, reads mail and administers the infrastructure with the same account, a successful phishing attack against her mail hands over control of all of production directly.

The solution: two identities for the same person.

Account Use Restrictions
lucia@nimbusreservas.example Mail, documents, browsing, meetings No administrative permissions
adm.lucia@nimbusreservas.example Administration only No mail and no browsing; FIDO2 MFA mandatory; only from a managed machine; session logged; just-in-time access

The cost is a moderate daily inconvenience; the benefit is that the most likely vector (phishing the mailbox) stops leading to the most critical asset.

10.2 PAM and just-in-time access

PAM (privileged access management) groups together the practices for controlling the most powerful accounts:

Practice What it provides Application at Nimbus
Credential vault Nobody knows the administrative passwords: they are requested The PostgreSQL superuser password, the cloud provider's root account
Just-in-time access The permission does not exist until it is requested, and expires by itself Production access granted for 2 hours with a justification
Session recording Traceability of what was done Bastion host sessions
A second person's approval Separation of duties Destructive operations or access to customer data
Automatic rotation Reduces the window of a leaked credential Service and database credentials

The mental shift just-in-time proposes: instead of asking "who should have permanent access?", you ask "why would anybody have permanent access?". In a well-designed system, the normal state of an administrative account is without privileges; privileges are granted for minutes and disappear on their own. It is the least privilege principle of 01-03 taken to the axis of time: not only the minimum of permissions, but also the minimum of duration.

10.3 The consultancy's remote access (A-19)

This is the asset with critical criticality and Marta as owner in the 01-04 inventory, and it is the exact pattern of the Target incident we will analyse in 02-06.

Current situation:

Aspect State Risk
Accounts Shared by several engineers No traceability: it is not known who did what
Permissions Full administrative and permanent No limit of scope and no limit of time
MFA No One leaked password gives full access
Logging Not reviewed Misuse would go unnoticed
Expiry None The access will outlive the contract
Contract No detail of scope or obligations No basis for demanding anything

Target configuration:

  1. Named accounts per consultancy engineer. No exceptions: without naming there is no non-repudiation.
  2. Mandatory MFA on every access.
  3. Just-in-time access: no privileges by default; they are requested with a reason, approved by Lucía or Marta and expire within hours.
  4. Bounded scope: only the systems they genuinely need, never "administrator of everything".
  5. Logged sessions, reviewed by sampling.
  6. Automatic expiry of the access aligned with the contract, with a quarterly review.
  7. An alert on any access outside the agreed window.

Note: the contractual obligations with the supplier — scope, confidentiality, incident notification, subcontracting, audit — and third-party risk management as a process are covered in 04-04, and their data protection implications in 06-03. They must be validated with legal advice.

10.4 Service accounts

The accounts used by systems (not by people) are the usual blind spot: nobody claims them and nobody reviews them.

Rule Reason
One account per service, never shared between several It allows the damage to be bounded and who did what to be known
A named human owner Somebody has to answer for it at recertification
No interactive access (NOLOGIN, no shell) It must not be usable as a human way in
Minimal, explicit permissions Like the nimbus_api and nimbus_reports roles of 01-03
Short-lived or rotated credentials Reduces the window of a leak
Logged use with a known baseline A service has a very predictable pattern: any deviation is suspicious

  1. Recertification: the periodic access review

Everything above degrades over time. Recertification is the periodic review in which the owner of each asset confirms — or withdraws — the access that has been granted.

How it is done at Nimbus, sustainably:

Element Decision
Frequency Every six months for normal access; quarterly for privileged and third-party access
Who reviews The owner of the asset from the 01-04 inventory, not the technical team
What is reviewed The list of people with access, the level, the date it was granted and the date of last use
Default rule What is not explicitly confirmed is withdrawn. If somebody has to act in order to withdraw, it never gets withdrawn
Output A signed, dated list: it is the evidence an audit will ask for (06-04)

The piece of data that makes the review efficient is the date of last use. A permission unused for six months is almost never necessary, and presenting the list sorted by that field turns a tedious review into a quick decision.

-- Input for recertification: access with no recent use.
-- Sent to each asset's owner so they can confirm or withdraw it.
SELECT u.email,
       u.role,
       a.name                  AS asset,
       p.granted_at,
       p.granted_by,
       MAX(l.ts)               AS last_used,
       CASE WHEN MAX(l.ts) IS NULL THEN 'NEVER USED'
            WHEN MAX(l.ts) < now() - interval '180 days' THEN 'UNUSED 6 MONTHS'
            ELSE 'ACTIVE' END  AS status
  FROM permissions p
  JOIN users  u ON u.id = p.user_id
  JOIN assets a ON a.id = p.asset_id
  LEFT JOIN access_log l ON l.user_id = u.id AND l.asset_id = a.id
 GROUP BY u.email, u.role, a.name, p.granted_at, p.granted_by
HAVING MAX(l.ts) IS NULL OR MAX(l.ts) < now() - interval '180 days'
 ORDER BY a.name, last_used NULLS FIRST;

A "NEVER USED" permission is the best possible news in a recertification: it is withdrawn without discussion, with no risk of breaking anything and with an immediate benefit in surface reduction.


Common Mistakes and Tips

Common mistakes:

  • Only offboarding the mail account. It is the mistake of section 2.3: eight accesses stay alive in non-federated systems.
  • Deactivating the account without revoking sessions and tokens. An issued session keeps working; a token with no expiry, forever.
  • Copying another employee's permissions when onboarding. It propagates accumulated privileges nobody decided to grant.
  • Treating all MFA as equivalent. SMS and FIDO2 differ in whether the phishing attack works or not.
  • Forcing password expiry every 90 days. It produces trivial variations and passwords written on paper; the current guidance advises against it unless there is an indication of compromise.
  • Using OAuth 2.0 as if it were authentication. An access token says what can be done, not who you are. For identity, OIDC and validation of the ID token.
  • Storing tokens in localStorage. An XSS steals them. Use an HttpOnly cookie or a server-side intermediary.
  • Trusting a JWT without verifying the signature, exp, iss and aud, or letting the token choose the algorithm.
  • Putting personal data inside the JWT. It is signed, not encrypted: anyone can read it.
  • Accepting tenant_id as a client parameter. It is an IDOR by another name.
  • Granting permanent privileged access because "it is more convenient". The normal state of an administrative account should be without privileges.
  • Recertifying with the rule "we withdraw what somebody asks us to withdraw". By default, what is not confirmed must be withdrawn.

Tips:

  • Start with what pays best: FIDO2 on the administrative accounts and an expiry on every token. Two afternoons of work that close the two most exploited vectors.
  • Write out the leaver checklist from section 2.3 and run it on the last day, not afterwards. Keep signed evidence.
  • Separate the administrative account from the personal one for Lucía and Marta. It is inconvenient for a week and it protects for years.
  • Put the roles.yaml file in the repository and treat it as code: review, history and controlled deployment.
  • Add the tenant isolation test to CI. Five lines that permanently guard against the most expensive failure a SaaS can have.
  • Run the non-expiring token query today. If it returns rows, you already have this week's task.
  • At recertification, always sort by date of last use: it turns a tedious review into one-second decisions.

Exercises

Exercise 1 — Designing an employee's offboarding and spotting what is missing

Iván leaves Nimbus. Lucía does the following:

  1. Deactivates his mail account.
  2. Removes him from the internal messaging channel.
  3. Recovers the laptop.
  4. Removes him from the GitHub repository as a collaborator.

Two months later, during the quarterly review, activity is spotted: somebody has run a deployment at 4:10 in the morning.

Required: (a) list at least six accesses that are probably still alive and explain why each is dangerous; (b) write the complete leaver checklist that should have been applied; (c) explain how a deployment could have been run if the GitHub account had been removed, with two different technical hypotheses; (d) propose three structural measures that reduce this risk regardless of the discipline of whoever runs the offboarding.

Exercise 2 — Auditing a token implementation

This is the authentication code of a new version of the Nimbus API:

import jwt, datetime
from fastapi import HTTPException

SECRET = "nimbus-2026"

def issue(user):
    payload = {
        "sub": user.email,
        "name": user.full_name,
        "dni": user.dni,
        "tenant_id": user.tenant_id,
        "roles": user.roles,
        "internal_notes": user.notes,
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

def validate(token: str):
    claims = jwt.decode(token, SECRET,
                        algorithms=["HS256", "none"],
                        options={"verify_exp": False})
    return User(id=claims["sub"], tenant=claims["tenant_id"],
                roles=claims["roles"])

@app.post("/api/v1/sesion")
def login(email: str, password: str):
    u = authenticate(email, password)
    if not u:
        raise HTTPException(401, "User not found or incorrect password")
    return {"token": issue(u), "expires": "never"}

Required: (a) identify all the security problems, classifying them as critical, high and medium; (b) explain the concrete impact of the two most serious ones on the clinics' data; (c) rewrite issuance and validation correctly; (d) explain what changes if the API is deployed on three instances and a session has to be revoked immediately.

Exercise 3 — Authorising a new feature

Nimbus is launching occupancy reports by practitioner. Business requirements:

  • The centre administrator sees the report for every practitioner in their centre.
  • The practitioner sees only their own report.
  • Reception does not get access to this feature.
  • Nimbus support (Rubén) can see a centre's report only with an open ticket and the approval of a second operator, for 30 minutes at most, and the centre must be notified.
  • Reports can be exported to CSV, with a maximum of 5,000 rows and 6 requests per hour per user.
  • A practitioner who left more than 90 days ago must not appear.

Required: (a) decide which authorisation model or combination of models you would use and justify it; (b) write the corresponding fragment of roles.yaml; (c) write the Python code for the endpoint with all the authorisation checks, stating in comments which attack each one prevents; (d) give three events you would log and one alert you would build on them.


Solutions

Solution 1

(a) Accesses that are probably still alive:

Access Why it is dangerous
Cloud provider account (A-05) Access to buckets, the database and the infrastructure itself; it is the asset almost everything else depends on
Personal GitHub token created by Iván It survives the removal of the account: a token is an independent credential
Personal PostgreSQL user (dev_ivan) Direct access to production data without going through the API or its controls
SSH key in the servers' authorized_keys Access to the servers without central authentication; invisible to SSO
Secrets he knew (DB password, gateway keys, transactional e-mail credential) They are still valid: somebody with no relationship to the company knows them
Non-federated SaaS tools (ticketing, monitoring, error tracker) They contain customer data and production traces
Active sessions in browsers and the mobile app Deactivating the account does not invalidate an issued session (pass-the-cookie, 02-03)
Local copies of data on his laptop or in personal storage They do not even require access: the data has already left

(b) The checklist from section 2.3, carried out on the last day, with signed evidence and with reassignment of ownership of his inventory assets.

(c) Two hypotheses for the night-time deployment:

  1. A live personal token. Iván created a GitHub token with write permissions and no expiry — the pattern of the 01-04 finding. Removing the account as a collaborator does not invalidate the token, which keeps authenticating against the service's API. This is the most likely hypothesis.
  2. An SSH key or infrastructure credential. The deployment did not go through GitHub: it was run directly against the server with an SSH key left in authorized_keys, or with cloud provider credentials he knew.

In both cases, the lesson is the same: an identity is not just the account. It is the account plus every artefact it created, each of which lives independently.

(d) Three structural measures:

  1. Federate as much as possible with the identity provider, so that a single offboarding deactivates the maximum number of accesses. It reduces dependence on human discipline.
  2. Forbid by policy non-expiring tokens and personal SSH keys on servers, replacing them with short-lived credentials issued by CI and bastion access with a central identity. An artefact that does not exist cannot be forgotten.
  3. Quarterly recertification with the query from section 11, which catches whatever escaped the process. It is the safety net: it assumes the offboarding will fail at some point and limits how long it takes to notice.

Solution 2

(a) Problems found:

Severity Problem Detail
Critical algorithms=["HS256", "none"] Accepting none allows tokens with no signature to be forged: anyone becomes administrator of any tenant
Critical SECRET = "nimbus-2026" in the code A weak, guessable secret present in the repository; it allows arbitrary tokens to be signed
Critical verify_exp: False and "expires": "never" The tokens never expire: stealing one amounts to permanent access
High No exp, iss, aud, iat or jti in the payload Impossible to bound validity, origin or recipient, or to revoke
High dni, name and internal_notes inside the JWT The JWT is signed but not encrypted: personal data readable by anyone and present in logs and in the browser
High No revocation mechanism A leaver or a theft has no effect
Medium HS256 with a shared secret Any service that validates can also issue; with RS256 only the issuer signs
Medium sub is the e-mail address A change of address breaks the identity; a stable identifier is better
Medium An error message that distinguishes cases "User not found or incorrect password" is acceptable, but a fully uniform message and a constant response time are preferable to avoid the user enumeration of 02-02

(b) Impact of the two most serious. With none accepted, an attacker builds a token with "tenant_id": 7 and "roles": ["centre_admin"], unsigned, and the API accepts it. Result: full access to the diary and the data of any clinic, with no credentials needed, no phishing and nothing else exploited. Combined with the absence of expiry, a token obtained just once — from a log, from a screenshot, from a laptop — gives indefinite access. In terms of the CIA triad, this is a total loss of confidentiality and integrity over data that reveals health information, and it triggers notification obligations (06-03).

(c) Correct code:

import jwt, uuid, datetime
from jwt import PyJWKClient
from fastapi import HTTPException

ISSUER   = "https://identidad.nimbusreservas.example"
AUDIENCE = "https://api.nimbusreservas.example"
JWKS     = PyJWKClient(f"{ISSUER}/.well-known/jwks.json")
PRIVATE_KEY = secrets_manager.read("jwt/private-key")   # never in the code

def issue(user):
    now = datetime.datetime.now(datetime.timezone.utc)
    payload = {
        "iss": ISSUER,
        "aud": AUDIENCE,
        "sub": str(user.id),                    # stable identifier, not the e-mail
        "tenant_id": user.tenant_id,
        "roles": user.roles,                    # quick hint; sensitive data reloaded
        "iat": now,
        "exp": now + datetime.timedelta(minutes=15),     # short life
        "jti": str(uuid.uuid4()),               # identifier used to revoke
        # no name, no dni, no internal notes
    }
    return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256",
                      headers={"kid": "2026-03"})

def validate(token: str):
    try:
        key = JWKS.get_signing_key_from_jwt(token).key
        claims = jwt.decode(token, key,
                            algorithms=["RS256"],      # fixed, never from the token
                            issuer=ISSUER, audience=AUDIENCE,
                            options={"require": ["exp", "iat", "iss",
                                                 "aud", "sub", "jti"]},
                            leeway=30)
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "Session expired")
    except jwt.InvalidTokenError:
        raise HTTPException(401, "Invalid token")

    if cache.get(f"revoked:{claims['jti']}"):
        raise HTTPException(401, "Session revoked")

    permissions = load_current_permissions(claims["sub"], claims["tenant_id"])
    return User(id=claims["sub"], tenant=claims["tenant_id"],
                roles=permissions, jti=claims["jti"])

(d) Immediate revocation with three instances. A JWT is self-contained: the three instances validate it without consulting anyone, so a leaver has no effect until it expires. Solutions, which can be combined:

  1. A shared revocation list (Redis) consulted by jti, with the same lifetime as the token: at most 15 minutes' worth of entries. Cost: one fast lookup per request; it is what the code above does.
  2. A short access token life (5-15 min) with a rotating refresh. The real revocation is applied to the refresh, which is checked against the database: the maximum exposure window is the access token's lifetime.
  3. A per-user invalidation marker (tokens_valid_from): any token with an iat earlier than that marker is rejected. It revokes all of a person's sessions with a single field, ideal for a leaver or a compromise.

The underlying trade-off: the JWT is fast and stateless, but revocation requires state. The practical solution is not to pick one extreme, but to shorten the token's life so that the window is acceptable and keep a revocation mechanism for the urgent cases.

Solution 3

(a) Model chosen: RBAC as the base + ABAC/ReBAC for scope and conditions. Justification:

  • The three profiles (centre administrator, practitioner, reception) are roles: RBAC expresses them directly and auditably.
  • "Only their own report" is not a role: it is a relationship between the user and the resource (the assigned practitioner). It requires ReBAC or an attribute.
  • "With a ticket, approval, 30 minutes and notification" are contextual conditions: ABAC.
  • "A practitioner who left more than 90 days ago" is a temporal attribute of the resource.

Pure RBAC would force a role to be created per practitioner, which is ungovernable. Pure ABAC would be expressive but very hard to audit. The hybrid is what real systems use.

(b) Fragment of roles.yaml:

new_permissions:
  occupancy_reports:read_centre:
    roles: [centre_admin]
    conditions:
      tenant: own
      visible_practitioners: offboarded_less_than_90_days_ago

  occupancy_reports:read_own:
    roles: [practitioner]
    conditions:
      tenant: own
      scope: practitioner_id == user.practitioner_id

  occupancy_reports:export:
    roles: [centre_admin, practitioner]
    limits:
      max_rows: 5000
      requests_per_hour: 6
    logging: mandatory

  occupancy_reports:read_support:
    roles: [nimbus_support]
    conditions:
      requires_ticket: true
      requires_approval: second_operator
      max_duration: 30m
      notify_customer: true
      logging: mandatory
    denied_for: [reception]

(c) Endpoint with the checks:

@app.get("/api/v1/informes/ocupacion")
@limiter.limit("6/hour")                       # API abuse and application DDoS (02-02)
def occupancy_report(practitioner_id: int | None = None,
                     output_format: str = "json",
                     current_user = Depends(get_current_user)):

    # 1. ROLE AUTHORISATION. Reception never gets this far.
    #    Prevents: vertical privilege escalation.
    if not current_user.has_any(["centre_admin", "practitioner",
                                 "nimbus_support"]):
        raise HTTPException(403)

    # 2. TENANT FROM THE TOKEN, NEVER FROM THE CLIENT.
    #    Prevents: cross-tenant IDOR (01-01) and cross access.
    tenant = current_user.tenant_id

    # 3. SCOPE ACCORDING TO ROLE (the ABAC/ReBAC part).
    #    Prevents: horizontal escalation between practitioners at the same centre.
    if current_user.has("practitioner"):
        if practitioner_id not in (None, current_user.practitioner_id):
            raise HTTPException(404)          # 404, not 403: it does not reveal existence
        practitioner_id = current_user.practitioner_id

    # 4. SUPPORT: strict conditions and traceability.
    #    Prevents: the abuse of "view as customer" from the 02-02 exercise.
    if current_user.has("nimbus_support"):
        session = support_sessions.current(current_user.id, tenant)
        if not session or not session.approved_by_second or session.is_expired():
            raise HTTPException(403, "Requires an approved, current ticket")
        tenant = session.tenant_id            # the tenant is set by the approval
        notify_customer(tenant, current_user.id, "support_report_access")

    # 5. BOUNDED QUERY: tenant filter, exclusion of leavers and LIMIT.
    #    Prevents: excessive data exposure and application-layer DDoS.
    rows = db.execute("""
        SELECT p.id, p.name, COUNT(b.id) AS appointments,
               SUM(CASE WHEN b.status='completed' THEN 1 ELSE 0 END) AS completed
          FROM practitioners p
          LEFT JOIN bookings b ON b.practitioner_id = p.id
                              AND b.tenant_id = :t
         WHERE p.tenant_id = :t
           AND (p.offboarded_at IS NULL OR p.offboarded_at > now() - interval '90 days')
           AND (:pid::int IS NULL OR p.id = :pid)
         GROUP BY p.id, p.name
         LIMIT 5000""",
        {"t": tenant, "pid": practitioner_id}).fetchall()

    # 6. AUDITING: who, what, how much, for whom.
    record_audit(user_id=current_user.id, tenant_id=tenant,
                 action="occupancy_report",
                 output_format=output_format, record_count=len(rows),
                 practitioner_id=practitioner_id)

    if output_format == "csv":
        return export_csv(rows, maximum=5000)
    return {"data": [dict(r) for r in rows]}

(d) Three events to log and one alert:

Event Fields
occupancy_report_viewed user_id, tenant_id, role, practitioner_id, record_count, output_format
support_access_started user_id, tenant_id, ticket, approved_by, expires_at
csv_export user_id, tenant_id, record_count, ip

Alert: support access to more than 3 distinct tenants in 24 hours, or any support access with no associated ticket. It is exactly the signal that would have caught the support panel incident from the 02-02 exercise in hours instead of three days, and it is cheap to build because the fields are already in the log. A second useful alert: any CSV export outside the tenant's working hours, which takes advantage of the fact that Nimbus knows each centre's opening times.


Conclusion

You have worked through the domain that decides most incidents today. You know why identity is the new perimeter: none of Nimbus's relevant assets sits behind a network boundary, and the only thing that crosses every context is who you are. You have separated identification, authentication and authorisation precisely, and you have settled the trap that recurs throughout the course: most serious incidents are not authentication failures but authorisation failures. You have worked through the identity life cycle and its weak point, the leaving process, with the concrete case of the eight accesses that survive a mail deactivation, the checklist that prevents it and the two things that are always forgotten: revoking sessions and tokens, and rotating the shared secrets.

In passwords you take away the updated guidance — length over complexity, no forced expiry, allow pasting, and the most effective and least implemented measure: checking against leaked lists — and the definitive argument in favour of managers. In MFA you have learned the hierarchy that really matters, the one of phishing resistance, and the reason FIDO2 is qualitatively different: the key never leaves the device and the signature is bound to the domain, so that the defence works even if the user notices nothing. You have seen the inner workings of TOTP verification with its four decisions — a bounded window, constant-time comparison, single-use consumption and an encrypted secret — and the fix for MFA fatigue with number matching.

In federation you have settled the exact difference between OAuth 2.0, which authorises, and OpenID Connect, which authenticates, together with the Authorization Code + PKCE flow and the reason PKCE is essential in public applications. In sessions and tokens you have worked through the cookie attributes that neutralise XSS, CSRF and capture in transit, and you have compared a naive JWT validation with a correct one: signature verified with the issuer's key, the algorithm fixed by us, iss, aud and exp checked, revocation by jti and sensitive permissions reloaded from the source of truth. And you have fixed the non-expiring token of 01-04, the first link of the chained attack of 02-02.

In authorisation you have compared DAC, MAC, RBAC, ABAC and ReBAC and you have built Nimbus's real design: customer roles and internal roles in a versioned roles.yaml, with impersonate_user as a conditioned, notified permission, and audit:delete denied to everybody. You have closed off multi-tenant isolation with its golden rule — the tenant_id always comes from the token, never from the client — and its automated test in CI. And you have finished with privileged accounts: separating the administrative account, PAM and the just-in-time access that turns least privilege into least duration, the target configuration for the consultancy's access (A-19) and the periodic recertification whose decisive rule is that what is not confirmed is withdrawn.

You now have the module's complete armoury: you know what gets attacked, how, what defends against it and how access is controlled. What is missing is the reality check. In the module's final lesson, Cybersecurity Incident Case Studies (02-06), we will apply a method of analysis to real, public incidents — Target, WannaCry, Equifax, SolarWinds, Colonial Pipeline and a leak through misconfigured cloud storage — map each one to the concepts of the course, extract the patterns that recur without exception, and reconstruct hour by hour a fictitious ransomware incident at Nimbus that comes in through the consultancy's remote access, in order to answer the only question that matters: what would have changed the outcome.

Fundamentals of Information Security Course

Module 1: Introduction to Information Security

Module 2: Cybersecurity

Module 3: Cryptography

Module 4: Risk Management and Protection Measures

Module 5: Security Tools and Techniques

Module 6: Best Practices and Regulations

Module 7: Final Project

© Copyright 2026. All rights reserved