With identity verified (07-01) and the channel encrypted (07-02), an attacker can no longer read Ana's token or pose as Orders. But they can still be a legitimate user sending a malicious request: an id with odd characters, a 50 MB JSON, a MongoDB filter disguised as a string, or simply GET /v1/orders/ord-88214 to see whether it slips through. This lesson is about what each service does with what it receives and with what it stores: the principles that organize everything else, the OWASP API Security Top 10 walked through with TechCorp examples, input validation and sanitization with zod, injections (SQL, NoSQL, commands, logs), headers with helmet, dependencies, secrets in code, protection of personal data (GDPR at a practical level), auditing, security testing and vulnerability response. It ends with the checklist that gets added to the techcorp/node-service-template template. Authentication (07-01), TLS (07-02) and containers/Kubernetes (07-04) are only linked to.
Notice. The measures in this lesson are the usual ones for a development team, not a complete security program. The actual application of the GDPR, of PCI DSS and of any retention or audit policy must be reviewed with a security professional and with the organization's compliance/data protection officer.
Contents
- Principles that organize the rest
- OWASP API Security Top 10 (2023) with TechCorp examples
- Input validation and sanitization with
zod - Injection: SQL, NoSQL, commands and logs
- Security headers with
helmet - Dependency management
- Secrets in code: never, and how to make sure
- Protecting personal data: GDPR in practice
- Auditing sensitive actions
- Security testing: SAST, DAST, review and pentest
- Vulnerability response
- Per-service security checklist
- Principles that organize the rest
Six principles that recur in every decision of this module:
| Principle | Meaning | Already applied at TechCorp |
|---|---|---|
| Least privilege | Each component can only do what it needs | svc_* users per schema (02-04), RabbitMQ permissions per queue (07-02), scopes per client (07-01) |
| Defense in depth | Several independent layers; none is the only one | JWT verified at the gateway and in the service; TLS and HMAC signature; validation and parameterized queries |
| Fail secure | When in doubt, deny; on error, do not reveal | AuthorizationPolicy with explicit ALLOW; 500 INTERNAL_ERROR without a stack trace (04-02); invalid config → it does not start (04-03) |
| Minimal surface | Fewer doors, less risk | Inventory/Payments/Notifications not exposed (03-04); x-powered-by disabled; image without development npm (05-01) |
| Security by design | Decided at design time, not patched at the end | Resource owner in the use case; events without unnecessary data (02-05) |
| Shift left | Check as early as possible in the cycle | Lint, tests, npm audit, Trivy and gitleaks in CI (05-03) before deploying |
- OWASP API Security Top 10 (2023) with TechCorp examples
The OWASP API Security Top 10 is the reference list of API risks. Walking through it with your own cases is the best way to internalize it:
| # | Risk | What it would look like at TechCorp | Remedy and where |
|---|---|---|---|
| API1 | BOLA (Broken Object Level Authorization) | Ana requests GET /v1/orders/ord-88214 (another customer's) and gets it |
Owner check on every route with {id} (07-01): someone else's order → 404 |
| API2 | Broken authentication | Accepting alg: none, hours-long tokens, password grant, JWKS without verifying aud |
jwtVerify with algorithms, issuer, audience; 5-minute tokens (07-01) |
| API3 | Excessive data exposure / object property level | GET /v1/orders/{id} returns the email and keycloakSub from the customers_ref replica; a PATCH that accepts status or total from the client |
Explicit DTOs (toDto/toRepresentation from 04-02/04-04): the outgoing fields are chosen; zod schemas with .strict() that reject unexpected fields |
| API4 | Unrestricted resource consumption | A client requests ?limit=100000, sends 50 MB bodies or makes 10,000 POST /v1/orders per minute |
limit max. 100 (03-01/04-02), express.json({ limit: '100kb' }), rate limit 300/min at the gateway (03-04), timeout and bulkhead (06-03) |
| API5 | Broken function level authorization | A customer calls GET /v1/orders?status=PENDING (operator listing) or POST /v1/products |
requireRole('operator') on operational routes (07-01); admin routes not exposed by the public gateway |
| API6 | Unrestricted access to sensitive business flows | A bot creates thousands of orders for a product on sale and drains the stock | Rate limit per user (not only per IP), CAPTCHA on the web for spikes, business limits (max. N units per order/customer) |
| API7 | SSRF | A user-configurable webhook or an "image URL" that Catalog downloads points at http://10.0.0.5:15672 (RabbitMQ) or http://169.254.169.254 (cloud metadata) |
Do not accept arbitrary URLs from the user; if unavoidable, domain allowlist, resolve DNS and reject private IPs, do not follow redirects; restricted egress (07-04) |
| API8 | Security misconfiguration | CORS with *, x-powered-by, error stack traces sent to the client, NODE_ENV other than production |
CORS with specific origins (03-04), helmet (section 5), errorMiddleware that hides details (04-02) |
| API9 | Improper inventory management | A forgotten /v1/orders-legacy route without authentication; an old version still deployed |
OpenAPI as the source of truth (03-06), Pact contracts (04-05), the gateway as the single entry point, retiring versions with a date |
| API10 | Unsafe consumption of third-party APIs | Blindly trusting the response from the payment provider or from an address provider | Also validate what arrives from third parties with zod (the productTranslator ACL from 04-04 does that), timeouts, HMAC on webhooks (07-02) |
Most of the remedies already exist in TechCorp's code; this lesson gathers them and adds the missing ones.
- Input validation and sanitization with
zod
zodRule: everything that comes in over the network is validated against a schema before touching it, whether it comes from the user, from another service or from an event. In 04-02 and 04-04 this is already done with zod; here it is hardened:
// orders-service/src/routes/schemas.js
const { z } = require('zod');
// Identifiers with a closed pattern: no "ord-88213' OR 1=1" and no paths with "../"
const OrderId = z.string().regex(/^ord-[0-9a-f]{8}$/, 'invalid orderId');
const CustomerId = z.string().regex(/^c-[0-9]{1,10}$/);
const ProductId = z.string().regex(/^p-[0-9]{1,10}$/);
const COUNTRIES = ['ES', 'PT', 'FR']; // allowlist, not "any ISO code"
const Address = z.object({
street: z.string().trim().min(1).max(120),
postalCode: z.string().regex(/^[0-9]{5}$/),
city: z.string().trim().min(1).max(80),
country: z.enum(COUNTRIES)
}).strict(); // unknown fields → 400 (not silently ignored)
const CreateOrder = z.object({
customerId: CustomerId,
lines: z.array(z.object({ productId: ProductId, quantity: z.number().int().min(1).max(50) }).strict()).min(1).max(30),
shippingAddress: Address
}).strict();
const ListOrders = z.object({
status: z.enum(['PENDING', 'CONFIRMED', 'CANCELLED']).optional(),
customerId: CustomerId.optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().max(200).optional()
}).strict();
module.exports = { OrderId, CreateOrder, ListOrders };And in the routes, the route parameters too, which many forget:
// orders-service/src/routes/orders.js (fragments)
router.get('/v1/orders/:id', requireScope('orders:read'), async (req, res, next) => {
try {
const id = OrderId.parse(req.params.id); // ZodError → 400 INVALID_REQUEST via errorMiddleware (04-02)
// ... owner (07-01), repository.get(id), ETag ...
} catch (err) { next(err); }
});
router.post('/v1/orders', requireScope('orders:create'), async (req, res, next) => {
try {
const request = CreateOrder.parse(req.body); // from here on, "request" is trusted in shape; the business decides the rest
// ...
} catch (err) { next(err); }
});And in app.js the global limits the template already shipped with, now justified: express.json({ limit: '100kb', strict: true }) (a larger body → 413; strict rejects JSON that is not an object or array), and at the gateway proxy-body-size: 2m on the Ingress (05-02) as the absolute ceiling. Sanitization versus validation: TechCorp prefers to reject (400) rather than silently "clean up"; the only accepted normalization is trim() and uppercasing codes (country.toUpperCase()), and always in the schema, never scattered around.
- Injection: SQL, NoSQL, commands and logs
Injection happens when user data is interpreted as code or structure. Four variants that affect TechCorp:
SQL (PostgreSQL with pg). Never concatenate; always positional parameters, as OrderRepository already does in 04-04:
// BAD: if status = "PENDING' OR '1'='1", it returns everything; if it contains "; DROP TABLE orders; --", worse
await db.query(`SELECT * FROM orders.orders WHERE status = '${status}'`);
// GOOD: the value travels separately from the statement; the engine never interprets it as SQL
await db.query('SELECT * FROM orders.orders WHERE status = $1 AND customer_id = $2 ORDER BY created_at DESC LIMIT $3', [status, customerId, limit]);
// Identifiers (column name for ORDER BY) CANNOT be parameterized: allowlist
const SORT_COLUMNS = { createdAt: 'created_at', total: 'total_cents' };
const column = SORT_COLUMNS[sort] ?? 'created_at';NoSQL (MongoDB in Catalog). The risk is different: not text, but objects. If a route does collection.find({ sku: req.query.sku }) and the client sends ?sku[$ne]=x, Express (with qs) builds { sku: { $ne: 'x' } } and the query returns every product; with $where or $regex it can be worse. Rule: never pass user objects into the filter; validate with zod that each value is a string (or number) and build the filter in the repository with fixed keys:
// catalog-service/src/routes/products.js
const Query = z.object({ ids: z.string().optional(), category: z.string().regex(/^[a-z-]{1,40}$/).optional(), limit: z.coerce.number().int().min(1).max(100).default(20) }).strict();
const q = Query.parse(req.query); // ?category[$ne]=x → fails: not a string
// repository: const filter = {}; if (q.category) filter.category = q.category; // the key is set by the code, the value is a validated stringAlso, express.json does not interpret operators, but a body { "filter": { "$where": "..." } } would indeed arrive as an object: same remedy (.strict() schema, no free-form filter).
System commands. They are almost never needed in a service; if they exist (generating a PDF, converting an image), execFile('convert', [path]) with arguments in an array, never exec(\convert ${name}`)`, and the file name validated with a pattern. Better still: a native library.
Logs. Log injection: a user puts the text "Gran Vía 12\n{\"level\":\"info\",\"message\":\"order paid\"}" in street and, if the log were plain text, a fake line would appear. With pino (06-01) the value goes serialized inside a JSON (newlines are escaped as \n), so the structure does not break; the rule is not to build messages by concatenating user input (log.info(\order from ${name}`)) but to pass it as a field (log.info({ customerId }, 'order created')`), and to remember that personal data never goes into the log under any circumstances (section 8).
- Security headers with
helmet
helmethelmet is a set of Express middlewares that sets defensive HTTP headers. At the gateway (what the browser sees) and in each service (defense in depth; and some, like the BFF, serve HTML):
// gateway/server.js and service template (app.js), after requestIdMiddleware
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: false, // the API does not serve HTML; the CSP is defined by the SPA/BFF that does
strictTransportSecurity: { maxAge: 31_536_000, includeSubDomains: true }, // HSTS (07-02): consistent with the Ingress
crossOriginResourcePolicy: { policy: 'same-site' }
}));| Header | Effect | Who needs it |
|---|---|---|
Strict-Transport-Security |
HTTPS only for a year | Gateway (and the Ingress, 07-02) |
X-Content-Type-Options: nosniff |
The browser does not "guess" MIME types (a JSON response is not executed as a script) | Everyone |
X-Frame-Options: DENY / CSP frame-ancestors |
Prevents clickjacking | BFF/web; harmless in APIs |
Content-Security-Policy |
Which script/style origins are allowed | web-store and bff-mobile if they serve HTML; disabled in the API |
Referrer-Policy, X-DNS-Prefetch-Control, Cross-Origin-* |
Information leaks while browsing | Web |
(removes) X-Powered-By |
Do not advertise Express | Everyone (app.disable('x-powered-by') already in 04-02) |
helmet does not replace CORS (03-04): CORS says from which origin the browser may call; helmet says how the browser must treat the response.
- Dependency management
A typical orders-service drags along 300 transitive packages; most real vulnerabilities come in through there. TechCorp's policy:
| Practice | How | When |
|---|---|---|
Versioned package-lock.json and npm ci |
The lockfile pins exact versions; npm ci fails if it does not match package.json (05-01, 05-03) |
Always |
npm audit --omit=dev --audit-level=high |
The pipeline fails if there is a high/critical vulnerability with a patch in production dependencies | In ci.yml, lint stage |
| Dependabot or Renovate | Automatic update pull requests, grouped weekly; security patches, immediately | Configuration in .github/dependabot.yml |
| Update policy | Minors and patches: accepted if CI and contracts pass; majors: team review | Luis's rule (05-03) |
| Minimal dependencies | Before adding a package: does Node ship it? (fetch, crypto, AbortSignal), is it maintained?, how many transitives does it drag in? |
PR review |
| Pin the base image and CI actions | node:20-alpine with digest (07-04), actions/checkout@v4 by version |
Template |
| SBOM | Bill of materials for every image (Syft, 07-04) to know within minutes whether "we use library X" | Mention; generated in CI |
And npm audit in CI is not infallible: review the advisory, do not silence it with --force; if there is no patch, assess the risk (is the vulnerable function used?) and document the exception with a review date.
- Secrets in code: never, and how to make sure
The rule has existed since 04-03 (secrets arrive through the environment or _FILE files, never in the repository or the image); here it becomes verifiable:
# .github/workflows/node-service-ci.yml (fragment, the reusable workflow from 05-03)
- name: Scan history for secrets
uses: gitleaks/gitleaks-action@v2 # detects AWS keys, tokens, passwords in URLs, private keys...
env: { GITLEAKS_LICENSE: "" } # free for organization repos with a license; alternative: git-secrets or trufflehogAnd locally, a pre-commit hook with gitleaks protect --staged. If a secret reaches the repository, the only correct response is to rotate it (git history is public forever to anyone who has cloned it): the mechanics of rotation — Secrets, External Secrets, restarts — belong to 07-04. Add .env, *.pem, *.key to the template's .gitignore; and remember that a secret in a console.log, in an error trace or in a request URL (?token=) is also a leak: hence redact (06-01) and configForLog() (04-03).
- Protecting personal data: GDPR in practice
TechCorp processes Ana's personal data (name, email, address) and payment data. Without pretending to replace the data protection officer, the technical decisions a team must make:
- Minimization. Should
order.createdcarrycustomer.emailandname(02-02)? They are needed so that Notifications can send the email without calling Customers (autonomy, 03-02). Alternatives: (a) the event carries the data and all consumers receive it (Inventory does not need it); (b) the event carries onlycustomerIdand Notifications queriesGET /v1/customers/{id}with its service token. TechCorp's decision: (a) for autonomy and because Notifications is the only subscriber that uses it today, under three conditions: personal data goes only inorder.created(not in the rest of the saga's events), RabbitMQ is encrypted (07-02) and the queues and the DLQ have limited retention (messages in.dlqolder than 7 days are purged after review: they are not an archive); and it will be revisited toward (b) if a second consumer appears that does not need it. - Pseudonymization. Outside Customers, services store
customerId, not the name; logs and metrics carry only identifiers (06-01); Jaeger traces carry no bodies. - Encryption at rest. On managed databases it is enabled by default (encrypted disks); for especially sensitive columns you can encrypt at the application level with a key from the secrets manager, at the cost of not being able to index them. Backups encrypted and with the same retention.
- Right to erasure. The flow from 02-04:
customers-serviceanonymizes and publishescustomer.deleted; Orders deletescustomers_refand anonymizes addresses of old orders that tax regulations require keeping; Notifications stores nothing. It is one more use case, with an automated test. - Card data: NEVER at TechCorp. The browser sends the card directly to the payment provider (the provider's form or SDK); TechCorp receives a token (
tok_…) thatpayments-serviceuses to charge. That way the PCI DSS scope is reduced to the minimum (SAQ A). Neither the number, nor the CVV, nor even "the last four digits" unless the provider returns them already masked.*.cardinredactis a safety net in case someone slips up, not a permission. - Record of processing activities and retention: each service documents which personal data it stores, why and for how long (table in its
README), and aCronJobapplies the retention (e.g. anonymizing addresses of orders older than 5 years).
- Auditing sensitive actions
The logs from 06-01 tell what happened technically; the audit trail tells who did what to what, and must be provable months later. Auditable actions at TechCorp: cancelling an order (who, reason), changing a price in Catalog, manually changing a payment's status, reprocessing the DLQ (scripts/reprocessDlq.js, 06-03), an operator accessing a customer's data, changing roles in Keycloak. Requirements: immutable (insert-only, no UPDATE/DELETE for the svc_* user), separate from operational logs (different retention: years versus weeks), and correlatable with X-Request-Id and the token's sub (X-User-Id, 07-01).
// @techcorp/common-http/src/audit.js — one record per sensitive action
function createAudit({ db, service }) {
return {
async record(req, { action, resource, resourceId, detail = {} }) {
// table <schema>.audit_log(id, occurred_at, service, action, resource, resource_id, user_id, roles, request_id, detail jsonb)
// The svc_* user only has INSERT on it; reading is a separate role (auditor)
await db.query(
'INSERT INTO audit_log (occurred_at, service, action, resource, resource_id, user_id, roles, request_id, detail) VALUES (now(), $1, $2, $3, $4, $5, $6, $7, $8)',
[service, action, resource, resourceId, req.user?.sub ?? 'system', (req.user?.roles ?? []).join(','), req.id, JSON.stringify(detail)]
);
}
};
}
// Usage in the cancellation (03-01): await audit.record(req, { action: 'ORDER_CANCELLED', resource: 'order', resourceId: order.orderId, detail: { reason } });At high volume, the table is replaced by audit.* events sent to a write-only store (or the structured log with type: 'audit' sent to a Loki tenant with long retention and no deletion); what matters is the separation and immutability, not the technology. detail contains no personal data: identifiers and business values.
- Security testing: SAST, DAST, review and pentest
| Type | What it does | Tool at TechCorp | When |
|---|---|---|---|
| SAST (static analysis) | Looks for dangerous patterns in the code without running it | eslint-plugin-security in the template's configuration; Semgrep with the p/nodejs and p/owasp-top-ten rules in CI |
Every PR |
| Dependency analysis | Known vulnerabilities | npm audit, Dependabot (section 6) |
Every PR and daily |
| Image scanning | CVEs in the image | Trivy (07-04) | Every build |
| DAST (dynamic) | Attacks the deployed API | OWASP ZAP in API mode (zap-api-scan.py with the OpenAPI from 03-06) against staging, after the E2E from 05-03 |
Every deployment to staging (report), weekly (blocking on high) |
| Code review | Human eyes with a checklist | Checklist from section 12 in the PR template; reviewer from another team for routes with {id} or personal data |
Every PR |
| Pentest | An external professional tries to break it | External firm; scope: gateway, web-store, Keycloak, webhooks |
Annual and before the Payments audit |
# .github/workflows/node-service-ci.yml (SAST fragment)
- name: Semgrep
uses: semgrep/semgrep-action@v1
with: { config: "p/nodejs p/owasp-top-ten p/secrets" }And application security tests are ordinary tests: the test/orders.auth.test.js from 07-01 (401/403/404) and cases such as "an id that does not match the pattern returns 400", "a body with an unknown field returns 400", "?category[$ne]=x returns 400" live in Jest alongside the rest and protect against regressions.
- Vulnerability response
Sooner or later someone will find something. What TechCorp leaves prepared:
- Reporting channel:
[email protected]and a/.well-known/security.txtfile onshop.techcorp.example(RFC 9116) with contact and policy; public acknowledgment (no formal reward in phase 1). - Triage: severity with CVSS; critical/high → security incident following the process from 06-05 (channel, owner, timeline), even if there is no service outage.
- Patching: branch from
main, a test that reproduces the flaw, deployment through the normal pipeline (canary if applicable, 05-04); vulnerable dependency → update or temporary mitigation (block the route at the gateway). - Communication: internal (all teams, because the same pattern may exist in another service) and, if personal data was accessed, notification to the supervisory authority within 72 hours and to those affected under the GDPR: a decision of the data protection officer, not of the technical team.
- Blameless postmortem (06-05) with actions: new Semgrep rule, new item on the checklist, regression test.
- Per-service security checklist
This table is added to techcorp/node-service-template/SECURITY.md and to the pull request template; each service reviews it when created and on every relevant change:
| Area | Check | Reference |
|---|---|---|
| Authentication | authenticate() on /v1/*; probes and /metrics without token; algorithms, issuer, audience pinned |
07-01 |
| Authorization | Every route with {id} checks owner or role; requireScope/requireRole on operational routes |
07-01, API1/API5 |
| Input | zod .strict() schemas for body, query and route parameters; ids with a pattern; limit ≤ 100; express.json({ limit }) |
Section 3, API4 |
| Output | Explicit DTOs; never third parties' email/keycloakSub; errors without stack traces |
Section 2 (API3), 04-02 |
| Injection | Parameterized queries; MongoDB filters with fixed keys; no exec with user input; logging by fields |
Section 4 |
| Headers | helmet, x-powered-by off, CORS with specific origins |
Section 5 |
| Dependencies | npm ci, npm audit in CI, Dependabot enabled, no unmaintained packages |
Section 6 |
| Secrets | None in repo/image/log; gitleaks in CI; redact and configForLog() cover the new ones |
Section 7, 07-04 |
| Personal data | Processing table in the README; retention with a CronJob; reacts to customer.deleted if it stores customer data; no card data whatsoever |
Section 8 |
| Audit | Sensitive actions recorded with user_id and request_id; insert-only table |
Section 9 |
| Tests | 400/401/403/404 cases in Jest; Semgrep and ZAP green or with documented exceptions | Section 10 |
| Channel | TLS to DB and broker, minimal svc_* user |
07-02 |
| Platform | securityContext, NetworkPolicy, own ServiceAccount |
07-04 |
Common Mistakes and Tips
- Validating only the body and leaving
req.params.idandreq.querywithout a schema: half of the injections and BOLA come in through there. - Permissive schemas (
z.objectwithout.strict(),z.string()withoutmax) that accept any field or 10 MB strings: validation must be as strict as the OpenAPI contract. - "Sanitizing" instead of rejecting: stripping "dangerous" characters with home-grown regular expressions never covers every case and silently changes the user's data.
- Trusting third-party responses because "it's the payment provider": API10 exists because third parties make mistakes too, and get attacked too.
- Logging the request body at
info"for debugging" and discovering weeks later that Loki is storing addresses and emails. - Silencing
npm auditwith--forceoraudit-level=criticalso that CI passes: every exception, documented with a date. - Treating the audit trail as "just another log" and deleting it after 30 days with Loki's retention.
- Tip: every rule in this lesson that can be automated (lint, Semgrep, gitleaks, ZAP, Jest test) is worth more than the same rule in a document; the checklist is for what cannot be automated.
Exercises
Exercise 1. A developer proposes adding to catalog-service the route GET /v1/products/search?filter=<json>, which receives a MongoDB filter object "so the front end can run flexible queries". Explain the problem using the OWASP numbering and propose a secure design that covers the real needs (search by text, category and price range).
Exercise 2. Write the zod schema for POST /v1/orders/{id}/cancellation (03-01: reason from an allowed list, optional comment) including the route parameter, and the corresponding audit record with createAudit.
Exercise 3. Marta asks whether, to comply with the GDPR, it is enough for Notifications to "delete the email when customer.deleted arrives". List which other places in the platform may contain Ana's email and which measure applies to each.
Solutions
Solution 1. It is a textbook API8/API3 and NoSQL injection: an arbitrary filter object allows { "$where": "..." }, { "price": { "$gt": 0 } } on fields that should not be filterable, exposing internal fields (supplierCost), unindexed queries that bring Mongo down (API4) and, with $lookup in aggregations, reading other collections. Secure design: named parameters and a closed schema, Search = z.object({ q: z.string().trim().min(2).max(60).optional(), category: z.string().regex(/^[a-z-]{1,40}$/).optional(), priceMin: z.coerce.number().min(0).optional(), priceMax: z.coerce.number().max(100000).optional(), limit: ...max 100, cursor }).strict(); the repository builds the filter with fixed keys ({ normalizedName: { $regex: escape(q), $options: 'i' } } with q escaped or, better, a text index and $text: { $search: q }; priceCents: { $gte, $lte }); indexed fields only; and the route returns the usual toDto DTO. If the front end needs something more flexible, that is a case for GraphQL in the BFF (03-03), with a typed schema, not for passing Mongo objects around.
Solution 2.
const REASONS = ['CUSTOMER_CHANGED_MIND', 'ORDER_ERROR', 'OUT_OF_STOCK', 'FRAUD'];
const Cancellation = z.object({ reason: z.enum(REASONS), comment: z.string().trim().max(300).optional() }).strict();
router.post('/v1/orders/:id/cancellation', requireScope('orders:create'), async (req, res, next) => {
try {
const id = OrderId.parse(req.params.id);
const { reason, comment } = Cancellation.parse(req.body);
const order = await repository.get(id);
// owner/role as in 07-01 (customer: only their own → otherwise 404; operator: any; FRAUD only operator)
if (reason === 'FRAUD' && !req.user.roles.includes('operator')) throw new BusinessError('FORBIDDEN', 'Reason reserved for operators', 403);
await cancelOrder({ order, reason, user: req.user }); // transition + outbox order.cancelled (04-04)
await audit.record(req, { action: 'ORDER_CANCELLED', resource: 'order', resourceId: id, detail: { reason, hasComment: Boolean(comment) } });
res.status(202).set('Location', `/v1/orders/${id}`).end();
} catch (err) { next(err); }
});The comment does not go into the audit trail (free text from the user, may contain personal data): only whether it exists. And the audit record is written after the transition, inside the same transaction if the repository allows it, so as not to audit cancellations that failed.
Solution 3. Places and measures: (1) Customers database: anonymize the row (not delete, for integrity and tax obligations), source of the event. (2) customers_ref in Orders (02-04): delete the row and anonymize shipping_address of old orders on receiving customer.deleted. (3) order.created messages in RabbitMQ queues, .retry and .dlq: short retention and purge (section 8); if there are DLQ messages for the customer, they are processed or discarded. (4) Logs in Loki: there should be no email thanks to redact and the rule of not logging it; if an audit finds one, it is an incident (section 11) and it must be deleted from the store. (5) Traces in Jaeger: no bodies or personal data by design (06-02); check custom span attributes. (6) Backups: they are not edited; it is documented that the data disappears when the backup's retention expires (and it is anonymized on restore). (7) Keycloak: delete or disable the user (the sub), which is where the login email lives. (8) Notifications' email provider (if it keeps a sending history): configure minimal retention or request deletion through its API. (9) The audit table: it must not contain emails (only user_id), and by design it is kept. The answer to Marta: "delete in Notifications" is one of nine actions, which is why the right to erasure is a use case with a list and a test, not a DELETE.
Conclusion
This lesson has turned "code" security into concrete TechCorp practices: six principles (least privilege, defense in depth, fail secure, minimal surface, security by design, shift left); the OWASP API Security Top 10 read through its own cases and remedies that already existed (resource owner, DTOs, limit ≤ 100, rate limit, CORS, OpenAPI and Pact) or have been added; zod .strict() schemas with pattern-based ids (^ord-[0-9a-f]{8}$), allowlists and limits, also on route and query parameters, with express.json({ limit: '100kb' }); parameterized queries in pg, MongoDB filters with fixed keys, execFile and logging by fields; helmet on gateway and services; npm ci, npm audit, Dependabot and SBOM; gitleaks in CI and rotation on any leak; practical GDPR (minimization with the decision to keep email in order.created under conditions, pseudonymization, encryption at rest, customer.deleted, retention, and never card data: provider tokenization and minimal PCI DSS scope); an immutable, separate audit trail with createAudit correlated by user_id and request_id; SAST with ESLint/Semgrep, DAST with ZAP against staging, checklist review and an annual pentest; a response process with security.txt and GDPR notification; and the checklist that is now part of node-service-template. The last layer remains, the one that holds up all the others: the image that runs the code, the pod that contains it, the secrets that reach it, the network around it and the cluster's permissions. That is the topic of the next lesson: container and Kubernetes security.
Microservices Course
Module 1: Introduction to Microservices
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
