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 — or, on an edge brain, on a zone — 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:
- 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), or its edge zones withzones[].tenantfor a customer with hostnames and no prefixes. - Add an API token under
api.tokenscarrying that sametenant:plus arole:—vieweris read-only,operatorcan also ban/unban. - 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). An edge zone
carries the same label (zones[].tenant), so a customer may own hostnames and no prefixes at
all.
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.
Ownership and placement are two axes, and they may not contradict each other. A zone also
carries a hostgroup — where it is served — and that group
may itself carry a tenant. Nothing is inherited in either direction: a zone in a labelled
group is a house zone until it says otherwise, and a zone's own label never changes where it
runs. What is refused is a disagreement: when the zone and its named hostgroup both carry a
tenant and the two differ, the reload fails as a whole and the previous zones stay live, because
one zone cannot belong to two customers. The global group is the exception, and deliberately:
it is the fleet's catch-all, not a customer's PoP, so a labelled zone may sit in it whatever
kapkan.yaml's top-level tenant says.
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.
| Token | Sees | May 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: viewer | only customerA | nothing |
tenant: customerA, role: operator | only customerA | only customerA |
An unscoped token is an admin — the default, fully backward-compatible behavior. A token
scoped to a tenant that no hostgroup — and, with an edge block, no zone — uses
is rejected at config load (a typo would otherwise silently see nothing). With an edge block
that check needs both files, so it runs when the daemon loads them; the browser-side validator
sees only kapkan.yaml, and kapkan -check-config on the host is the real check.
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.
What narrows an agent token is topology, never ownership. node
binds it to one box, and an edge node's placement scope
decides which zones its document carries — whoever owns them. So a bound token on a PoP that
serves one customer's zones does hold one customer's zones, but it holds them because of where
the box is, not because the token is scoped: nothing about it is checked against a tenant.
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,/bansand/auditreturn only rows whose owning group carries the caller's tenant./statusis 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 atenant) 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 atargetfilter naming an out-of-tenant address is refused — no cross-tenant existence oracle. - Mutations — a scoped operator may
ban/unbanonly within its own prefixes. A target outside the tenant returns a uniform403whether or not a ban exists, so a tenant cannot probe another's ban state. The source-block channel binds to the same axis through itsvictim: a scoped token may block a source only for its own victims, and an out-of-tenant victim gets the same uniform403on both block and unblock.POST /api/v1/config/reloadis admin-only — a reload rewrites every tenant's policy and the token set itself. - Edge zones — a zone in the zones file carries an optional
tenanttoo.GET /api/v1/edge/zones/statusreturns a scoped token only its own zones — another tenant's hostname appears in no row, no would-be set and no HTTP/3 list, and thetenantfield is left out — and a scoped operator pulls the lever only on its own zones: any other zone, another tenant's, unlabelled or gone from the file, answers the same404 unknown zonean unknown name gets. The edge history follows the same line: a scoped token reads the history and the telling sources of its own zones, any other zone is one uniform403, and the per-node filter and the fleet's events stay unscoped. The names of the nodes a zone is served on are visible to its tenant; the zones document, the node reports, the ACME coordination, the node inventory andconfig/reloadstay unscoped. - 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.
!One resource is shared across tenants: a source's 8 victim slots
Access control is per tenant, but one kernel structure is not. A source block anchors a single kernel policy block at the source, and that block holds at most 8 victims regardless of who owns them. If the same attacker hits victims in two tenants, those victims compete for the same 8 slots and the ninth block is refused whichever tenant asks. Distinct blocked sources also draw on the deployment-wide policy pool. Neither cap can be partitioned per tenant — it is structural to the one-policy-per-source layout — so on a shared deployment, treat the source-block channel as a shared resource and keep its use to the tenant that observed the traffic. See Source blocks.
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.
Related
- Hostgroups — the prefix groups a tenant labels.
- Authentication — token roles the tenant scope builds on.
- Audit log — the per-tenant trail of operator actions.
- REST API — the endpoints that are filtered per tenant.
- Configuration reference