Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/target/
/dependency-reduced-pom.xml
/dependency-reduced-pom.xml
/CLAUDE.md
.claude/
10 changes: 8 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@
<maven-surefire-plugin.version>3.2.5</maven-surefire-plugin.version>
<spotless-maven-plugin.version>2.43.0</spotless-maven-plugin.version>

<hbase.version>2.6.0</hbase.version>
<hbase.version>2.6.4</hbase.version>
<hbase.protobuf.version>2.5.0</hbase.protobuf.version>
<caffeine.version>2.8.1</caffeine.version>
<caffeine.version>2.8.8</caffeine.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -106,6 +106,12 @@
<artifactId>hbase-testing-util</artifactId>
<version>${hbase.version}</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.apache.directory.jdbm</groupId>
<artifactId>apacheds-jdbm1</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
Expand Down
1,029 changes: 714 additions & 315 deletions src/main/java/tech/stackable/hbase/OpenPolicyAgentAccessController.java

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions src/main/java/tech/stackable/hbase/opa/OpType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package tech.stackable.hbase.opa;

/**
* Region-level operation types, mirroring the OpType enum in the HBase reference AccessController.
* {@code NONE} and {@code ROW_MUTATIONS} are extensions not present in the reference.
*/
public enum OpType {
NONE("none"),
GET("get"),
EXISTS("exists"),
SCAN("scan"),
PUT("put"),
DELETE("delete"),
CHECK_AND_PUT("checkAndPut"),
CHECK_AND_DELETE("checkAndDelete"),
APPEND("append"),
INCREMENT("increment"),
ROW_MUTATIONS("rowMutations");

private final String value;

OpType(String value) {
this.value = value;
}

@Override
public String toString() {
return value;
}
}
32 changes: 30 additions & 2 deletions src/main/java/tech/stackable/hbase/opa/OpaAclChecker.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -80,13 +83,38 @@ public OpaAclChecker(
}
}

public void checkPermissionInfo(User user, TableName table, Permission.Action action)
private void checkPermissionInfo(User user, TableName table, Permission.Action action)
throws AccessControlException {
checkPermissionInfoWithOp(user, table, action, OpType.NONE);
}

public void checkPermissionInfoWithOp(
User user, TableName table, Permission.Action action, OpType operation)
throws AccessControlException {
checkPermissionInfoWithOp(user, table, action, operation, Collections.emptyMap());
}

public void checkPermissionInfoWithOp(
User user,
TableName table,
Permission.Action action,
OpType operation,
Map<String, List<String>> families)
throws AccessControlException {
OpaAllowQuery query =
new OpaAllowQuery(new OpaAllowQuery.OpaAllowQueryInput(user.getUGI(), table, action));
new OpaAllowQuery(
new OpaAllowQuery.OpaAllowQueryInput(
user.getUGI(), table, action, operation, families));
this.checkPermissionInfo(query);
}

public void checkPermissionInfo(User user, TableName table, Permission.Action... actions)
throws AccessControlException {
for (Permission.Action action : actions) {
checkPermissionInfo(user, table, action);
}
}

