# The security model of an ERP integration

> Keys and scopes, secrets and rotation, transport, safe retries, the outbox, thin events, stale writes, data minimisation, the audit trail and the OWASP API Top 10.

An integration between your ERP and BatteryPass moves data that ends up in signed, public battery passports. This
page collects the rules that keep that path safe. Most of them are on your side of the connection; where
BatteryPass enforces something itself, it says so. Key handling in day-to-day operation is described in
[Handle keys securely](https://passport.kabin.sh/docs/en/guides/secure-key-handling).

## Keys and scopes

**One key per integration and per environment.** A test middleware and the production middleware get different
keys, and so do two integrations that run in the same middleware. You can then revoke one without stopping the
others, and the portal's **Last used** column tells you which one is active.

**The smallest scope that works.** A key has one or more of three scopes:

| Scope       | Lets the key                                                         | Workflows                                                                                                                                                                                                                                         |
| ----------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `read`      | read models, units, values, passports, exports and the change feed   | [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp), [Product change](https://passport.kabin.sh/docs/en/integrations/workflows/product-change), lookups in the other workflows |
| `condition` | report measured condition data and lifecycle events                  | [Condition data](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms), [Returns and repairs](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs)                                            |
| `import`    | upload, map and validate spreadsheet imports of units, and read them | [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production)                                                                                                                                         |

No scope lets a key commit an import, approve a value, publish, withdraw, manage members or keys, or change
declared data. Those stay with members of your organisation in the portal. A key acts with the rights of a viewer
plus its scopes, and it is never limited to some units: a `condition` key can report for every unit of your
organisation.

## Secrets

- Keep each key in a secrets manager or vault (HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, the iPaaS's
  credential store) and hand it to the process at start. Never put it into code, configuration under version
  control, container images, URLs, logs or tickets. The
  [OWASP Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html)
  describes the practice in detail.
- Send the key only in the header `Authorization: Bearer bp_live_…`. A request that also carries a portal session
  cookie is refused, so the actor is never ambiguous.
- Mask the `Authorization` header in every log of your middleware and HTTP client.

**Rotation.** Keys do not expire. Rotate them on your own schedule and whenever someone who knew a key leaves:
create a new key with the same scopes, switch the middleware to it, check that the new key shows a recent
**Last used** time, then revoke the old one. Both work at the same time during the switch, so nothing fails.
Revocation is immediate and final.

## Transport

- Call BatteryPass only over HTTPS (TLS) and verify the server certificate. Do not turn verification off in test
  environments either: a middleware configuration tends to travel from test to production.
- **Egress allow-listing** as a second line: let the middleware reach only the BatteryPass address and the ERP.
  It does not replace keys and TLS, but it limits what a compromised middleware can talk to.
- The same goes the other way: expose the ERP's APIs to the middleware only, never directly to the internet.

## Retries that do not duplicate

Networks fail. A request that timed out may or may not have been carried out, so retries must be safe.

**Back off.** Retry network errors and `5xx` answers with exponential backoff and jitter (for example 1, 2, 4, 8
seconds plus a random part, at most five attempts). Answer `429 rate_limited` by waiting the seconds of the
`Retry-After` header; all keys of your organisation share 600 requests per minute. Do not retry other `4xx`
answers: the request was refused as a whole, so fix it. Details are in
[Handle errors and retries](https://passport.kabin.sh/docs/en/guides/handle-errors) and [Rate limits](https://passport.kabin.sh/docs/en/rate-limits).

**Make each retry safe.** BatteryPass accepts an `Idempotency-Key` header only on bulk and publishing operations of
the portal, which keys cannot call. For the operations a key uses, the safety comes from the operations themselves
and from your middleware:

| Operation                                                                                                    | Safe to repeat                   | Why, or what you do                                                                                                                                                                                                                                                              |
| ------------------------------------------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Every `GET` (reads, the change feed, `getImport`)                                                            | Yes                              | They change nothing.                                                                                                                                                                                                                                                             |
| [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition)            | Yes, with the same `measured_at` | A measurement equal to or older than the stored one is skipped.                                                                                                                                                                                                                  |
| [`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import)                    | Yes, with care                   | A repeated upload creates a second import. Its rows become duplicates once the first one is committed, so no unit is created twice; members simply do not commit the extra import.                                                                                               |
| [`updateImportMapping`](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping)     | Yes                              | With the same `If-Match` the repetition is refused as stale (`409`); read the import and you see the mapping already applied.                                                                                                                                                    |
| [`validateImport`](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import)                | Yes                              | A second start while the first runs is refused (`409`); poll the import instead.                                                                                                                                                                                                 |
| [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event) | No                               | Every call records a new event. Remember which source record (for example the service order) you already reported, and look at [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) after a timeout before you send again. |

The IETF is working on a general `Idempotency-Key` header
([draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/),
draft 08, not yet an RFC). It is useful background for your own APIs; BatteryPass does not accept it on the
operations above.

## The outbox on the ERP side

The ERP must not lose a change while the middleware or BatteryPass is unreachable, and it must not call BatteryPass
inside its own business transaction. The **transactional outbox** solves both: in the same database transaction
that posts the goods receipt, the ERP (or an extension of it) writes a row to an outbox table. The middleware reads
the outbox, calls BatteryPass, and marks the row done only after a successful answer.

This gives **at-least-once delivery**: a row may be sent twice (after a crash between call and marking), never
zero times. Combine it with the retry rules above, and keep a small table of what you already sent (source record,
BatteryPass id, time) so you can de-duplicate on your side.

## Thin events, then fetch

Do not move data inside notifications. Both directions work the same way:

- From the ERP: many ERPs send thin events (a product number and the kind of change). The middleware reads the
  current record from the ERP's API afterwards, checks the vendor's signature, handshake or secret URL, and drops
  events it cannot verify.
- From BatteryPass: the [change feed](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes) lists only
  `{id, occurred_at, entity_type, entity_id, action}`, never values, actors or personal data. The middleware reads
  the entity with the same key afterwards, so the feed reveals nothing on its own and is always current.

The feed returns a change only once every older transaction in BatteryPass has finished, so a stored cursor never
skips a change that committed late. Store the cursor only after your side has processed the page. BatteryPass
sends no outbound webhooks yet; they are planned and need a decision of their own (signing, subscription
management, protection of target addresses). Until then, poll.

## Stale writes

Mutable resources carry a `row_version`. [`updateImportMapping`](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping)
requires `If-Match: "<row_version>"`; when someone else changed the import in between, the answer is
`409 conflict` and nothing is overwritten. Read the import again and decide. Condition data has its own rule: an
older measurement never replaces a newer one.

## Data minimisation

The GDPR asks for data that is "adequate, relevant and limited to what is necessary" (Art. 5(1)(c)). For a
battery passport that means data about the battery: serial number, model, dates, measurements, events. Customer
names and addresses, order numbers, employee names, prices, vehicle and location data stay in the ERP. Each
[workflow](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production) lists what it sends and what it leaves out, and
the [overview](https://passport.kabin.sh/docs/en/integrations) lists what never flows.

Free text is where personal data slips in: an event description such as "repaired for Mr. X at Y" ends up in your
battery's history. Fill descriptions from fixed codes or templates, not from free text of a service order.

## The audit trail

- Every write with a key is recorded in your organisation's audit log with the actor type `api_key` and the key's
  name: staging and validating an import, a condition update, a lifecycle event.
- Imports staged with a key name the key instead of a member (`created_by_api_key`), in the API and in the portal.
- Values written with a key do not carry the key themselves. A condition update creates a `condition_update`
  lifecycle event, and that event names the key (`api_key`).
- Condition data is stored as approved measurements without a reviewer. Declared data takes the reviewed path:
  imported values are approved by a reviewer, and whether a second person must approve is your organisation's
  four-eyes setting.
- Reads are not recorded. Assume that a leaked key's data was read.

## OWASP API Security Top 10

How the [OWASP API Security Top 10 (2023)](https://api-security.owasp.org/editions/2023/en/0x11-t10) risks that
matter most for an ERP integration are handled, and what remains your part:

| Risk                                          | In BatteryPass                                                                                                                                                                               | Your part                                                                                                                                                 |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API1:2023 Broken object level authorization   | Every query is filtered by the key's organisation; another organisation's id answers `404`.                                                                                                  | Use one key per organisation you connect; never mix tenants in one middleware flow.                                                                       |
| API4:2023 Unrestricted resource consumption   | 600 requests per minute for all keys of an organisation, 30 attempts per 15 minutes for unknown keys, request bodies of 1 MiB, uploads of 10 MB and 10 000 rows, lists of at most 200 items. | Back off on `429`, page with `limit` and `cursor`, and send files in batches.                                                                             |
| API5:2023 Broken function level authorization | Each operation names its scope (`x-api-key-scope`); a key without it gets `403`, and portal operations do not accept keys at all (`401`).                                                    | Give each key only the scopes its workflow lists.                                                                                                         |
| API7:2023 Server side request forgery         | BatteryPass does not call addresses that an integration supplies; there are no webhooks yet.                                                                                                 | When the middleware fetches URLs from ERP events, allow-list the targets.                                                                                 |
| API10:2023 Unsafe consumption of APIs         | BatteryPass validates every value with the registry's rules, whatever the source.                                                                                                            | Treat ERP events and BatteryPass answers as untrusted input: validate, check signatures where the vendor offers them, and never execute received content. |
