Skip to content

Feat/tenant limits and quotas - #40

Open
olavgg wants to merge 6 commits into
mainfrom
feat/tenant-limits-and-quotas
Open

Feat/tenant limits and quotas#40
olavgg wants to merge 6 commits into
mainfrom
feat/tenant-limits-and-quotas

Conversation

@olavgg

@olavgg olavgg commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What this changes

How it was verified

Checklist

  • Pull requests this one depends on are named above and merged first, or there are none
  • Commits are signed off (git commit -s), per CONTRIBUTING.md
  • ./gradlew build passes
  • Behaviour visible outside this repo is reflected in the documentation, or does not need to be
    (see AGENTS.md for which of the two documentation sites it belongs in)

olavgg and others added 5 commits September 3, 2026 11:56
withoutAClientCertificateTheServerRejectsTheHandshake failed about nine
runs in ten, so `./gradlew build` could not go green on any branch. It
was not a broken build: the test was racing.

It asserted that a client with no certificate is refused with an
SSLHandshakeException, or something caused by one, or a message
containing "SSL". That is a TLS 1.2 shape. Under TLS 1.3 the client
finishes its side of the handshake before the server has looked at the
certificate it never sent, so the refusal usually arrives on the first
read instead, as "IOException: HTTP/1.1 header parser received no
bytes". Both shapes occur and which one turns up is a matter of timing,
which is exactly why it failed intermittently rather than always.

The assertion is now just that the call fails. What mutual TLS has to
guarantee is that an uncertificated client gets nothing back, not which
layer reports it, and keystoreAndTruststoreCompleteTheMutualTlsHandshake
against the same server already proves a failure here is the missing
certificate rather than an unreachable server. The javadoc records both
observed shapes so nobody tightens it back to one of them.

Verified with 20 consecutive runs, previously 1 pass in 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
Until now nothing bounded what one caller could send. This puts a ceiling
on every axis and answers each one with a status code a client can act
on: what clears by waiting is a 429 with Retry-After, and nothing else is.

- Field and batch caps in the API model: description, metadata, labels,
  related resources, GeoJSON size, datapoint value length, items per
  request, nodes and relations per create, datapoints per collection.
  Enforced on create and on update alike, so a cap cannot be grown past
  one append at a time.
- Request body size, 4 MiB by default and 16 MiB for datapoint inserts,
  checked on Content-Length before the body is read and counted as a
  chunked body is consumed. Over the cap is a 413 problem document. The
  file upload and download stream and are exempt; the body-cache filter
  learns the same exemption, so a multi-gigabyte download is no longer
  held in memory and a raw upload is parsed straight off the stream.
- Per-minute rate limits per organization and per user, with separate
  read and write budgets. A POST that only reads (filter, search, byids,
  list, count, check, fetch-related, fetch-nearest, aggregate, latest)
  is charged as a read. The MCP tools spend the same budget.
- Daily ingest quotas per organization: events, nodes, relationships,
  datapoints and write-request bytes, reset at 00:00 UTC.
- Lifetime ceilings on how large an organization may grow, answered
  with a 403 and no Retry-After. Off unless a deployment turns them on,
  since switching them on applies to every tenant at once.
- Caps on live WebSocket connections per organization and per user, and
  on subscriptions multiplexed onto one socket; the first closes the
  socket with a reason frame, the second refuses only that subscription.

Deployment-wide defaults live under datahub.limits. A one-row
tenant_limits table in each tenant's schema (migration V38) overrides
any of them for that tenant, live, within the cache TTL and with no
restart. Jackson's stream constraints are raised so a legal body cannot
be refused for one long string, and the OpenAPI description gains a
Limits section.

Around the API: the console shows a 413 or 429 as a message naming the
limit rather than a generic failure; the Java SDK's ingest result tells
a tenant-limit 403 apart from a permission one and never spools it; the
nginx examples gain a per-IP request zone for traffic that arrives before
a caller is identified, and keep their body ceiling above the API's own
so an oversized request comes back as the platform's message rather than
the proxy's error page; the systemd config comments say where to raise a
limit for one tenant instead of the deployment.

Two smaller changes ride along because they touch the same files: the
console drops the Feign paths it no longer calls (the governance
controllers, the label service, and the byids proxy, all of which the
browser now reaches on the API directly), and fetch-nearest resolves an
externalId the way fetch-related always has, instead of answering a 500
to a request its own form declares valid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
…int ingestion

Captures the plan to take TimeseriesService.insertDatapoints()/deleteDatapoints()
off the per-request PostgreSQL read (unpooled SimpleDriverDataSource) by caching the
small timeseries metadata the hot path needs in Valkey, including cache-key tenant
scoping, the invalidation points on save/update/delete, and the write-ACL staleness
considerations. Design note only, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
AGENTS.md states that every source file carries an SPDX identifier as its first
line, and the Java tree does. The eight Python files did not, so the rule held
everywhere except the one language where nothing enforced it.

That matters more than tidiness here: the repository is public, and a file with no
licence line is a file whose licence a reader has to infer from its neighbours.
These are all platform tooling and demo seeding, so they take the AGPL header like
the rest of the tree.

Header only; no behaviour changes. Placed after the shebang where there is one, so
the scripts stay executable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
GraphEventNeo4jListener was replaced when resource mirroring moved to the
Postgres outbox: the node and relationship properties it used to build are
built in GraphNodeProperties now, and applying them is ResourceGraphApplier's
job. Three comments still named it.

PolicyService's publishPolicyUpsert carried more than a stale name. Its
javadoc explained why the graph action had to be CREATE rather than UPDATE,
and left a backlog note about teaching the consumer's UPDATE branch to accept
full resources. There is no consumer and no action any more: the method queues
one idempotent upsert, so the workaround and the follow-up are both gone.

Comments only, no behaviour change.

Signed-off-by: Olav Gjerde <olav@intellistream.ai>
@olavgg
olavgg requested a review from JosteinGj September 3, 2026 10:14
@olavgg olavgg mentioned this pull request Sep 3, 2026
4 tasks
…on message

Code scanning flags an exception message written into an HTTP response as
error information exposed to an external user. The two limit refusals, the
daily quota 429 and the lifetime ceiling 403, did exactly that, in the
RestControllerAdvice and in the body-size filter's own copy of the 429,
through getMessage().

Nothing sensitive was in that message: both exceptions compose it from a
metric name and a number the platform chose, and the sentence is the answer
the caller is meant to read. But a scanner cannot tell a composed sentence
from a leaked one when both travel through Throwable.getMessage(), and
neither can the next person to add a catch block. LimitException now keeps
the sentence as its own field behind detail(), and the advice and the filter
read that. The message stays on the exception for logs, and the wire body
is unchanged.

Verified: LimitExceptionHandlerTest (new) pins the detail and the
Retry-After on both refusals; RequestBodySizeLimitFilterTest and
IngestQuotaServiceTest pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants