GitHub

Multi-tenancy

One Kapkan instance can serve many customers (a managed service provider (MSP) or internet data center (IDC) use case) and give each a token that sees and touches only their own attacks, bans and hosts. A tenant is just an optional label on a hostgroup — there is no new top-level object, no separate tenant registry, and the detection hot path is untouched.

tenant: "house"                 # optional: label the global/fallback group

hostgroups:
  - name: custA-web
    tenant: "customerA"         # this group belongs to customerA
    networks: ["203.0.113.0/26"]
  - name: custA-dns
    tenant: "customerA"         # a tenant can span several groups
    networks: ["203.0.113.64/26"]
  - name: custB
    tenant: "customerB"
    networks: ["198.51.100.0/24"]
  - name: shared-infra          # no tenant → visible only to admin tokens
    networks: ["192.0.2.0/24"]

api:
  tokens:
    - { name: admin,    token_env: KAPKAN_ADMIN, role: operator }
    - { name: a-portal, token_env: KAPKAN_A,     role: viewer,   tenant: "customerA" }
    - { name: b-ops,    token_env: KAPKAN_B,     role: operator, tenant: "customerB" }

How to set it up

Three steps map directly to the YAML above:

  1. Tag each customer's hostgroup(s) with tenant: "customerA" (the same label can repeat across several groups — a customer's web and DNS groups, say).
  2. Add an API token under api.tokens carrying that same tenant: plus a role:viewer is read-only, operator can also ban/unban.
  3. Hand the customer that token. They now see and touch only their own prefixes; everything else (other tenants, untagged groups) is invisible to them.

That's the whole workflow. The rest of this page explains how ownership is resolved and exactly what a scoped token can and can't do.

The model

"Which tenant owns this IP" is answered by the same longest-prefix hostgroup lookup the engine and mitigator already use to attribute traffic — when prefixes overlap, the most specific matching hostgroup wins (longest-prefix match). A tenant can span several hostgroups (a customer with separate web and DNS groups).

Any IP that falls outside every named hostgroup is handled by the implicit global/fallback group (the catch-all). That global group can carry a top-level tenant so this catch-all traffic is attributed to a "house" tenant; a named hostgroup with no tenant is unlabeled and visible only to admin tokens.

A tenant label is not a unit of mitigation policy — thresholds, BGP attributes and escalation stay per-hostgroup. It is purely an ownership tag used to scope the API.

Token scope

An API token gains an optional tenant, building on its role: a viewer can read but not change anything; an operator can also ban/unban.

TokenSeesMay mutate
unscoped (no tenant)all tenants (admin)everything its role allows (a viewer reads all tenants; an operator may ban/unban in all tenants)
tenant: customerA, role: vieweronly customerAnothing
tenant: customerA, role: operatoronly customerAonly customerA

An unscoped token is an admin — the default, fully backward-compatible behavior. A token scoped to a tenant that no hostgroup uses is rejected at config load (a typo would otherwise silently see nothing).

iThe agent role sits outside this axis

A scrub node's agent token is not a lesser viewer with a tenant — it is off the role ladder entirely, and it cannot be tenant-scoped (the combination is a config error). The rule feed and node inventory span every tenant by design: a scrub node filters for the whole deployment, so per-node tenant scoping is a fleet concern, not a token one. Treat an agent token as deployment-wide, and keep it off boxes that hold customer-facing data.

Enforcement

Scoping is default-deny: a scoped token only ever sees its own tenant. The tenant is derived once during authentication and applied to every row, so a scoped token can never fall through to another tenant's data.

  • Reads/api/v1/status, /attacks, /hosts, /bans and /audit return only rows whose owning group carries the caller's tenant. /status is rebuilt per scope: a scoped token gets only its own hostgroups and counts, never the global protected networks, the global thresholds, or the deployment-wide fallback group's config row — so a tenant never learns another's prefixes or BGP posture. A house tenant (the fallback group labeled with a tenant) still sees its catch-all attacks, bans and hosts; only that group's config row is hidden. The audit log is bound to the caller's tenant server-side, and a target filter naming an out-of-tenant address is refused — no cross-tenant existence oracle.
  • Mutations — a scoped operator may ban/unban only within its own prefixes. A target outside the tenant returns a uniform 403 whether or not a ban exists, so a tenant cannot probe another's ban state. POST /api/v1/config/reload is admin-only — a reload rewrites every tenant's policy and the token set itself.
  • Ambiguity fails closed — if one bearer secret matches tokens of different role or tenant (a reused secret), the request is refused rather than resolved to the wider scope.

Verify isolation

Hand the scoped token to curl and confirm it only sees its own tenant. /api/v1/status returns just that tenant's hostgroups:

curl -H "Authorization: Bearer $KAPKAN_A" http://127.0.0.1:8080/api/v1/status
# → only customerA's hostgroups and counts; no other tenant, no global config

Then confirm a cross-tenant ban is refused — banning an address outside the token's tenant returns 403:

curl -X POST -H "Authorization: Bearer $KAPKAN_A" \
  -d '{"ip":"198.51.100.5"}' http://127.0.0.1:8080/api/v1/ban
# → 403 {"error":"target is outside your tenant"}

i/metrics and the dashboard

/metrics is not tenant-scoped — it stays open for Prometheus scraping and requires no token, even when API auth is configured. The dashboard shell is shared (it holds no data of its own); every data call it makes is filtered by the token pasted into it, so a tenant's viewer token shows only that tenant's data.

!Ownership follows the current config

A scoped token's visibility is computed against the live config: re-tenanting a prefix (an admin-only reload) hands its in-flight attacks and bans to the new owner immediately. This is deliberate — control follows current ownership — but it means a mistaken reload can briefly move rows between tenants.

Backward compatibility

No tenant configured anywhere = single-tenant behavior, byte-for-byte: every group is unlabeled, every token is an unscoped admin, and the API returns everything as before. Tenancy is opt-in and you cannot get isolation on an open (token-less) API — there is no principal to scope.