From 602909f9b5cc55da205f15a72daca7a446cf2541 Mon Sep 17 00:00:00 2001 From: libo Date: Wed, 29 Jul 2026 22:04:09 +0800 Subject: [PATCH] Audit user role membership changes --- .../iotdb/db/i18n/DataNodeMiscMessages.java | 2 + .../iotdb/db/i18n/DataNodeMiscMessages.java | 2 + .../UserRoleModificationAuditContext.java | 99 +++++++++++ .../thrift/impl/ClientRPCServiceImpl.java | 45 ++++- .../UserRoleModificationAuditContextTest.java | 154 ++++++++++++++++++ .../iotdb/commons/audit/AuditEventType.java | 1 + 6 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 iotdb-core/datanode/src/main/java/org/apache/iotdb/db/audit/UserRoleModificationAuditContext.java create mode 100644 iotdb-core/datanode/src/test/java/org/apache/iotdb/db/audit/UserRoleModificationAuditContextTest.java diff --git a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java index b43037206ccdc..3154a3f621bee 100644 --- a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java +++ b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java @@ -121,6 +121,8 @@ public final class DataNodeMiscMessages { public static final String CREATE_NEW_REGION_ERROR_FMT = "create new region %s error, exception:%s"; public static final String CREATE_NEW_REGION_SUCCEED_FMT = "create new region %s succeed"; + public static final String LOG_USER_ARG_ROLE_ARG_422D48D3 = "user: %s, role: %s"; + private DataNodeMiscMessages() {} // --------------------------------------------------------------------------- diff --git a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java index a319bce5000e6..c1cc841637e4f 100644 --- a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java +++ b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java @@ -121,6 +121,8 @@ public final class DataNodeMiscMessages { public static final String CREATE_NEW_REGION_ERROR_FMT = "创建新 region %s 错误,异常:%s"; public static final String CREATE_NEW_REGION_SUCCEED_FMT = "创建新 region %s 成功"; + public static final String LOG_USER_ARG_ROLE_ARG_422D48D3 = "用户:%s,角色:%s"; + private DataNodeMiscMessages() {} // --------------------------------------------------------------------------- diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/audit/UserRoleModificationAuditContext.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/audit/UserRoleModificationAuditContext.java new file mode 100644 index 0000000000000..38999655fa3cd --- /dev/null +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/audit/UserRoleModificationAuditContext.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.iotdb.db.audit; + +import org.apache.iotdb.common.rpc.thrift.TSStatus; +import org.apache.iotdb.commons.audit.AuditEventType; +import org.apache.iotdb.commons.audit.AuditLogFields; +import org.apache.iotdb.commons.audit.AuditLogOperation; +import org.apache.iotdb.commons.auth.entity.PrivilegeType; +import org.apache.iotdb.db.i18n.DataNodeMiscMessages; +import org.apache.iotdb.db.protocol.session.IClientSession; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.RelationalAuthorStatement; +import org.apache.iotdb.db.queryengine.plan.relational.type.AuthorRType; +import org.apache.iotdb.db.queryengine.plan.statement.AuthorType; +import org.apache.iotdb.db.queryengine.plan.statement.sys.AuthorStatement; +import org.apache.iotdb.rpc.TSStatusCode; + +/** Tracks one user-role membership statement and logs a successful modification. */ +public class UserRoleModificationAuditContext { + + private final DNAuditLogger auditLogger; + private final String sqlString; + + private IClientSession clientSession; + private String targetUsername; + private String targetRoleName; + + public UserRoleModificationAuditContext(String sqlString) { + this(DNAuditLogger.getInstance(), sqlString); + } + + UserRoleModificationAuditContext(DNAuditLogger auditLogger, String sqlString) { + this.auditLogger = auditLogger; + this.sqlString = sqlString; + } + + public void setClientSession(IClientSession clientSession) { + this.clientSession = clientSession; + } + + public void track(AuthorStatement statement) { + if (statement.getAuthorType() == AuthorType.GRANT_USER_ROLE + || statement.getAuthorType() == AuthorType.REVOKE_USER_ROLE) { + targetUsername = statement.getUserName(); + targetRoleName = statement.getRoleName(); + } + } + + public void track(RelationalAuthorStatement statement) { + if (statement.getAuthorType() == AuthorRType.GRANT_USER_ROLE + || statement.getAuthorType() == AuthorRType.REVOKE_USER_ROLE) { + targetUsername = statement.getUserName(); + targetRoleName = statement.getRoleName(); + } + } + + public void log(TSStatus status) { + if (targetUsername == null || clientSession == null || !isSuccessful(status)) { + return; + } + auditLogger.log( + new AuditLogFields( + clientSession.getUserId(), + clientSession.getUsername(), + clientSession.getClientAddress(), + AuditEventType.MODIFY_USER_ROLE, + AuditLogOperation.CONTROL, + PrivilegeType.SECURITY, + true, + clientSession.getDatabaseName(), + sqlString), + () -> + String.format( + DataNodeMiscMessages.LOG_USER_ARG_ROLE_ARG_422D48D3, + targetUsername, + targetRoleName)); + } + + private static boolean isSuccessful(TSStatus status) { + return status != null && status.getCode() == TSStatusCode.SUCCESS_STATUS.getStatusCode(); + } +} diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/ClientRPCServiceImpl.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/ClientRPCServiceImpl.java index ea67ebb8523df..b6959ca87b92e 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/ClientRPCServiceImpl.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/ClientRPCServiceImpl.java @@ -59,6 +59,7 @@ import org.apache.iotdb.commons.queryengine.plan.relational.sql.parser.ParsingException; import org.apache.iotdb.commons.utils.PathUtils; import org.apache.iotdb.db.audit.DNAuditLogger; +import org.apache.iotdb.db.audit.UserRoleModificationAuditContext; import org.apache.iotdb.db.auth.AuthorityChecker; import org.apache.iotdb.db.conf.IoTDBConfig; import org.apache.iotdb.db.conf.IoTDBDescriptor; @@ -105,6 +106,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.security.TreeAccessCheckContext; import org.apache.iotdb.db.queryengine.plan.relational.sql.ParameterExtractor; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Execute; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.RelationalAuthorStatement; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.SetSqlDialect; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Use; import org.apache.iotdb.db.queryengine.plan.relational.sql.parser.SqlParser; @@ -130,6 +132,7 @@ import org.apache.iotdb.db.queryengine.plan.statement.metadata.template.SetSchemaTemplateStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.template.UnsetSchemaTemplateStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.view.CreateTableViewStatement; +import org.apache.iotdb.db.queryengine.plan.statement.sys.AuthorStatement; import org.apache.iotdb.db.queryengine.plan.statement.sys.SetSqlDialectStatement; import org.apache.iotdb.db.schemaengine.SchemaEngine; import org.apache.iotdb.db.schemaengine.schemaregion.ISchemaRegion; @@ -329,7 +332,25 @@ public ClientRPCServiceImpl() { private TSExecuteStatementResp executeStatementInternal( NativeStatementRequest request, SelectResult setResult) { + UserRoleModificationAuditContext userRoleModificationAuditContext = + new UserRoleModificationAuditContext(request.getSql()); + TSExecuteStatementResp response = null; + try { + response = + executeStatementInternalWithoutUserRoleModificationAudit( + request, setResult, userRoleModificationAuditContext); + return response; + } finally { + userRoleModificationAuditContext.log(response == null ? null : response.getStatus()); + } + } + + private TSExecuteStatementResp executeStatementInternalWithoutUserRoleModificationAudit( + NativeStatementRequest request, + SelectResult setResult, + UserRoleModificationAuditContext userRoleModificationAuditContext) { IClientSession clientSession = SESSION_MANAGER.getCurrSessionAndUpdateIdleTime(); + userRoleModificationAuditContext.setClientSession(clientSession); if (!SESSION_MANAGER.checkLogin(clientSession)) { return RpcUtils.getTSExecuteStatementResp(getNotLoggedInStatus()); } @@ -353,6 +374,9 @@ private TSExecuteStatementResp executeStatementInternal( ExecutionResult result; if (clientSession.getSqlDialect() == SqlDialect.TREE) { Statement s = request.getTreeStatement(clientSession.getZoneId()); + if (s instanceof AuthorStatement) { + userRoleModificationAuditContext.track((AuthorStatement) s); + } if (s instanceof SetSqlDialectStatement) { setSqlDialect = true; } @@ -426,6 +450,9 @@ private TSExecuteStatementResp executeStatementInternal( } else { org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Statement s = request.getTableStatement(relationSqlParser, clientSession.getZoneId(), clientSession); + if (s instanceof RelationalAuthorStatement) { + userRoleModificationAuditContext.track((RelationalAuthorStatement) s); + } if (s instanceof Use) { useDatabase = true; @@ -2144,6 +2171,10 @@ public TSStatus executeBatchStatement(TSExecuteBatchStatementReq req) { try { for (int i = 0; i < req.getStatements().size(); i++) { String statement = req.getStatements().get(i); + UserRoleModificationAuditContext userRoleModificationAuditContext = + new UserRoleModificationAuditContext(statement); + userRoleModificationAuditContext.setClientSession(clientSession); + TSStatus statementStatus = null; long t2 = System.nanoTime(); String type = null; OperationQuota quota = null; @@ -2156,6 +2187,9 @@ public TSStatus executeBatchStatement(TSExecuteBatchStatementReq req) { return RpcUtils.getStatus( TSStatusCode.EXECUTE_STATEMENT_ERROR, "This operation type is not supported"); } + if (s instanceof AuthorStatement) { + userRoleModificationAuditContext.track((AuthorStatement) s); + } if (s instanceof CreateTableViewStatement) { result = @@ -2223,6 +2257,9 @@ public TSStatus executeBatchStatement(TSExecuteBatchStatementReq req) { org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Statement s = relationSqlParser.createStatement( statement, clientSession.getZoneId(), clientSession); + if (s instanceof RelationalAuthorStatement) { + userRoleModificationAuditContext.track((RelationalAuthorStatement) s); + } if (s instanceof Use) { useDatabase = true; @@ -2266,15 +2303,17 @@ public TSStatus executeBatchStatement(TSExecuteBatchStatementReq req) { } } - results.add(result.status); + statementStatus = result.status; + results.add(statementStatus); } catch (Exception e) { LOGGER.warn(DataNodeMiscMessages.ERROR_EXECUTING_BATCH_STATEMENT, e); - TSStatus status = + statementStatus = onQueryException( e, "\"" + statement + "\". " + OperationType.EXECUTE_BATCH_STATEMENT); isAllSuccessful = false; - results.add(status); + results.add(statementStatus); } finally { + userRoleModificationAuditContext.log(statementStatus); CommonUtils.addStatementExecutionLatency( OperationType.EXECUTE_STATEMENT, type, System.nanoTime() - t2); if (quota != null) { diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/audit/UserRoleModificationAuditContextTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/audit/UserRoleModificationAuditContextTest.java new file mode 100644 index 0000000000000..f9ba1f8ec17cf --- /dev/null +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/audit/UserRoleModificationAuditContextTest.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.iotdb.db.audit; + +import org.apache.iotdb.commons.audit.AuditEventType; +import org.apache.iotdb.commons.audit.AuditLogOperation; +import org.apache.iotdb.commons.audit.IAuditEntity; +import org.apache.iotdb.commons.auth.entity.PrivilegeType; +import org.apache.iotdb.db.protocol.session.IClientSession; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.RelationalAuthorStatement; +import org.apache.iotdb.db.queryengine.plan.relational.type.AuthorRType; +import org.apache.iotdb.db.queryengine.plan.statement.AuthorType; +import org.apache.iotdb.db.queryengine.plan.statement.sys.AuthorStatement; +import org.apache.iotdb.rpc.RpcUtils; +import org.apache.iotdb.rpc.TSStatusCode; + +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class UserRoleModificationAuditContextTest { + + @Test + public void testTreeGrantRoleSuccess() { + DNAuditLogger auditLogger = mock(DNAuditLogger.class); + UserRoleModificationAuditContext context = + new UserRoleModificationAuditContext(auditLogger, "GRANT ROLE role1 TO user1"); + context.setClientSession(mockSession()); + AuthorStatement statement = new AuthorStatement(AuthorType.GRANT_USER_ROLE); + statement.setUserName("user1"); + statement.setRoleName("role1"); + + context.track(statement); + context.log(RpcUtils.SUCCESS_STATUS); + + assertAuditLog(auditLogger, "GRANT ROLE role1 TO user1"); + } + + @Test + public void testTableRevokeRoleSuccess() { + DNAuditLogger auditLogger = mock(DNAuditLogger.class); + UserRoleModificationAuditContext context = + new UserRoleModificationAuditContext(auditLogger, "REVOKE ROLE role1 FROM user1"); + context.setClientSession(mockSession()); + RelationalAuthorStatement statement = + new RelationalAuthorStatement(AuthorRType.REVOKE_USER_ROLE); + statement.setUserName("user1"); + statement.setRoleName("role1"); + + context.track(statement); + context.log(RpcUtils.SUCCESS_STATUS); + + assertAuditLog(auditLogger, "REVOKE ROLE role1 FROM user1"); + } + + @Test + public void testFailedModificationIsIgnored() { + DNAuditLogger auditLogger = mock(DNAuditLogger.class); + UserRoleModificationAuditContext context = + new UserRoleModificationAuditContext(auditLogger, "grant role"); + context.setClientSession(mockSession()); + AuthorStatement statement = new AuthorStatement(AuthorType.GRANT_USER_ROLE); + statement.setUserName("user1"); + statement.setRoleName("role1"); + context.track(statement); + + context.log(RpcUtils.getStatus(TSStatusCode.NO_PERMISSION)); + + verify(auditLogger, never()).log(any(), any()); + } + + @Test + public void testRedirectedModificationIsIgnored() { + DNAuditLogger auditLogger = mock(DNAuditLogger.class); + UserRoleModificationAuditContext context = + new UserRoleModificationAuditContext(auditLogger, "grant role"); + context.setClientSession(mockSession()); + AuthorStatement statement = new AuthorStatement(AuthorType.GRANT_USER_ROLE); + statement.setUserName("user1"); + statement.setRoleName("role1"); + context.track(statement); + + context.log(RpcUtils.getStatus(TSStatusCode.REDIRECTION_RECOMMEND)); + + verify(auditLogger, never()).log(any(), any()); + } + + @Test + public void testPrivilegeGrantIsIgnored() { + DNAuditLogger auditLogger = mock(DNAuditLogger.class); + UserRoleModificationAuditContext context = + new UserRoleModificationAuditContext(auditLogger, "grant privilege"); + context.setClientSession(mockSession()); + context.track(new AuthorStatement(AuthorType.GRANT_USER)); + + context.log(RpcUtils.SUCCESS_STATUS); + + verify(auditLogger, never()).log(any(), any()); + } + + private static IClientSession mockSession() { + IClientSession session = mock(IClientSession.class); + when(session.getUserId()).thenReturn(7L); + when(session.getUsername()).thenReturn("operator"); + when(session.getClientAddress()).thenReturn("127.0.0.1"); + when(session.getDatabaseName()).thenReturn("database"); + return session; + } + + @SuppressWarnings("unchecked") + private static void assertAuditLog(DNAuditLogger auditLogger, String sql) { + ArgumentCaptor entityCaptor = ArgumentCaptor.forClass(IAuditEntity.class); + ArgumentCaptor> logCaptor = ArgumentCaptor.forClass(Supplier.class); + verify(auditLogger).log(entityCaptor.capture(), logCaptor.capture()); + + IAuditEntity entity = entityCaptor.getValue(); + assertEquals(7L, entity.getUserId()); + assertEquals("operator", entity.getUsername()); + assertEquals("127.0.0.1", entity.getCliHostname()); + assertEquals(AuditEventType.MODIFY_USER_ROLE, entity.getAuditEventType()); + assertEquals(AuditLogOperation.CONTROL, entity.getAuditLogOperation()); + assertEquals(PrivilegeType.SECURITY, entity.getPrivilegeTypes().get(0)); + assertTrue(entity.getResult()); + assertEquals("database", entity.getDatabase()); + assertEquals(sql, entity.getSqlString()); + assertEquals("user: user1, role: role1", logCaptor.getValue().get()); + } +} diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/audit/AuditEventType.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/audit/AuditEventType.java index 510912a302fdc..4d8eb9c550e22 100644 --- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/audit/AuditEventType.java +++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/audit/AuditEventType.java @@ -37,6 +37,7 @@ public enum AuditEventType { LOGIN_FINAL, MODIFY_SECURITY_OPTIONS, MODIFY_DEFAULT_SECURITY_VALUES, + MODIFY_USER_ROLE, REVOKE_FAILED, GRANT_ROLE_FAILED, LOGIN_RESOURCE_RESTRICT,