fix(enterprise): let the admin read and manage collaboration Custom Fields

The Custom Fields table (collaboration_field_definition) answered 403
"This needs collaboration.view." to the all-access admin. Its permission
rules are NOT_PROTECTED with qbit-collaboration's FieldSecurity checker,
which tests collaboration.view / collaboration.admin (and
collaboration.fields.manage for writes and processes) by literal name, so
PermissionsHelper.getAllAvailablePermissionNames never lists them; the
same pattern as the jobs and assistant names the profile already grants.

The enterprise session now also holds collaboration.view, .admin,
.fields.manage and .tags.manage. The grants moved into
DemoUsers.grantEnterpriseAccess so EnterpriseAccessTest can run the real
FieldSecurity checker against them: refused without, read and insert
allowed with.

GuardedColumnStatsStep gets a TODO to delete it once QRun-IO/qqq#1367
lands in core.

Fixes #47

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-11-01 16:26:11 -06:00
1 parent 8015b8b090
commit 39c28a4e00
3 files changed
+90 -22

No files matched your search

@@ -88,32 +88,47 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
}
if(EnterpriseFeatures.isEnabled())
{
//////////////////////////////////////////////////////////////////////
// enterprise test server: every session is an all-access admin, so //
// the terminal clients can reach every qbit surface //
//////////////////////////////////////////////////////////////////////
session.withPermissions(PermissionsHelper.getAllAvailablePermissionNames(instance));
//////////////////////////////////////////////////////////////////////
// permissions some modules check by literal name, outside the //
// table/process permission rules (QRun-IO/qqq#1362) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin", "assistant.hasAccess", "assistant.admin");
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
{
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
}
//////////////////////////////////////////////////////////////////
// per-user keys (owner locks of qbit tables) take the user id //
//////////////////////////////////////////////////////////////////
session.withSecurityKeyValue(keyType.getName(), userId);
}
grantEnterpriseAccess(instance, session, userId);
}
session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test")));
if(EnterpriseFeatures.isEnabled())
{
EnterpriseSessions.record(session);
}
}
/**
* The enterprise test server: every session is an all-access admin, so the
* terminal clients can reach every qbit surface.
*/
static void grantEnterpriseAccess(QInstance instance, QSession session, String userId)
{
//////////////////////////////////////////////////////////////////////
// enterprise test server: every session is an all-access admin, so //
// the terminal clients can reach every qbit surface //
//////////////////////////////////////////////////////////////////////
session.withPermissions(PermissionsHelper.getAllAvailablePermissionNames(instance));
//////////////////////////////////////////////////////////////////////
// permissions some modules check by literal name, outside the //
// table/process permission rules (QRun-IO/qqq#1362) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin", "assistant.hasAccess", "assistant.admin");
//////////////////////////////////////////////////////////////////////
// qbit-collaboration's custom checkers (Custom Fields, tags, the //
// admin tables) test these names; without them the Custom Fields //
// table answered 403 "This needs collaboration.view." (#47) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("collaboration.view", "collaboration.admin", "collaboration.fields.manage", "collaboration.tags.manage");
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
{
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
}
//////////////////////////////////////////////////////////////////
// per-user keys (owner locks of qbit tables) take the user id //
//////////////////////////////////////////////////////////////////
session.withSecurityKeyValue(keyType.getName(), userId);
}
}
}
@@ -29,6 +29,10 @@ import com.kingsrook.qqq.backend.core.processes.implementations.columnstats.Colu
* field-access policy to them instead of withholding table-less records.
* The process shape belongs in qqq core (QRun-IO/qqq#1367); this is the host's
* safe check until then.
*
* TODO(QRun-IO/qqq#1367): when core's ColumnStatsStep refuses fields the caller
* cannot read in full and tags its result records with the source table, delete
* this class and the code swap in EnterpriseFeatures.enableColumnStatistics.
*/
public class GuardedColumnStatsStep extends ColumnStatsStep
{
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Kingsrook, LLC
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
package com.kingsrook.qqq.all;
import java.nio.file.Path;
import com.kingsrook.qbits.collaboration.customfields.FieldSecurity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** The enterprise admin passes qbit-collaboration's Custom Fields checker (qqq-all#47). */
class EnterpriseAccessTest
{
@TempDir Path dataDirectory;
@AfterEach
void tearDown()
{
QContext.clear();
}
@Test
void adminCanReadAndManageCustomFields() throws Exception
{
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
QSession plain = new QSession();
QContext.init(instance, plain);
assertThrows(QPermissionDeniedException.class,
() -> new FieldSecurity().checkPermissionsThrowing(new QueryInput("collaboration_field_definition"), null));
QSession admin = new QSession();
DemoUsers.grantEnterpriseAccess(instance, admin, "core:admin");
QContext.init(instance, admin);
assertDoesNotThrow(() -> new FieldSecurity().checkPermissionsThrowing(new QueryInput("collaboration_field_definition"), null));
assertDoesNotThrow(() -> new FieldSecurity().checkPermissionsThrowing(new InsertInput("collaboration_field_definition"), null));
}
}