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.
- 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_usersGroupGrantEvaluationIntegrationTest.the_group_grant_applies_only_at_its_exact_scopeGroupGrantEvaluationIntegrationTest.a_removed_member_loses_the_inherited_grantUserRbacIntegrationTest.customer_admin_cannot_grant_a_role_exceeding_its_own_permissionsPolicyDecisionLogIntegrationTest.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.