public void checkPermissionInfo(User user, String namespace, Permission.Action action)
throws AccessControlException {
OpaAllowQuery query =
Expand Down
30 changes: 28 additions & 2 deletions src/main/java/tech/stackable/hbase/opa/OpaAllowQuery.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package tech.stackable.hbase.opa;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.apache.hadoop.hbase.TableName;
import org.apache.hadoop.hbase.security.access.Permission;
import org.apache.hadoop.security.UserGroupInformation;
Expand All @@ -16,22 +19,45 @@ public static class OpaAllowQueryInput {
public final TableName table;
public final String namespace;
public final Permission.Action action;
public final OpType operation;

/**
* Column families and their qualifiers being accessed. An empty qualifier list means CF-level
* access; a non-empty list means KV-level access to specific qualifiers within that family.
*/
public final Map<String, List<String>> families;

public OpaAllowQueryInput(UserGroupInformation ugi, TableName table, Permission.Action action) {
this.callerUgi = new OpaQueryUgi(ugi);
this(ugi, table, action, null);
}

public OpaAllowQueryInput(
UserGroupInformation ugi, TableName table, Permission.Action action, OpType operation) {
this(ugi, table, action, operation, Collections.emptyMap());
}

public OpaAllowQueryInput(
UserGroupInformation ugi,
TableName table,
Permission.Action action,
OpType operation,
Map<String, List<String>> families) {
this.callerUgi = new OpaQueryUgi(ugi);
this.table = table;
this.action = action;
this.namespace = table.getNamespaceAsString();
this.operation = operation;
this.families = families;
}

public OpaAllowQueryInput(
UserGroupInformation ugi, String namespace, Permission.Action action) {
this.callerUgi = new OpaQueryUgi(ugi);

this.table = null;
this.action = action;
this.namespace = namespace;
this.operation = OpType.NONE;
this.families = Collections.emptyMap();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package tech.stackable.hbase;

import static org.junit.Assert.assertTrue;

import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.hadoop.hbase.coprocessor.BulkLoadObserver;
import org.apache.hadoop.hbase.coprocessor.EndpointObserver;
import org.apache.hadoop.hbase.coprocessor.MasterObserver;
import org.apache.hadoop.hbase.coprocessor.RegionObserver;
import org.apache.hadoop.hbase.coprocessor.RegionServerObserver;
import org.junit.Test;

/**
* Verifies that every method in the coprocessor observer interfaces is either explicitly overridden
* in OpenPolicyAgentAccessController or listed in the known exclusion set below.
*
* <p>Because all HBase observer interface methods have default implementations, adding a new hook
* upstream would not cause a compile error. This test catches that: any new method that appears in
* an observer interface and is not in our class or in EXCLUDED will cause a test failure.
*
* <p>When upgrading HBase, if this test fails, review the new method(s) and either:
*
* <ul>
* <li>Implement them in OpenPolicyAgentAccessController, or
* <li>Add them to the appropriate section of EXCLUDED with a justification comment.
* </ul>
*
* <p>Note: all {@code post*} methods are excluded automatically — they fire after the operation has
* already been permitted and cannot block it, so OPA enforcement is never applicable.
*/
public class TestCoprocessorInterfaceCoverage {

private static final Class<?>[] OBSERVER_INTERFACES = {
MasterObserver.class,
RegionObserver.class,
RegionServerObserver.class,
EndpointObserver.class,
BulkLoadObserver.class,
};

/**
* Pre-hooks that are deliberately not overridden. Organised by reason. When a new HBase version
* adds a method that belongs here, add it with a comment explaining why.
*/
private static final Set<String> EXCLUDED =
new HashSet<>(
Arrays.asList(

// --- deprecated overloads superseded by variants we do override ---
// The WALEdit-carrying variants of data-write hooks were deprecated in HBase 2.x;
// the 2-arg versions (which we do override) are the current API.
"preAppend(ObserverContext, Append, WALEdit)",
"preDelete(ObserverContext, Delete, WALEdit)",
"preIncrement(ObserverContext, Increment, WALEdit)",
"prePut(ObserverContext, Put, WALEdit)",
// Old single-descriptor overload; we override the 4-arg (old + new) variant.
"preModifyTable(ObserverContext, TableName, TableDescriptor)",
// Old 2-descriptor namespace overload; we override the 2-arg (new descriptor only)
// variant.
"preModifyNamespace(ObserverContext, NamespaceDescriptor, NamespaceDescriptor)",
// Old 3-arg unassign with boolean; we override the 2-arg variant.
"preUnassign(ObserverContext, RegionInfo, boolean)",

// --- after-row-lock variants where we check at the pre-lock level ---
// HBase calls the pre-lock hook before acquiring the row lock and the after-lock hook
// after.
// We enforce permissions at the pre-lock level (preAppend, preIncrement), so the
// after-lock
// variants are redundant for OPA authorization.
"preAppendAfterRowLock(ObserverContext, Append)",
"preIncrementAfterRowLock(ObserverContext, Increment)",

// --- internal HBase multi-step DDL action hooks ---
// These are called internally by the HBase master during multi-step DDL procedures.
// They are not triggered by direct client calls; the public pre* hooks (e.g.
// preCreateTable)
// are already checked before these fire.
"preCreateTableAction(ObserverContext, TableDescriptor, RegionInfo[])",
"preCreateTableRegionsInfos(ObserverContext, TableDescriptor)",
"preDeleteTableAction(ObserverContext, TableName)",
"preEnableTableAction(ObserverContext, TableName)",
"preDisableTableAction(ObserverContext, TableName)",
"preTruncateTableAction(ObserverContext, TableName)",
"preTruncateRegion(ObserverContext, RegionInfo)",
"preTruncateRegionAction(ObserverContext, RegionInfo)",
"preModifyTableAction(ObserverContext, TableName, TableDescriptor)",
"preModifyTableAction(ObserverContext, TableName, TableDescriptor, TableDescriptor)",
"preMergeRegionsAction(ObserverContext, RegionInfo[])",
"preMergeRegionsCommitAction(ObserverContext, RegionInfo[], List)",
"preSplitRegionAction(ObserverContext, TableName, byte[])",
"preSplitRegionBeforeMETAAction(ObserverContext, byte[], List)",
"preSplitRegionAfterMETAAction(ObserverContext)",

// --- internal storage, compaction, and scan hooks ---
// These are called by HBase's internal storage engine for compaction, flush, and scan
// operations. They are not user-initiated and carry no meaningful authorization
// context.
"preClose(ObserverContext, boolean)",
"preCommitStoreFile(ObserverContext, byte[], List)",
"preCompactScannerOpen(ObserverContext, Store, ScanType, ScanOptions, CompactionLifeCycleTracker, CompactionRequest)",
"preCompactSelection(ObserverContext, Store, List, CompactionLifeCycleTracker)",
"preFlush(ObserverContext, Store, InternalScanner, FlushLifeCycleTracker)",
"preFlushScannerOpen(ObserverContext, Store, ScanOptions, FlushLifeCycleTracker)",
"preMemStoreCompaction(ObserverContext, Store)",
"preMemStoreCompactionCompact(ObserverContext, Store, InternalScanner)",
"preMemStoreCompactionCompactScannerOpen(ObserverContext, Store, ScanOptions)",
"prePrepareTimeStampForDeleteVersion(ObserverContext, Mutation, Cell, byte[], Get)",
"preStoreFileReaderOpen(ObserverContext, FileSystem, Path, FSDataInputStreamWrapper, long, CacheConfig, Reference, StoreFileReader)",
"preStoreScannerOpen(ObserverContext, Store, ScanOptions)",

// --- WAL, replication, and master lifecycle hooks ---
// Triggered by HBase internals (WAL writers, replication pipeline, master startup),
// not by user requests.
"preMasterInitialization(ObserverContext)",
"preMasterStoreFlush(ObserverContext)",
"preReplayWALs(ObserverContext, RegionInfo, Path)",
"preWALAppend(ObserverContext, WALKey, WALEdit)",
"preWALRestore(ObserverContext, RegionInfo, WALKey, WALEdit)",
"preReplicationSinkBatchMutate(ObserverContext, WALEntry, Mutation)",

// --- RSGroup management ---
// RSGroups are an optional HBase feature for grouping RegionServers. Not yet
// implemented.
// All RSGroup operations should require ADMIN when implemented.
"preAddRSGroup(ObserverContext, String)",
"preRemoveRSGroup(ObserverContext, String)",
"preBalanceRSGroup(ObserverContext, String, BalanceRequest)",
"preGetRSGroupInfo(ObserverContext, String)",
"preGetRSGroupInfoOfServer(ObserverContext, Address)",
"preGetRSGroupInfoOfTable(ObserverContext, TableName)",
"preListRSGroups(ObserverContext)",
"preMoveServers(ObserverContext, Set, String)",
"preMoveServersAndTables(ObserverContext, Set, Set, String)",
"preMoveTables(ObserverContext, Set, String)",
"preRemoveServers(ObserverContext, Set)",
"preRenameRSGroup(ObserverContext, String, String)",
"preUpdateRSGroupConfig(ObserverContext, String, Map)",

// --- TODO: genuine gaps that need OPA implementation ---
// These pre-hooks are user-facing and should enforce OPA permissions, but are not yet
// implemented. They are excluded here to keep this test focused on detecting new
// upstream methods; the implementation gaps are tracked separately in plan.md.
//
// Metadata listing hooks: getTableNames is covered post-hoc by postGetTableNames
// filtering; listNamespace* hooks are not currently enforced.
"preGetTableNames(ObserverContext, List, String)",
"preListNamespaceDescriptors(ObserverContext, List)",
"preListNamespaces(ObserverContext, List)",
// Cluster metrics: currently unenforced; reference AC requires ADMIN.
"preGetClusterMetrics(ObserverContext)"));

@Test
public void testAllObserverMethodsAreExplicitlyOverridden() {
Set<String> interfaceMethodSigs =
Arrays.stream(OBSERVER_INTERFACES)
.flatMap(iface -> Arrays.stream(iface.getDeclaredMethods()))
.filter(m -> !m.isSynthetic() && !Modifier.isStatic(m.getModifiers()))
.map(TestCoprocessorInterfaceCoverage::signature)
.collect(Collectors.toSet());

Set<String> ourMethodSigs =
Arrays.stream(OpenPolicyAgentAccessController.class.getDeclaredMethods())
.filter(m -> !m.isSynthetic())
.map(TestCoprocessorInterfaceCoverage::signature)
.collect(Collectors.toSet());

List<String> unhandled =
interfaceMethodSigs.stream()
.filter(sig -> !sig.startsWith("post")) // post hooks can never block operations
.filter(sig -> !EXCLUDED.contains(sig))
.filter(sig -> !ourMethodSigs.contains(sig))
.sorted()
.collect(Collectors.toList());

assertTrue(
"Observer interface pre-hooks found that are neither overridden nor in the exclusion list"
+ " — review each and either implement it or add it to EXCLUDED with a justification:\n"
+ String.join("\n", unhandled),
unhandled.isEmpty());
}

private static String signature(Method m) {
String params =
Arrays.stream(m.getParameterTypes())
.map(Class::getSimpleName)
.collect(Collectors.joining(", "));
return m.getName() + "(" + params + ")";
}
}
Loading