You already know what has to be protected (lesson 01-01) and what from (lesson 01-02). The most important question remains: on what basis are decisions taken? Nobody can memorise the infinite list of correct configurations, and no such list would survive the next technology shift. What does survive are the principles: a handful of design rules, some of them formulated more than fifty years ago, that still explain why one architecture holds and another falls apart. In this lesson you are going to internalise those principles and see them applied to concrete Nimbus Reservas decisions: the permissions of the account the API uses to talk to PostgreSQL, the layers surrounding the attachments bucket, who may deploy to production. These principles are the backbone of the rest of the course: when we discuss hardening or cloud security in module 5, we will be applying what is here.

Contents

  1. Why principles and not recipes
  2. Least privilege and need to know
  3. Defence in depth
  4. Secure by default and security by design
  5. Fail-safe / fail-secure
  6. Separation of duties and rotation
  7. Complete mediation
  8. Economy of mechanism and least common mechanism
  9. Psychological acceptability
  10. Not relying on security through obscurity
  11. Zero Trust and the end of the perimeter
  12. Assume breach
  13. Summary table and the security-usability-cost trade-off

  1. Why principles and not recipes

Marta, the Nimbus CTO, receives contradictory proposals every week: a supplier sells her a web application firewall, an article says that what matters is MFA, a customer demands an 80-question questionnaire. Without a criterion of her own, security turns into a shopping list.

Most of the principles you will see here come from a 1975 paper by Saltzer and Schroeder on the protection of information in computer systems, later extended by industry practice. They have survived mainframes, client-server, the web and the cloud — and they still explain the incidents of 2026. That longevity is the reason to study them: a specific recommendation expires; a principle lets you generate the right recommendation for a technology that does not exist yet.

A warning before we start: the principles conflict with one another. Economy of mechanism calls for simplicity; defence in depth adds layers. Secure by default calls for restriction; psychological acceptability calls for not drowning the user. Applying them well is not about obeying all of them to the maximum, but about knowing which one weighs most in each decision and being able to explain why.


  1. Least privilege and need to know

2.1 Least privilege

Every entity — person, process or service — must have exactly the permissions it needs for its function, not one more, and only for as long as it needs them.

It is the most profitable principle of all, because it limits the damage of any failure, whether it comes from an attack, a mistake or a bug. If a compromised credential can only read three tables, the attacker only reads three tables.

Applied to Nimbus: the API's database account.

Iván started the project connecting the API with the postgres superuser, "so as not to fight with permissions". It works perfectly... until an SQL injection or a bug turns that convenience into a total disaster: with the superuser, an attacker can read any table, drop them all, create new users and, in some scenarios, run commands on the system.

This is how least privilege is applied in PostgreSQL:

-- === 1. A role with no log-in capability that groups the permissions ===
CREATE ROLE nimbus_api_role NOLOGIN;

-- === 2. Revoke what PostgreSQL grants by default ===
-- By default, any role can create objects in the public schema.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO nimbus_api_role;   -- see the schema, not create in it

-- === 3. Grant ONLY what is needed, table by table and operation by operation ===
GRANT SELECT, INSERT, UPDATE ON bookings       TO nimbus_api_role;
GRANT SELECT, INSERT, UPDATE ON end_customers  TO nimbus_api_role;
GRANT SELECT                  ON services      TO nimbus_api_role;  -- catalogue: read only
GRANT SELECT, INSERT          ON access_audit  TO nimbus_api_role;  -- appended to, NOT modified
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public  TO nimbus_api_role;  -- for the auto-increments

-- === 4. Not a single table with DELETE or DROP ===
--     Removals are done with a logical delete (deleted_at column), not with DELETE.

-- === 5. Especially sensitive data: nothing by default ===
REVOKE ALL ON payroll       FROM nimbus_api_role;  -- the API never touches HR
REVOKE ALL ON gateway_keys  FROM nimbus_api_role;

-- === 6. The application's real user inherits from the role ===
CREATE USER nimbus_api WITH PASSWORD :'generated_password';
GRANT nimbus_api_role TO nimbus_api;

Explanation of the key decisions:

  • Separating role from user (steps 1 and 6) lets you rotate the password or create a second user (for an asynchronous worker, say) without rewriting every GRANT.
  • The REVOKE in step 2 is essential. Many people grant permissions and assume everything else is closed; in PostgreSQL, the public schema is permissive by default. Least privilege starts by taking away, not by giving.
  • access_audit with INSERT but without UPDATE or DELETE. This directly underpins the traceability and non-repudiation from 01-01: not even an attacker who gets hold of the API credential can erase their own trail from there.
  • No DELETE on any table. A DELETE FROM bookings with no WHERE stops being possible. Logical deletion also allows recovery without restoring backups.
  • An explicit REVOKE on payroll. Even if it had never been granted, writing it documents the intent and protects against a future GRANT ... ON ALL TABLES done in a hurry.

The same principle in the cloud, with the policy of the machine that runs the API:

