Work

MonitoringHive: who may see which building's data

Several parties share one building-monitoring platform. Each may see only what it's entitled to, and every decision has to be explainable afterwards.

How MonitoringHive decides who may access what A request passes the API gateway, which establishes identity and tenant. The receiving service's permission check asks a single policy evaluator, which denies by default and returns allow or deny with a reason. Every decision is written to a decision log in its own transaction. Underneath, PostgreSQL row-level security filters every query by tenant. At build time, architecture tests fail if an endpoint has no permission guard or if code other than the row-level-security layer touches the tenant setting. Request API gateway identity, tenant Service permission check Policy evaluator deny by default PostgreSQL row-level security Decision log own transaction allow / deny + reason every query every decision Build: architecture tests fail if an endpoint has no guard or if code bypasses the row-level-security layer
Simplified access-decision flow. Blue: the decision · dashed: evidence.
Status
In development. The access model described here is implemented; the gaps are listed at the end.
My role
Architecture and implementation. MonitoringHive is my own project.
Scope
Backend authorisation: the policy evaluator, how services enforce it, and how decisions are logged
Last reviewed
28 September 2026

MonitoringHive connects building equipment (HVAC, meters, lighting) to a central dashboard. A small box in the building sends data outbound only; there are no open ports on site. The same data is used by several parties: the building's operator, service partners who run the platform under their own brand, equipment makers, and me as the platform operator.

Where it started

The first prototype checked device.read without checking which device the user was allowed to access. Tenant isolation depended on every query remembering its filter.

Rules stay on the server

The boxes in buildings never get a copy of the rules. They only verify signed commands, and there is exactly one component that signs them. The trade-off: every check needs the backend. In return, a revoked permission is revoked everywhere at once, rather than on each box whenever it next syncs.

Decisions

Central permission evaluation. All business authorisation goes through a single interface, evaluate(context, action, resource), backed by one implementation in the auth service. The interface lives in a library with no framework dependencies, and a test fails the build if Spring or JPA creep into it.

Deny by default, with a reason. The evaluator walks a fixed order: is the context complete, is the actor active, is the permission known, does the resource belong to the requested tenant, then role permissions, then a grant at exactly that scope, then a grant to a group the actor belongs to. Anything that falls through is denied, with one of a fixed set of deny reasons. A customer can never act outside their home tenant:

// Step 4 — tenant/resource relationship.
String resourceTenantId = tenantIdOf(resource);
if (resourceTenantId != null && !resourceTenantId.equals(ctx.targetTenantId())) {
  return deny(DenyReason.DENY_RESOURCE_TENANT_MISMATCH, ctx);
}
if (ctx.actorClass() == ActorClass.CUSTOMER
    && !ctx.actorHomeTenantId().equals(ctx.targetTenantId())) {
  return deny(DenyReason.DENY_TENANT_MISMATCH, ctx);
}

Verbatim from AccessPolicyEvaluatorImpl.java, lines 109–117. Code as of 17 July 2026.

Database filtering and architecture tests. PostgreSQL row-level security restricts the tenant-scoped tables to the current tenant, so a query that forgets its tenant filter still can't return another tenant's rows. And because the services' permission interceptor lets handlers without an annotation through, an architecture test fails the build if any public endpoint is neither guarded nor explicitly allowlisted. A second one fails if code outside the row-level-security layer touches the tenant setting. If any step of the permission lookup errors, the service answers 403.

Verification

Every evaluation writes one row to a decision log and emits one audit event. The log entry uses a separate transaction, so it survives if the original operation is rolled back. It records the deny reason or the grant that allowed access.

Changes to access can be run as a read-only dry run first, showing who would see what before and after, and applied changes are audited separately.

The tests include:

  • tenant A cannot read tenant B's users, enforced by the database
  • a group grant applies only to the exact device it was given for
  • a member removed from a group loses the inherited grant
  • a customer admin cannot hand out a role with more permissions than their own
  • one evaluation produces exactly one log row and one event
Test references
  • RlsIsolationTest.tenant_a_cannot_read_tenant_b_users
  • GroupGrantEvaluationIntegrationTest.the_group_grant_applies_only_at_its_exact_scope
  • GroupGrantEvaluationIntegrationTest.a_removed_member_loses_the_inherited_grant
  • UserRbacIntegrationTest.customer_admin_cannot_grant_a_role_exceeding_its_own_permissions
  • PolicyDecisionLogIntegrationTest.one_evaluator_call_produces_one_log_row_and_one_event
@Test
void the_group_grant_applies_only_at_its_exact_scope() {
  String admin = actor("admin");
  String member = actor("member");
  String groupId = teamWithDeviceGrant(admin, DEVICE_ID);
  groupService.addMember(TENANT, groupId, member, admin, NOW);

  // Same member + permission, but a DIFFERENT device the team was not granted → DENY.
  PolicyDecision d = evaluateDeviceRead(member, OTHER_DEVICE_ID);

  assertThat(d.deny()).isTrue();
  assertThat(d.denyReason()).isEqualTo(DenyReason.DENY_NO_GRANT);
}

Verbatim from GroupGrantEvaluationIntegrationTest.java. Code as of 17 July 2026.

Not done yet

  • Consent for cross-tenant access. Partners and equipment makers have no consent step yet.
  • Break-glass access for emergencies. Today every break-glass request is denied.
  • Attribute-based conditions and delegation.
  • Grants that cascade from a tenant down to its sites and devices. Today a grant applies at exactly one scope.
  • Moving the evaluator into its own service. It runs inside the auth service for now.

Stack: Java 21, Spring Boot with Spring Modulith, PostgreSQL, Keycloak, ArchUnit and Testcontainers.