# Object storage access policy for the API role
# Principle: only ITS prefix, only the actions it uses, only with encryption
Version: "2012-10-17"
Statement:
  - Sid: ReadWriteTenantAttachmentsOnly
    Effect: Allow
    Action:
      - "s3:GetObject"
      - "s3:PutObject"
    Resource: "arn:aws:s3:::nimbus-adjuntos-prod/tenants/*"   # NOT the whole bucket
    Condition:
      StringEquals:
        "s3:x-amz-server-side-encryption": "aws:kms"          # forces encryption on write

  - Sid: DenyDeletionAndBackupTampering
    Effect: Deny                                              # Deny always beats Allow
    Action:
      - "s3:DeleteObject"
      - "s3:PutBucketPolicy"
      - "s3:PutBucketAcl"
    Resource:
      - "arn:aws:s3:::nimbus-adjuntos-prod/*"
      - "arn:aws:s3:::nimbus-backups-prod/*"

Points worth highlighting:

  • The resource is /tenants/*, not *: the API cannot touch the bucket's internal configuration prefixes.
  • The encryption condition turns "remember to encrypt" into "it is impossible to write without encrypting".
  • The Deny block includes the actions that would allow changing the rules of the game (PutBucketPolicy, PutBucketAcl). An attacker holding that credential cannot grant themselves more permissions.
  • The backups are explicitly out of the API's reach. That is the structural defence against ransomware: the compromised workload cannot reach the backup that saves you.

2.2 Need to know

This is the same principle applied to information rather than to operations: having the right authorisation level is not enough; you have to need that data for the specific task.

Rubén, in support, has general authorisation to look up end customer records. But he does not need to see every record of every Nimbus customer all the time. He needs to see the record of the customer with an open ticket.

Without need to know With need to know
Rubén has permanent access to the whole end_customers table Access is opened for the tenant of the assigned ticket and is logged
The external consultancy has access to every environment Access only to the environment of the incident, within a limited time window
The monthly board report includes identifying data The report is generated in aggregate, with no personal identifiers

In practice it is implemented with just-in-time access (granted when the ticket is opened and expiring when it is closed), field masking and aggregation. It is also the basis of the data minimisation principle required by data protection law (06-03).


  1. Defence in depth

No control is infallible. Place several independent controls in series, so that the failure of one is not the failure of the system.

The classic metaphor is the castle: moat, wall, courtyard, keep. The more honest metaphor is Swiss cheese: every layer has holes, but if the layers are independent, it is unlikely the holes will line up.

Applied to the most delicate Nimbus asset — the attachments bucket with scanned medical reports:

flowchart TD
    ATK[External attacker] --> L1
    L1["LAYER 1 - PERIMETER\nWAF and rate limiting\nHTTPS only, TLS 1.2+"] --> L2
    L2["LAYER 2 - IDENTITY\nSession authentication\nMFA for internal accounts"] --> L3
    L3["LAYER 3 - AUTHORISATION\nPermission + tenant ownership\nchecked on every request"] --> L4
    L4["LAYER 4 - NETWORK\nThe bucket is not public\nAccess only from the VPC"] --> L5
    L5["LAYER 5 - RESOURCE PERMISSIONS\nLeast privilege policy\nwith no DeleteObject"] --> L6
    L6["LAYER 6 - DATA\nEncryption at rest with KMS\nSigned, expiring URLs"] --> L7
    L7["LAYER 7 - DETECTION\nAccess log, alerts on\nanomalous download volume"] --> L8
    L8["LAYER 8 - RECOVERY\nVersioning and immutable backups\nin another account"]

How to read this diagram. An attacker who gets past one layer runs into the next. And most importantly: if they exfiltrate data anyway, layers 7 and 8 still add value — Nimbus finds out what was taken and when (traceability), and it has not lost the information (recovery).

The condition that makes it work: independence. Ten layers that all depend on the same identity directory are not ten layers; they are one. When you design your layers, ask yourself: what single failure would bring several down at once? At Nimbus that single failure would be the cloud provider's administrator account: whoever holds it cancels layers 4, 5, 6 and 8 in one go. Hence that account deserves disproportionate protection (MFA with a hardware key, exceptional use only, alerts on every use).

Frequent mistakes when applying defence in depth:

  • Stacking layers of the same type. Three different antivirus products are not defence in depth; they are redundancy of the same control.
  • Using the existence of one layer to relax another. "Since we have a WAF, we don't need to validate input in the code." The WAF is a generic filter and can be evaded; validation in the code is what knows the business rules.
  • Forgetting the detection layer. Many architectures have five prevention layers and none for detection. If nobody is looking, the breach lasts months.

  1. Secure by default and security by design

These are two sibling principles that are usually cited together (and that European data protection law sets out explicitly).

4.1 Secure by default

The out-of-the-box configuration must be the most restrictive that is reasonable. If the user touches nothing, they must end up protected.

The default configuration is the one that ends up in production in most installations. Any insecure default becomes, statistically, a mass vulnerability.

Decision in Nimbus Insecure default Secure default
New user in the admin panel Created with the admin role and permissions removed afterwards Created with no permissions; they are granted explicitly
New bucket created by Lucía Inherits the account configuration Infrastructure template with public access block and mandatory encryption
New endpoint added by Iván Public unless the authentication decorator is added Authentication mandatory unless an explicit exception is added to an allowlist
New customer signing up All modules enabled Only the module they bought
API logging DEBUG level with full bodies INFO level with no personal data

The third case deserves some code, because it is the one that prevents the silliest data leaks. Let us compare:

# === INSECURE-BY-DEFAULT PATTERN ===
# The endpoint is public unless somebody remembers to protect it.
@router.get("/api/v1/facturas")
async def list_invoices(db=Depends(get_db)):     # <-- they forgot the user dependency
    return await db.fetch_all("SELECT * FROM invoices")
# === SECURE-BY-DEFAULT PATTERN ===
# Authentication is applied to the WHOLE router; forgetting it is impossible.
router = APIRouter(dependencies=[Depends(require_authenticated_user)])

# And the genuinely public routes are declared one by one, explicitly and visibly:
PUBLIC_ROUTES = {"/api/v1/salud", "/api/v1/estado-servicio"}

@app.middleware("http")
async def require_authentication(request: Request, call_next):
    if request.url.path not in PUBLIC_ROUTES:
        if not await valid_session(request):
            return JSONResponse({"detail": "Not authenticated"}, status_code=401)
    return await call_next(request)

The conceptual difference is enormous. In the first pattern, security depends on every developer remembering every time. In the second, failing by omission results in denial, and opening something up requires a deliberate, reviewable act (adding a route to PUBLIC_ROUTES shows up in code review and draws attention). This is also fail-secure behaviour, which we cover in section 5.

4.2 Security by design

Security is built in when the system is conceived, not as a final review.

The cost of fixing a flaw grows brutally depending on the phase in which it is detected:

When it is detected Relative cost of fixing Example in Nimbus
At design time 1 Deciding that every table carries tenant_id and that the filter is mandatory
While coding ~5 Adding the check to an endpoint during code review
In testing ~15 Finding in QA that three endpoints do not filter by tenant
In production ~50 Emergency patch, out-of-window deployment
After an incident ~200+ Forensic investigation, notification, customer loss, reputation

A concrete example of a design decision at Nimbus: instead of trusting every query to add WHERE tenant_id = ..., row level security is enabled in PostgreSQL, so that isolation between customers is guaranteed by the database:

-- Multi-tenant isolation stops depending on nobody forgetting a WHERE
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON bookings
    USING (tenant_id = current_setting('app.current_tenant')::int);

-- The API sets the session variable at the start of each request, after authenticating:
--   SET LOCAL app.current_tenant = '42';
-- From then on, a "SELECT * FROM bookings" only sees the rows of tenant 42.

Why this is design and not a patch: a developer can no longer cause a cross-customer leak by forgetting a clause. The security property has moved to a layer where the mistake is impossible, instead of being policed at each of the hundreds of places where it could be made. A practical note: SET LOCAL inside the transaction is essential if a connection pool is used, so that the value does not leak into the next request.


  1. Fail-safe / fail-secure

When something fails — and it will fail — the system must end up in the safest possible state. The default decision when in doubt is to deny.

Saltzer and Schroeder's classic formulation is fail-safe defaults: access is based on explicit permission, not on the absence of prohibition.

Two variants need to be told apart:

Variant On failure it... Prioritises Example
Fail-secure Closes / denies Confidentiality and integrity If the authorisation service does not respond, the API returns 403
Fail-safe (physical safety) Opens / releases Human life, availability Emergency doors unlock in the event of a fire

In computer systems we almost always want fail-secure; in systems with lives at stake, fail-safe. And sometimes you have to choose deliberately.

The awkward case at Nimbus. The service that validates permissions stops responding. Two options:

# === OPTION A: fail-open (DANGEROUS as a default) ===
try:
    allowed = await permission_service.check(current_user, action)
except TimeoutError:
    allowed = True            # "so as not to break the service"...
                              # ...an attacker who takes the permission service down
                              # gets full access. The failure is the key.

# === OPTION B: fail-secure (CORRECT in most cases) ===
try:
    allowed = await permission_service.check(current_user, action)
except TimeoutError:
    logger.error("event=permissions_unavailable actor=%s action=%s", current_user.id, action)
    raise HTTPException(503, "Service temporarily unavailable")
    # Access is denied, an alert is raised and degradation is visible.

Option A has a devastating property: it turns a denial-of-service attack into a privilege escalation. The attacker no longer needs to steal credentials; saturating the permission service is enough.

That said, fail-secure applied without judgement also does harm. If Nimbus decides that any internal failure prevents booking, a problem in the mail sending service would leave clinics with no diary. The correct resolution is graduated degradation:

Failing component Decision Rationale
Permission service Deny everything (fail-secure) Without authorisation you cannot operate safely
Transactional e-mail service Continue, queue and warn E-mail is incidental to the main operation
Payment gateway Deny the charge operation Never treat an unconfirmed payment as good
Audit service Deny on sensitive operations With no record there is no traceability and no non-repudiation

That last case is debatable and is useful for practising the reasoning: if a mass export of personal data cannot be logged, is it allowed to go ahead blind? At Nimbus, the agreed answer is no.


  1. Separation of duties and rotation

6.1 Separation of duties

No single person should control every phase of a critical process on their own.

It is a principle born in accounting audit and transplanted into computing with excellent results. It protects against deliberate fraud and against individual error at the same time.

Applied to Nimbus:

Critical process Without separation (risk) With separation
Deployment to production Iván writes, approves and deploys his own code Iván writes; somebody else approves the PR; the deployment is executed by the pipeline, not by a person
Payments to suppliers Sara creates the supplier and orders the payment Sara registers the supplier; the payment order is authorised by the board
Access management Lucía grants herself administrator permissions when she needs them The grant requires a ticket approved by Marta and is recorded
Review of the audit logs Lucía administers the systems and reviews her own records The records go to a separate account where Lucía has no delete permission

And this is how it materialises in the repository configuration, which is where separation of duties really lives in a software company:

# .github/branch-protection (simplified representation of the branch protection)
branch: main
required_pull_request_reviews:
  required_approving_review_count: 1
  dismiss_stale_reviews: true          # a new push invalidates the previous approval
  require_code_owner_reviews: true     # sensitive files require a specific reviewer
required_status_checks:
  strict: true
  contexts:
    - "tests"
    - "security-static-analysis"
    - "dependency-scanning"
enforce_admins: true                   # the rule applies to administrators TOO
allow_force_pushes: false              # history cannot be rewritten
allow_deletions: false

Explanation of the lines that matter most:

  • enforce_admins: true is the one almost everybody leaves as false. If Marta and Lucía can bypass the process, the process does not exist: compromising one of those two accounts is enough.
  • dismiss_stale_reviews: true prevents the trick of obtaining approval with an innocuous change and adding the real code afterwards.
  • allow_force_pushes: false protects the historical integrity of the repository: nobody can erase the trace of a change.

A practical limit in an SME. Nimbus has a single systems administrator. Strict separation is impossible: Lucía has to be able to do her job at three in the morning. When preventive separation is not viable, it is replaced by compensating controls: an unalterable record of all her actions in an account she cannot write to, automatic alerts to Marta on critical actions, and periodic review by a third party. It is an honest answer and perfectly defensible in front of an auditor.

6.2 Rotation

This complements the above: people change roles and credentials expire.

  • Rotation of people: having somebody else take over the function reveals irregularities and removes the dependency on one individual (the so-called bus factor). Mandatory holidays are, historically, an anti-fraud control.
  • Rotation of credentials: API keys, certificates and secrets with a limited life. A credential that expires every 90 days bounds the useful window of a leak. Better still: short-lived credentials issued by the identity provider, valid for minutes, which eliminate the long-lived secret altogether.

  1. Complete mediation

Every access to every resource must be checked, every time. No exceptions, no shortcuts, no caches that outlive a permission change.

This principle attacks a very specific mistake: checking permissions once at the start and trusting from then on.

Examples of incomplete mediation at Nimbus:

  • The SPA hides the "Export customers" button if the user lacks the permission... but the endpoint does not check it. The interface is not a security control: anybody can call the API directly.
  • Rubén's session token lasts 8 hours. His permissions are revoked at 10:00, but his token remains valid, with the old permissions baked in, until 18:00.
  • Authorisation is checked when listing bookings, but not when downloading the attachment of a specific booking: the file URL, if known, works without verification.

How the attachment case is implemented correctly:

@router.get("/api/v1/reservas/{booking_id}/adjunto")
async def download_attachment(booking_id: int, current_user=Depends(get_current_user), db=Depends(get_db)):
    # COMPLETE MEDIATION: it is checked again here, even though it was checked when listing
    booking = await db.fetch_one(
        "SELECT attachment_key FROM bookings WHERE id=:id AND tenant_id=:t",
        {"id": booking_id, "t": current_user.tenant_id},
    )
    if booking is None:
        raise HTTPException(404, "Not found")

    # The object's permanent URL is never exposed: a signed, expiring one is generated
    url = generate_signed_url(
        key=booking["attachment_key"],
        expiry_seconds=120,              # two minutes: enough to download
        read_only=True,
    )
    logger.info("event=attachment_download actor_id=%s resource=booking:%s", current_user.id, booking_id)
    return {"url": url}

The three defensible decisions here: ownership is rechecked even though "it was already checked earlier"; the signed URL expires in two minutes, so sharing it by mistake has a minimal window; and the access is recorded, which makes the mediation auditable as well.

The tension in this principle is obvious: always checking costs performance. It is resolved with very short-lived caches and with explicit invalidation when permissions change — never by removing the check.


  1. Economy of mechanism and least common mechanism

8.1 Economy of mechanism (simplicity)

The security mechanism must be as small and simple as possible, because only the simple can be verified.

A permission system with 4 clear, auditable roles protects better in practice than one with 60 granular permissions that nobody understands and that get granted wholesale "because we don't know which one is missing". Complexity is not an aesthetic inconvenience: it is where flaws hide.

Signs that Nimbus is violating this principle:

  • Nobody can explain in one sentence why a particular user can perform a particular action.
  • There are three different authorisation mechanisms coexisting (one in the proxy, another in the middleware, another inside each endpoint) and it is not clear which one wins.
  • Onboarding an employee means touching seven places, and there is a 20-page document that almost nobody follows.

Rule of thumb: if you cannot draw your authorisation model on a whiteboard in five minutes, you cannot guarantee it is correct.

8.2 Least common mechanism

Minimise the resources and mechanisms shared between users or between different systems, because everything shared is a potential channel for leakage or propagation.

Applied to Nimbus:

Shared (risk) Separated (better)
Production and pre-production in the same cloud account and the same VPC Separate accounts; a pre-production compromise does not reach production
A single credential used by the API, the batch jobs and Lucía's scripts One credential per identity, with different permissions and traceable
Real data copied into the test environment Anonymised or synthetic data in testing
The same wifi for employees, guests and the printers Separate networks; the guest one with no access to the internal one

The case of pre-production with real data is especially instructive: test environments have fewer controls, less monitoring and more people with access. If they contain real data, you have created a copy of your most sensitive asset in your least protected environment — and, in data protection terms, a processing activity that is hard to justify.


  1. Psychological acceptability

If a control is too inconvenient, people work around it. A control that is worked around does not protect: worse, it hides the real exposure.

This principle, formulated back in 1975, remains the most ignored. Security that does not take people into account systematically produces the opposite of what it seeks:

Badly designed control Real behaviour it causes Acceptable alternative
Password change every 30 days with 5 rules Nimbus2026!, Nimbus2026!!, a sticky note under the keyboard Long passwords with no forced expiry + MFA + corporate password manager
Blocking every file transfer service Rubén uses his personal account to send the CSV to the customer A convenient corporate service, with expiry and logging
Access request with 3 approvals and a 5-day wait Colleagues share credentials "in the meantime" Self-service just-in-time access, with one person's approval and logging
MFA on every action, every 10 minutes People look for ways to disable it; they approve prompts without reading them MFA at log-in and on sensitive actions; sessions of a reasonable length

The operational rule: make the secure path the easy path. If the corporate password manager is more convenient than a notepad, it will get used. If the internal transfer service is faster than WeTransfer, it will get used. Designing this way takes more work up front, but it is the only way for a control to survive past its second month.

A diagnostic warning: if you discover that people at Nimbus are bypassing a control, the first hypothesis should not be "they are irresponsible" but "the control is badly designed". It almost always is. This approach connects with the training and awareness lesson (06-05).


  1. Not relying on security through obscurity

The security of a system must not depend on its design being secret. It must depend on its keys being secret.

This is Kerckhoffs's principle, formulated in the nineteenth century for military cryptography and valid for any system: the design may fall into enemy hands without that compromising security.

What counts as illegitimate obscurity (it does not protect):

  • Putting the admin panel at /panel-secreto-nimbus-2024 and not requiring strong authentication. It is found by an automated path scan.
  • Encrypting with a home-grown algorithm "that nobody knows". Home-made algorithms are almost always broken; the standards are public precisely because they have withstood decades of analysis.
  • Trusting that a booking identifier is long and hard to guess instead of checking authorisation.
  • Hiding the server version in the headers as the only measure against a known unpatched vulnerability.

What is legitimate (and is not the same thing):

  • Not publishing internal details unnecessarily. Reducing the information you give away for free is useful as an additional layer; it simply cannot be your only layer.
  • Secrets that are designed to be secret: cryptographic keys, passwords, tokens. There, secrecy is not obscurity: it is the mechanism.
  • The key difference: a legitimate secret can be rotated if it leaks. If your security depends on nobody knowing the path to your panel, when it leaks you will have to redesign; if it depends on a key, you change the key.

A practical corollary for Nimbus: when Marta evaluates a security tool, she should be wary of a supplier that refuses to explain how it works. "It's proprietary, we can't give details" is not an acceptable answer about a cryptographic mechanism.


  1. Zero Trust and the end of the perimeter

11.1 Why the perimeter died

The classic model divided the world in two: outside (dangerous) and inside (trusted). A firewall at the border and that was that. At Nimbus, that model is simply inapplicable:

  • Half the headcount works from home. Where is the "inside"?
  • The servers are at a cloud provider, not in the office.
  • An external consultancy connects remotely with elevated privileges.
  • The end users' mobile app connects from any network in the world.
  • The data lives in an S3 bucket and in third-party SaaS services.

On top of that, the perimeter model has a structural flaw: once you are inside, there is nothing more. An attacker who compromises a laptop moves laterally without resistance — which is exactly what modern ransomware does.

11.2 The principles of Zero Trust

Never trust, always verify. Location on the network grants no privileges.

flowchart TB
    subgraph PER["PERIMETER MODEL (obsolete)"]
        direction LR
        FW[Firewall] --> INT["Internal network\nEVERYTHING trusts EVERYTHING"]
    end
    subgraph ZT["ZERO TRUST MODEL"]
        direction LR
        U[User or service] --> V{"Verification on every access\nidentity + device +\ncontext + least privilege"}
        V -->|allowed, short session| REC[Specific resource]
        V -->|denied| X[Block and log]
    end

Zero Trust rests on three ideas you already know, combined:

  1. Explicit verification on every access (complete mediation) using every available signal: who you are, from which device, in what state that device is, at what time, from where.
  2. Least privilege with just-in-time access and short sessions.
  3. Assume breach: microsegmentation, end-to-end encryption and continual analysis, so that a compromise stays contained.

What it means concretely at Nimbus:

Before (perimeter) With Zero Trust
"If you are on the VPN, you can reach the database" Access to the DB requires a verified identity, a managed device and an approved time window
The API trusts requests coming from the internal network Services authenticate to each other with mTLS or service tokens
The consultancy's laptop joins the whole internal network Access only to the specific system of the incident, with session recording
Containers in the same VPC talk to each other freely Network policies that only allow the declared flows

An important warning: Zero Trust is not a product you buy. It is an architecture and a journey of several years. For Nimbus, getting started is perfectly realistic without a big investment: removing implicit network-based trust from database access, requiring MFA on all administrative accounts and narrowing the consultancy's access are already three Zero Trust steps.


  1. Assume breach

Design on the basis that the attacker is already inside. The question is not "how do I stop them?", but "how much damage can they do and how long before I find out?".

It is a change of mindset more than a control. It changes the questions asked in architecture meetings:

Preventive-mindset question "Assume breach" question
How do I stop them getting in? If they get in through Rubén's laptop, what can they reach from there?
Is the database protected? If they steal the database backup, is the data encrypted and with which key?
Do we have backups? Can the attacker who controls the infrastructure delete those backups?
Do we have logs? How long would it take us to detect a slow, sustained exfiltration?
Is our CI/CD secure? If a GitHub Actions token is compromised, what can it deploy and who approves it?

The three most important design consequences:

  1. Segmentation: so that the compromise of one component does not grant access to the next.
  2. Backups out of reach: immutable backups or backups in another account, so that production credentials cannot destroy them. It is the difference between paying a ransom and not paying it.
  3. Detection and response: measuring the time to detection. An organisation that detects in hours suffers an incident; one that detects in months suffers a catastrophe.

This approach is developed operationally in the incident response plan (04-05) and in the monitoring techniques (05-02).


  1. Summary table and the security-usability-cost trade-off

13.1 The principles at a glance

Principle What it prevents Concrete decision in Nimbus
Least privilege A failure turning into total compromise The nimbus_api account has no DELETE and no access to payroll
Need to know Unnecessary exposure of data Rubén sees the record of the open ticket, not the whole table
Defence in depth A single failure bringing the system down 8 layers around the attachments bucket
Secure by default Insecure configurations reaching production Router with mandatory authentication; public routes on an allowlist
Security by design Expensive, late fixes Multi-tenant isolation in the database (RLS), not in every query
Fail-secure A failure opening the doors If the permission service does not respond, access is denied and an alert is raised
Separation of duties Uncontrolled fraud and error enforce_admins: true; nobody approves their own deployment
Rotation Eternal credentials and personal dependencies Keys with expiry; short-lived credentials
Complete mediation Permissions checked "once and done" Ownership is rechecked on every attachment download
Economy of mechanism Complexity that hides flaws Four clear roles instead of sixty loose permissions
Least common mechanism Propagation between environments Separate cloud accounts; no real data in testing
Psychological acceptability Controls that get bypassed A convenient password manager instead of expiry every 30 days
No obscurity Security that evaporates once the design is published No home-grown cryptography; no "secret" paths as the only defence
Zero Trust Lateral movement after the first compromise Being on the VPN does not grant access to the database
Assume breach Blindness to the incident under way Immutable backups in another account; measurement of the time to detection

13.2 How the tension is resolved

All these principles cost money, time or convenience. The professional way to resolve the tension has four steps:

  1. Classify the asset. Not everything deserves the same treatment. The medical notes in the attachments bucket and the public catalogue of services do not require the same layers. Applying the maximum to everything exhausts the budget and the team's patience, and ends in a uniformly mediocre level.
  2. Apply the principle proportionally. Rigorous least privilege in production; something more flexible in a development environment with no real data (and it is that condition which makes it acceptable).
  3. Measure the friction, do not assume it. If MFA adds 6 seconds twice a day, that is bearable; if it forces Rubén to authenticate 40 times in a morning, it will become a problem and he will find a way around it.
  4. Document what you accept and who accepts it. If Nimbus decides not to separate duties in systems administration because there is only one person, that gets written down, the compensating control is explained and the board signs it off. An accepted, documented risk is management; a risk accepted in silence is negligence.

Note on legal implications: several of these principles — security by design and by default, data minimisation — appear as express obligations in European data protection law. Their concrete application to your case must be validated with your compliance lead or with a legal professional; what is offered here is technical training, not legal advice.


Common Mistakes and Tips

Common mistakes:

  • Granting permissions "temporarily" with no end date. The longest-lasting temporary access in the world is the one granted on a Friday to get out of a jam. Every exceptional grant must carry automatic expiry.
  • Confusing defence in depth with redundancy. Layers of the same type share the same blind spots.
  • Applying fail-open "so that nothing breaks". It turns an outage into a privilege escalation. If you must open up on failure, let that be a conscious, documented decision, not the except that happened to be left there.
  • Believing the interface protects you. Hiding a button does not protect the endpoint. Security is applied on the server, always.
  • Taking simplicity as an excuse to do nothing. "Economy of mechanism" does not mean "let's not add authorisation"; it means the authorisation you do add must be comprehensible.
  • Rolling out Zero Trust as a product purchase. No supplier sells you Zero Trust; they sell you a piece of it. You design the architecture yourself.
  • Forgetting psychological acceptability in policies. A policy the team cannot comply with generates widespread non-compliance, and with it the loss of credibility of every other policy.

Tips:

  • Faced with any decision, walk mentally through three principles: is this the least privilege possible? what happens if this fails? is there more than one layer? That covers most cases.
  • Write permissions as code (SQL, policies, configuration files) and keep them in the repository. What is reviewed in a PR gets discussed; what is done by hand in a console does not.
  • Start by revoking, not by granting. The default posture must be closed.
  • When you have to violate a principle for practical reasons, write it down along with its compensating control. An unmet principle that is documented is manageable; one unmet in silence is not.

Exercises

Exercise 1 — Diagnosing which principles are being violated

Marta finds this situation while reviewing the Nimbus infrastructure. Identify all the principles being violated in each point and propose the fix:

  1. The API connects to PostgreSQL with the postgres user (superuser), and the same credential is used by the nightly jobs and by Lucía's manual scripts.
  2. The internal admin panel is at https://admin.nimbusreservas.example, with no network restriction and no MFA, but "nobody knows that URL".
  3. The authorisation middleware has an except Exception: return True block "to avoid outages".
  4. The password policy forces a change every 30 days with uppercase, lowercase, numbers and symbols; 60% of the team uses numbered variants of the same password.

Exercise 2 — Writing least-privilege permissions

Nimbus is going to add a new service, nimbus-reports, which generates an aggregate occupancy report per customer every night. The service:

  • Reads from the bookings and services tables.
  • Writes the result into the occupancy_reports table.
  • Uploads a PDF to the informes/ prefix of the nimbus-adjuntos-prod bucket.
  • Must not access end_customers or payroll, and must not delete anything.

Write (a) the PostgreSQL GRANT/REVOKE statements and (b) the skeleton of the bucket access policy. Justify any decision that is not obvious.

Exercise 3 — Designing defence-in-depth layers

Nimbus is going to let its customers download a CSV file containing all their bookings for the last year. It is a mass export of personal data. Design at least five independent layers of defence around this feature, stating for each layer which principle underpins it and which concrete attack or mistake it mitigates.


Solutions

Solution 1

1. Shared superuser:

  • Violates least privilege: the API can drop tables, create users and read everything, when all it needs is to operate on four tables.
  • Violates least common mechanism: a single credential for three different identities (API, batch, human).
  • Violates separation of duties: if Lucía uses the same credential as the application, her actions are indistinguishable from the API's in the records — traceability and non-repudiation are lost.
  • Fix: one role per identity (nimbus_api_role, nimbus_batch_role, a named account for Lucía), with GRANTs narrowed as in section 2.1; no service running as superuser; administrative access on request with an expiry.

2. Panel with no real protection:

  • Violates not relying on obscurity: the URL is discovered through public certificates, DNS and scanning. It is the first thing a bot does.
  • Violates defence in depth: there is a single barrier, and it is fictitious.
  • Violates secure by default: an admin panel should be born restricted.
  • Fix: mandatory MFA, restriction to network ranges or access only through a verified identity, attempt rate limiting, logging of all accesses and an alert on out-of-hours access.

3. except Exception: return True:

  • Violates fail-secure seriously: it turns any error — including one an attacker can provoke — into authorisation granted.
  • Violates complete mediation: the check ceases to exist exactly when it is most needed.
  • Fix: catch specific exceptions, deny access, log the error at ERROR level and raise an alert. If for a business reason it were necessary to degrade open, it must be limited to non-sensitive read-only operations and be documented and approved in writing.

4. Password policy:

  • Violates psychological acceptability: the burden is such that the team generates predictable patterns, weaker than a stable long password.
  • Indirectly violates economy of mechanism: the complexity of the rule does not deliver proportional security.
  • Fix: long passwords (passphrases), with no forced expiry unless there is an indication of compromise, checking against lists of leaked passwords, a corporate password manager and MFA. Mandatory periodic expiry is advised against by current guidance precisely because of this effect.

Solution 2

(a) PostgreSQL:

CREATE ROLE nimbus_reports_role NOLOGIN;

-- See the schema, without being able to create objects in it
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO nimbus_reports_role;

-- Strict read access to what it needs to aggregate
GRANT SELECT ON bookings TO nimbus_reports_role;
GRANT SELECT ON services TO nimbus_reports_role;

-- Write only into its own results table; no UPDATE and no DELETE:
-- each run appends a new report, it does not rewrite the previous ones (historical integrity)
GRANT SELECT, INSERT ON occupancy_reports TO nimbus_reports_role;
GRANT USAGE ON SEQUENCE occupancy_reports_id_seq TO nimbus_reports_role;

-- Explicit prohibitions, even though they had never been granted: they document the intent
REVOKE ALL ON end_customers FROM nimbus_reports_role;
REVOKE ALL ON payroll       FROM nimbus_reports_role;
REVOKE ALL ON access_audit  FROM nimbus_reports_role;

CREATE USER nimbus_reports WITH PASSWORD :'generated_password';
GRANT nimbus_reports_role TO nimbus_reports;

Non-obvious decisions, justified:

  • No UPDATE on occupancy_reports: a generated report is not modified. If it needs correcting, another one is generated. This preserves the history and makes tampering easier to spot.
  • Explicit REVOKEs: they protect against a future GRANT ... ON ALL TABLES IN SCHEMA public done in a hurry, which would otherwise give access to personal data to a service that should only aggregate.
  • Better still, security by design: if the report is an aggregate, ideally it should not even read bookings directly, but a view exposing only the necessary columns (tenant_id, date_time, service_id) with no identifying data. Less privilege and less leak surface.

(b) Bucket policy:

Version: "2012-10-17"
Statement:
  - Sid: UploadReportsOnly
    Effect: Allow
    Action: "s3:PutObject"
    Resource: "arn:aws:s3:::nimbus-adjuntos-prod/informes/*"   # only its own prefix
    Condition:
      StringEquals:
        "s3:x-amz-server-side-encryption": "aws:kms"

  - Sid: NoAttachmentReadsNoDeletions
    Effect: Deny
    Action:
      - "s3:GetObject"        # it does not need to read: it only writes
      - "s3:DeleteObject"
      - "s3:PutBucketPolicy"
      - "s3:PutBucketAcl"
    Resource: "arn:aws:s3:::nimbus-adjuntos-prod/*"

Note: the Deny on s3:GetObject is deliberate and perhaps the most interesting point of the exercise. A service that only uploads files does not need to read the rest of the bucket, where the patients' scanned medical reports live. If the service is compromised, the attacker does not obtain that read access.

Solution 3 — Layers for the mass CSV export:

# Layer Principle What it mitigates
1 A specific data:export permission, not included in the basic role; only the customer's administrator holds it Least privilege Any receptionist downloading the business's entire database
2 Re-verification of identity (MFA) at the moment of exporting, even if the session is already open Complete mediation, need to know Use of a stolen session or of an unattended machine
3 A mandatory tenant_id filter applied in the database through RLS, not in the query Security by design An oversight in the code exporting other customers' data
4 A range limit (maximum 12 months), a size limit and a frequency limit (one export every 24 h per customer) Fail-secure, availability Repeated mass exfiltration; resource exhaustion
5 Asynchronous generation and delivery through a signed URL expiring in 15 minutes, single use, never attached to an e-mail Least privilege, defence in depth Accidental forwarding of the link; permanent, indexable links
6 An audit record with actor, tenant, rows exported, IP and time, in storage with no delete permission Traceability, non-repudiation, separation of duties Later denial; inability to investigate an incident
7 An automatic alert to Marta and an e-mail to the customer's administrator on every export Assume breach Silent exfiltration through a compromised account
8 Minimal columns in the CSV (no internal notes, no payment identifiers) and encryption of the file at rest Need to know, least privilege A leaked CSV exposing more than the bare minimum

The layers are independent: a failure of the permission check (1) does not cancel the database isolation (3), and even if the file leaks, its content is minimised (8) and the fact is recorded and alerted on (6, 7). An additional note: an export of personal data has compliance implications — lawful basis, information to the data subject, recording of the activity — that must be validated with the data protection officer.


Conclusion

You have worked through the fifteen principles that underpin every technical decision in the rest of the course. Least privilege and need to know limit the damage of any failure, and you have seen them made concrete in the GRANTs of the nimbus_api account — no DELETE, no access to payroll, INSERT but not UPDATE on the audit table — and in a bucket policy that stops the API itself deleting objects or rewriting the rules. Defence in depth stacks independent layers around the medical attachments, including the detection and recovery layers that so many architectures forget. Secure by default and security by design move protection from "let nobody forget" to "forgetting is impossible", as row level security in PostgreSQL does. Fail-secure has taught you that a badly placed except turns an outage into a privilege escalation.

You have also seen the principles that are forgotten most often and cost the most: separation of duties with enforce_admins: true and its compensating controls when an SME has only one administrator; complete mediation, which checks again on every download; economy of mechanism, which requires you to be able to draw your authorisation model on a whiteboard; psychological acceptability, which explains why the strictest password policy produces the weakest passwords; and the rejection of security through obscurity, with the distinction between a rotatable secret and a hidden design. And you have closed with the two ideas that define modern security: Zero Trust — location on the network grants no privileges — and assume breach, which changes the question from "how do I stop them?" to "how much damage can they do and how long before I find out?".

You now know what to protect, what from and on what basis. What is missing is the most concrete part: knowing exactly what Nimbus has. In the next lesson, Assets, Attack Surface and Threat Actors (01-04), we will build the company's asset inventory with owner, criticality and information classification; we will measure its attack surface in all its dimensions; we will meet the actors who might attack it and why an SME is affected above all by automated attacks; and you will take your first steps with STRIDE over a real Nimbus data flow diagram.

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