From 68d00170211b42b02497002340712c892c0ab0ec Mon Sep 17 00:00:00 2001 From: "hanyu.liang" Date: Sun, 15 Mar 2026 20:41:22 -0700 Subject: [PATCH] [identity]: add generic external tenant resource isolation framework Implement a generic framework for external services (ZCF, AIOS, etc.) to isolate resources by tenant via HTTP headers (X-Tenant-Source/Id/User). - ExternalTenantContext: ThreadLocal DTO bridging REST layer to AOP layer - ExternalTenantProvider: SPI interface for tenant validation and tracking - ExternalTenantResourceRefVO: JPA entity with CASCADE delete on ResourceVO - ExternalTenantResourceTracker: AOP-driven resource binding on creation, cleanup on hard/soft delete via extension points - ExternalTenantZQLExtension: two-phase ZQL AST injection for automatic tenant-scoped query filtering with SQL injection protection - RestServer integration: header parsing, provider validation, ThreadLocal lifecycle management with try/finally cleanup - SessionInventory: @APINoSee externalTenantContext field + hasExternalTenant() - DB migration: V5.5.1__schema.sql for ExternalTenantResourceRefVO table Resolves: ZCF-1147 Co-Authored-By: Claude Opus 4.6 --- conf/db/upgrade/V5.4.8__schema.sql | 22 ++- conf/persistence.xml | 1 + conf/springConfigXml/AccountManager.xml | 16 ++ .../aspect/OwnedByAccountAspectHelper.java | 28 +++ .../identity/ExternalTenantContext.java | 70 +++++++ .../identity/ExternalTenantProvider.java | 52 +++++ .../ExternalTenantResourceRefInventory.java | 122 ++++++++++++ .../identity/ExternalTenantResourceRefVO.java | 131 +++++++++++++ .../ExternalTenantResourceRefVO_.java | 21 ++ .../header/identity/SessionInventory.java | 14 ++ .../zstack/identity/AccountManagerImpl.java | 1 + .../zstack/identity/AuthorizationManager.java | 33 +++- .../ExternalTenantResourceTracker.java | 182 ++++++++++++++++++ .../identity/ExternalTenantZQLExtension.java | 98 ++++++++++ .../java/org/zstack/rest/RestConstants.java | 4 + .../main/java/org/zstack/rest/RestServer.java | 50 +++++ 16 files changed, 843 insertions(+), 2 deletions(-) create mode 100644 header/src/main/java/org/zstack/header/identity/ExternalTenantContext.java create mode 100644 header/src/main/java/org/zstack/header/identity/ExternalTenantProvider.java create mode 100644 header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefInventory.java create mode 100644 header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefVO.java create mode 100644 header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefVO_.java create mode 100644 identity/src/main/java/org/zstack/identity/ExternalTenantResourceTracker.java create mode 100644 identity/src/main/java/org/zstack/identity/ExternalTenantZQLExtension.java diff --git a/conf/db/upgrade/V5.4.8__schema.sql b/conf/db/upgrade/V5.4.8__schema.sql index 25979794136..6eaf085abbd 100644 --- a/conf/db/upgrade/V5.4.8__schema.sql +++ b/conf/db/upgrade/V5.4.8__schema.sql @@ -30,4 +30,24 @@ CREATE TABLE IF NOT EXISTS `zstack`.`ResNotifyWebhookRefVO` ( PRIMARY KEY (`uuid`), CONSTRAINT `fk_ResNotifyWebhookRefVO_ResNotifySubscriptionVO` FOREIGN KEY (`uuid`) REFERENCES `ResNotifySubscriptionVO`(`uuid`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `zstack`.`ExternalTenantResourceRefVO` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `source` VARCHAR(64) NOT NULL COMMENT 'source service identifier (zcf, svcX, ...)', + `tenantId` VARCHAR(128) NOT NULL COMMENT 'external tenant identifier', + `userId` VARCHAR(128) DEFAULT NULL COMMENT 'external user identifier (optional)', + `resourceUuid` VARCHAR(32) NOT NULL COMMENT 'resource UUID', + `resourceType` VARCHAR(256) NOT NULL COMMENT 'resource type (VO SimpleName)', + `accountUuid` VARCHAR(32) NOT NULL COMMENT 'associated ZStack Account', + `createDate` TIMESTAMP NOT NULL DEFAULT '0000-00-00 00:00:00', + `lastOpDate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_source_tenant_user (`source`, `tenantId`, `userId`), + INDEX idx_source_tenant_resource (`source`, `tenantId`, `resourceUuid`), + INDEX idx_resource (`resourceUuid`), + UNIQUE KEY uk_resource_source_tenant (`resourceUuid`, `source`, `tenantId`), + CONSTRAINT fk_ext_tenant_resource FOREIGN KEY (`resourceUuid`) + REFERENCES `ResourceVO`(`uuid`) ON DELETE CASCADE, + CONSTRAINT fk_ext_tenant_account FOREIGN KEY (`accountUuid`) + REFERENCES `AccountVO`(`uuid`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/conf/persistence.xml b/conf/persistence.xml index 67362864053..1ab846ddb65 100755 --- a/conf/persistence.xml +++ b/conf/persistence.xml @@ -90,6 +90,7 @@ org.zstack.header.identity.SessionVO org.zstack.header.identity.AccountVO org.zstack.header.identity.AccountResourceRefVO + org.zstack.header.identity.ExternalTenantResourceRefVO org.zstack.header.identity.UserVO org.zstack.header.identity.PolicyVO org.zstack.header.identity.UserPolicyRefVO diff --git a/conf/springConfigXml/AccountManager.xml b/conf/springConfigXml/AccountManager.xml index 79df4f3c2fe..2430e86656e 100755 --- a/conf/springConfigXml/AccountManager.xml +++ b/conf/springConfigXml/AccountManager.xml @@ -111,5 +111,21 @@ + + + + + + + + + + + + + + + + diff --git a/header/src/main/java/org/zstack/header/aspect/OwnedByAccountAspectHelper.java b/header/src/main/java/org/zstack/header/aspect/OwnedByAccountAspectHelper.java index 0c6b8cd1358..b25a78eecbd 100644 --- a/header/src/main/java/org/zstack/header/aspect/OwnedByAccountAspectHelper.java +++ b/header/src/main/java/org/zstack/header/aspect/OwnedByAccountAspectHelper.java @@ -6,8 +6,27 @@ import org.zstack.header.vo.ResourceTypeMetadata; import javax.persistence.EntityManager; +import java.util.Collections; +import java.util.List; public class OwnedByAccountAspectHelper { + + /** + * Extension point for receiving notifications when resource ownership is created. + * Implementations should be registered via PluginRegistry (Spring XML). + */ + public static interface ResourceOwnershipCreationNotifier { + void notifyResourceOwnershipCreated(AccountResourceRefVO ref, EntityManager entityManager); + } + + // Notifiers populated by PluginRegistry; set once during Component.start() phase. + // Using static field because AOP aspects cannot participate in Spring DI. + private static volatile List notifiers = Collections.emptyList(); + + public static void setResourceOwnershipCreationNotifiers(List list) { + notifiers = list != null ? list : Collections.emptyList(); + } + public static void createAccountResourceRefVO(OwnedByAccount oa, EntityManager entityManager, Object entity) { AccountResourceRefVO ref = new AccountResourceRefVO(); ref.setAccountUuid(oa.getAccountUuid()); @@ -19,5 +38,14 @@ public static void createAccountResourceRefVO(OwnedByAccount oa, EntityManager e ref.setShared(false); entityManager.persist(ref); + + // Notify all registered listeners — no try-catch so exceptions propagate + // and the outer transaction rolls back, ensuring strong consistency between + // AccountResourceRefVO and ExternalTenantResourceRefVO. + // Pass the EntityManager so listeners persist within the same flush/TX context, + // avoiding nested @DeadlockAutoRestart scopes that cause unretryable rollbacks. + for (ResourceOwnershipCreationNotifier n : notifiers) { + n.notifyResourceOwnershipCreated(ref, entityManager); + } } } diff --git a/header/src/main/java/org/zstack/header/identity/ExternalTenantContext.java b/header/src/main/java/org/zstack/header/identity/ExternalTenantContext.java new file mode 100644 index 00000000000..4cf7530a7fd --- /dev/null +++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantContext.java @@ -0,0 +1,70 @@ +package org.zstack.header.identity; + +import java.io.Serializable; + +/** + * External tenant context DTO. + * Passed by external services (like ZCF, AIOS, etc.) through HTTP Headers, + * attached to SessionInventory throughout the entire request chain. + */ +public class ExternalTenantContext implements Serializable { + private static final long serialVersionUID = 1L; + + // ThreadLocal used to pass current request's external tenant context at AOP level + // Set by RestServer after Header parsing, cleaned up after request completion + private static final ThreadLocal current = new ThreadLocal<>(); + + public static void setCurrent(ExternalTenantContext ctx) { + current.set(ctx); + } + + public static ExternalTenantContext getCurrent() { + return current.get(); + } + + public static void clearCurrent() { + current.remove(); + } + + private String source; // Source service identifier, such as "zcf", "svcX" + private String tenantId; // External tenant identifier + private String userId; // External user identifier (optional) + + public ExternalTenantContext() { + } + + public ExternalTenantContext(String source, String tenantId, String userId) { + this.source = source; + this.tenantId = tenantId; + this.userId = userId; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + @Override + public String toString() { + return String.format("ExternalTenantContext{source='%s', tenantId='%s', userId='%s'}", source, tenantId, userId); + } +} diff --git a/header/src/main/java/org/zstack/header/identity/ExternalTenantProvider.java b/header/src/main/java/org/zstack/header/identity/ExternalTenantProvider.java new file mode 100644 index 00000000000..5a3fb074b74 --- /dev/null +++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantProvider.java @@ -0,0 +1,52 @@ +package org.zstack.header.identity; + +/** + * External tenant Provider SPI. + * Each external service (ZCF, AIOS, etc.) implements this interface to integrate with the universal tenant resource isolation framework. + * + * The framework automatically collects all implementations through {@link org.zstack.core.componentloader.PluginRegistry}. + * Each Provider returns a unique source identifier (such as "zcf") through {@link #getSource()}, + * corresponding to the HTTP Header X-Tenant-Source value. + */ +public interface ExternalTenantProvider { + /** + * Source identifier, such as "zcf", "svcX". + * Corresponds to X-Tenant-Source header value. + * Must be globally unique. + */ + String getSource(); + + /** + * Validate tenant context validity. + * Called after RestServer parses Header and before injecting into Session. + * Throwing an exception indicates validation failure, and the request will be rejected. + * + * @param ctx External tenant context (already parsed from Header) + */ + void validateTenant(ExternalTenantContext ctx); + + /** + * Whether to track this type of resource. + * After resource creation, the framework calls this method to decide whether to write to ExternalTenantResourceRefVO. + * Returning false indicates that this resource type does not need to be associated with tenant. + * Default is true (track all resources). + * + * @param resourceType Resource type (VO SimpleName, such as "VmInstanceVO") + */ + default boolean shouldTrackResource(String resourceType) { + return true; + } + + /** + * Resource binding callback (optional). + * Called after ExternalTenantResourceRefVO is written, + * Provider can use this for custom logic such as sending notifications or writing audit logs. + * + * @param ctx External tenant context + * @param resourceUuid Resource UUID + * @param resourceType Resource type (VO SimpleName) + */ + default void onResourceBound(ExternalTenantContext ctx, + String resourceUuid, String resourceType) { + } +} diff --git a/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefInventory.java b/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefInventory.java new file mode 100644 index 00000000000..f0be3f065ae --- /dev/null +++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefInventory.java @@ -0,0 +1,122 @@ +package org.zstack.header.identity; + +import org.zstack.header.configuration.PythonClassInventory; +import org.zstack.header.search.Inventory; + +import java.sql.Timestamp; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Inventory for ExternalTenantResourceRefVO + */ +@PythonClassInventory +@Inventory(mappingVOClass = ExternalTenantResourceRefVO.class) +public class ExternalTenantResourceRefInventory { + private long id; + private String source; + private String tenantId; + private String userId; + private String resourceUuid; + private String accountUuid; + private String resourceType; + private Timestamp createDate; + private Timestamp lastOpDate; + + public ExternalTenantResourceRefInventory() { + } + + public static List valueOf(Collection vos) { + return vos.stream().map(ExternalTenantResourceRefInventory::valueOf) + .collect(Collectors.toList()); + } + + public static ExternalTenantResourceRefInventory valueOf(ExternalTenantResourceRefVO vo) { + return new ExternalTenantResourceRefInventory(vo); + } + + public ExternalTenantResourceRefInventory(ExternalTenantResourceRefVO vo) { + this.id = vo.getId(); + this.source = vo.getSource(); + this.tenantId = vo.getTenantId(); + this.userId = vo.getUserId(); + this.resourceUuid = vo.getResourceUuid(); + this.accountUuid = vo.getAccountUuid(); + this.resourceType = vo.getResourceType(); + this.createDate = vo.getCreateDate(); + this.lastOpDate = vo.getLastOpDate(); + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getResourceUuid() { + return resourceUuid; + } + + public void setResourceUuid(String resourceUuid) { + this.resourceUuid = resourceUuid; + } + + public String getAccountUuid() { + return accountUuid; + } + + public void setAccountUuid(String accountUuid) { + this.accountUuid = accountUuid; + } + + public String getResourceType() { + return resourceType; + } + + public void setResourceType(String resourceType) { + this.resourceType = resourceType; + } + + public Timestamp getCreateDate() { + return createDate; + } + + public void setCreateDate(Timestamp createDate) { + this.createDate = createDate; + } + + public Timestamp getLastOpDate() { + return lastOpDate; + } + + public void setLastOpDate(Timestamp lastOpDate) { + this.lastOpDate = lastOpDate; + } +} diff --git a/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefVO.java b/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefVO.java new file mode 100644 index 00000000000..7e5972842bb --- /dev/null +++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefVO.java @@ -0,0 +1,131 @@ +package org.zstack.header.identity; + +import org.zstack.header.vo.EntityGraph; +import org.zstack.header.vo.ForeignKey; +import org.zstack.header.vo.ForeignKey.ReferenceOption; +import org.zstack.header.vo.Index; +import org.zstack.header.vo.ResourceVO; + +import javax.persistence.*; +import java.sql.Timestamp; + +@Entity +@Table +@EntityGraph( + friends = { + @EntityGraph.Neighbour(type = AccountVO.class, myField = "accountUuid", targetField = "uuid"), + @EntityGraph.Neighbour(type = ResourceVO.class, myField = "resourceUuid", targetField = "uuid") + } +) +public class ExternalTenantResourceRefVO { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column + private long id; + + @Column + @Index + private String source; + + @Column + @Index + private String tenantId; + + @Column + private String userId; + + @Column + @ForeignKey(parentEntityClass = ResourceVO.class, parentKey = "uuid", onDeleteAction = ReferenceOption.CASCADE) + @Index + private String resourceUuid; + + @Column + @ForeignKey(parentEntityClass = AccountVO.class, parentKey = "uuid", onDeleteAction = ReferenceOption.CASCADE) + private String accountUuid; + + @Column + private String resourceType; + + @Column + private Timestamp createDate; + + @Column + private Timestamp lastOpDate; + + @PreUpdate + private void preUpdate() { + lastOpDate = null; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getResourceUuid() { + return resourceUuid; + } + + public void setResourceUuid(String resourceUuid) { + this.resourceUuid = resourceUuid; + } + + public String getAccountUuid() { + return accountUuid; + } + + public void setAccountUuid(String accountUuid) { + this.accountUuid = accountUuid; + } + + public String getResourceType() { + return resourceType; + } + + public void setResourceType(String resourceType) { + this.resourceType = resourceType; + } + + public Timestamp getCreateDate() { + return createDate; + } + + public void setCreateDate(Timestamp createDate) { + this.createDate = createDate; + } + + public Timestamp getLastOpDate() { + return lastOpDate; + } + + public void setLastOpDate(Timestamp lastOpDate) { + this.lastOpDate = lastOpDate; + } +} diff --git a/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefVO_.java b/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefVO_.java new file mode 100644 index 00000000000..911fd9e0303 --- /dev/null +++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefVO_.java @@ -0,0 +1,21 @@ +package org.zstack.header.identity; + +import java.sql.Timestamp; +import javax.persistence.metamodel.SingularAttribute; +import javax.persistence.metamodel.StaticMetamodel; + +/** + * JPA Metamodel for ExternalTenantResourceRefVO + */ +@StaticMetamodel(ExternalTenantResourceRefVO.class) +public class ExternalTenantResourceRefVO_ { + public static volatile SingularAttribute id; + public static volatile SingularAttribute source; + public static volatile SingularAttribute tenantId; + public static volatile SingularAttribute userId; + public static volatile SingularAttribute resourceUuid; + public static volatile SingularAttribute accountUuid; + public static volatile SingularAttribute resourceType; + public static volatile SingularAttribute createDate; + public static volatile SingularAttribute lastOpDate; +} diff --git a/header/src/main/java/org/zstack/header/identity/SessionInventory.java b/header/src/main/java/org/zstack/header/identity/SessionInventory.java index f79549f7788..41eb185b6f1 100644 --- a/header/src/main/java/org/zstack/header/identity/SessionInventory.java +++ b/header/src/main/java/org/zstack/header/identity/SessionInventory.java @@ -18,6 +18,8 @@ public class SessionInventory implements Serializable { private Timestamp createDate; @APINoSee private boolean noSessionEvaluation; + @APINoSee + private ExternalTenantContext externalTenantContext; public static SessionInventory valueOf(SessionVO vo) { SessionInventory inv = new SessionInventory(); @@ -92,4 +94,16 @@ public String getUserType() { public void setUserType(String userType) { this.userType = userType; } + + public ExternalTenantContext getExternalTenantContext() { + return externalTenantContext; + } + + public void setExternalTenantContext(ExternalTenantContext externalTenantContext) { + this.externalTenantContext = externalTenantContext; + } + + public boolean hasExternalTenant() { + return externalTenantContext != null && externalTenantContext.getTenantId() != null; + } } diff --git a/identity/src/main/java/org/zstack/identity/AccountManagerImpl.java b/identity/src/main/java/org/zstack/identity/AccountManagerImpl.java index c00a6e11dd2..49e2bda94f5 100755 --- a/identity/src/main/java/org/zstack/identity/AccountManagerImpl.java +++ b/identity/src/main/java/org/zstack/identity/AccountManagerImpl.java @@ -29,6 +29,7 @@ import org.zstack.header.errorcode.SysErrors; import org.zstack.header.exception.CloudRuntimeException; import org.zstack.header.identity.*; +import org.zstack.utils.function.ForEachFunction; import org.zstack.header.identity.Quota.QuotaPair; import org.zstack.header.identity.quota.QuotaDefinition; import org.zstack.header.identity.quota.QuotaMessageHandler; diff --git a/identity/src/main/java/org/zstack/identity/AuthorizationManager.java b/identity/src/main/java/org/zstack/identity/AuthorizationManager.java index fe7f727b7ce..be4cea0768a 100755 --- a/identity/src/main/java/org/zstack/identity/AuthorizationManager.java +++ b/identity/src/main/java/org/zstack/identity/AuthorizationManager.java @@ -78,7 +78,38 @@ private SessionInventory evaluateSession(APIMessage msg) { } } - msg.setSession(Session.renewSession(msg.getSession().getUuid(), null)); + // Preserve runtime-only externalTenantContext across session renewal. + // renewSession() re-creates SessionInventory from the DB (SessionVO), + // which does not store externalTenantContext — it's a transient field + // set by RestServer from HTTP headers on each request. + // + // IMPORTANT: Always set externalTenantContext on the renewed session + // (even to null). renewSession() may return a cached SessionInventory + // object that was mutated by a previous request sharing the same session + // UUID — if we only set when non-null, stale tenant context from the + // previous request leaks into this request. + ExternalTenantContext tenantCtx = msg.getSession().getExternalTenantContext(); + + SessionInventory renewedSession = Session.renewSession(msg.getSession().getUuid(), null); + + // Defensive copy: renewSession() returns a cached, shared SessionInventory + // object from Session.sessions map. Multiple concurrent requests using the + // same session UUID would share the same object reference, causing a race + // condition when setExternalTenantContext() is called — the last writer wins + // and other requests see the wrong tenant. By creating a per-request copy, + // each API message gets its own SessionInventory instance. + SessionInventory sessionCopy = new SessionInventory(); + sessionCopy.setUuid(renewedSession.getUuid()); + sessionCopy.setAccountUuid(renewedSession.getAccountUuid()); + sessionCopy.setUserUuid(renewedSession.getUserUuid()); + sessionCopy.setUserType(renewedSession.getUserType()); + sessionCopy.setExpiredDate(renewedSession.getExpiredDate()); + sessionCopy.setCreateDate(renewedSession.getCreateDate()); + sessionCopy.setNoSessionEvaluation(renewedSession.isNoSessionEvaluation()); + sessionCopy.setExternalTenantContext(tenantCtx); + + msg.setSession(sessionCopy); + return msg.getSession(); } diff --git a/identity/src/main/java/org/zstack/identity/ExternalTenantResourceTracker.java b/identity/src/main/java/org/zstack/identity/ExternalTenantResourceTracker.java new file mode 100644 index 00000000000..cbb6b1bff69 --- /dev/null +++ b/identity/src/main/java/org/zstack/identity/ExternalTenantResourceTracker.java @@ -0,0 +1,182 @@ +package org.zstack.identity; + +import org.springframework.beans.factory.annotation.Autowired; +import org.zstack.core.cloudbus.CloudBus; +import org.zstack.core.componentloader.PluginRegistry; +import org.zstack.core.db.DatabaseFacade; +import org.zstack.core.db.SQLBatch; +import org.zstack.core.db.HardDeleteEntityExtensionPoint; +import org.zstack.core.db.SoftDeleteEntityByEOExtensionPoint; +import org.zstack.header.Component; +import org.zstack.header.aspect.OwnedByAccountAspectHelper; +import org.zstack.header.identity.*; +import org.zstack.header.message.AbstractBeforeDeliveryMessageInterceptor; +import org.zstack.header.message.AbstractBeforeSendMessageInterceptor; +import org.zstack.header.message.APIMessage; +import org.zstack.header.message.Message; +import org.zstack.header.vo.ResourceVO; + +import javax.persistence.EntityManager; +import java.util.*; + +public class ExternalTenantResourceTracker implements + HardDeleteEntityExtensionPoint, + SoftDeleteEntityByEOExtensionPoint, + Component, + OwnedByAccountAspectHelper.ResourceOwnershipCreationNotifier { + + @Autowired + private DatabaseFacade dbf; + @Autowired + private PluginRegistry pluginRgty; + @Autowired + private CloudBus bus; + + private Map providers = new HashMap<>(); + + /** + * Dedicated message header key for propagating ExternalTenantContext across + * CloudBus message delivery boundaries. This replaces the previous MDC-based + * approach which was susceptible to thread-pool reuse causing context leaks + * between concurrent requests. + * + * The header carries a String[] {source, tenantId, userId} and is set by + * BeforeSendMessageInterceptor on the send side, read by + * BeforeDeliveryMessageInterceptor on the receive side. + */ + private static final String HEADER_EXTERNAL_TENANT = "external-tenant-context"; + + @Override + public boolean start() { + for (ExternalTenantProvider p : pluginRgty.getExtensionList(ExternalTenantProvider.class)) { + providers.put(p.getSource(), p); + } + + OwnedByAccountAspectHelper.setResourceOwnershipCreationNotifiers( + pluginRgty.getExtensionList(OwnedByAccountAspectHelper.ResourceOwnershipCreationNotifier.class)); + + // === Send-side interceptor === + // Before each message is sent via CloudBus, copy the current thread's + // ExternalTenantContext (ThreadLocal) into a dedicated message header. + // This makes tenant context message-scoped rather than thread-scoped, + // eliminating race conditions from thread-pool reuse. + // + // Flow: ThreadLocal → msg.header["external-tenant-context"] + bus.installBeforeSendMessageInterceptor(new AbstractBeforeSendMessageInterceptor() { + @Override + public void beforeSendMessage(Message msg) { + ExternalTenantContext ctx = ExternalTenantContext.getCurrent(); + if (ctx != null && ctx.getTenantId() != null) { + msg.putHeaderEntry(HEADER_EXTERNAL_TENANT, + new String[]{ctx.getSource(), ctx.getTenantId(), ctx.getUserId()}); + } + } + }); + + // === Receive-side interceptor === + // On message delivery, restore ExternalTenantContext to ThreadLocal. + // + // For APIMessage: authoritative source is session.externalTenantContext + // (set by RestServer from HTTP headers). Write to ThreadLocal so AOP can read it. + // + // For non-APIMessage (internal messages like CreateVmInstanceMsg): + // read from the dedicated message header written by the send-side interceptor. + // This is the key fix — previously this read from MDC which is per-thread + // and gets corrupted under concurrent requests sharing the same thread pool. + bus.installBeforeDeliveryMessageInterceptor(new AbstractBeforeDeliveryMessageInterceptor() { + @Override + public void beforeDeliveryMessage(Message msg) { + if (msg instanceof APIMessage) { + SessionInventory session = ((APIMessage) msg).getSession(); + if (session != null && session.hasExternalTenant()) { + ExternalTenantContext ctx = session.getExternalTenantContext(); + ExternalTenantContext.setCurrent(ctx); + } else { + ExternalTenantContext.clearCurrent(); + } + } else { + // Non-APIMessage: restore from dedicated message header (message-scoped) + String[] tenantData = msg.getHeaderEntry(HEADER_EXTERNAL_TENANT); + if (tenantData != null && tenantData.length >= 2) { + String userId = tenantData.length >= 3 ? tenantData[2] : null; + ExternalTenantContext.setCurrent( + new ExternalTenantContext(tenantData[0], tenantData[1], userId)); + } else { + ExternalTenantContext.clearCurrent(); + } + } + } + }); + + return true; + } + + @Override + public boolean stop() { + OwnedByAccountAspectHelper.setResourceOwnershipCreationNotifiers(null); + return true; + } + + @Override + public void notifyResourceOwnershipCreated(AccountResourceRefVO ref, EntityManager entityManager) { + ExternalTenantContext ctx = ExternalTenantContext.getCurrent(); + if (ctx == null || ctx.getTenantId() == null) { + return; + } + + ExternalTenantProvider provider = providers.get(ctx.getSource()); + if (provider == null) { + return; + } + + if (!provider.shouldTrackResource(ref.getResourceType())) { + return; + } + + ExternalTenantResourceRefVO extRef = new ExternalTenantResourceRefVO(); + extRef.setSource(ctx.getSource()); + extRef.setTenantId(ctx.getTenantId()); + extRef.setUserId(ctx.getUserId()); + extRef.setResourceUuid(ref.getResourceUuid()); + extRef.setResourceType(ref.getResourceType()); + extRef.setAccountUuid(ref.getAccountUuid()); + entityManager.persist(extRef); + + provider.onResourceBound(ctx, ref.getResourceUuid(), ref.getResourceType()); + } + + @Override + public List getEntityClassForHardDeleteEntityExtension() { + return Collections.singletonList(ResourceVO.class); + } + + @Override + public void postHardDelete(Collection entityIds, Class entityClass) { + cleanupTenantRefs(entityIds); + } + + @Override + public List getEOClassForSoftDeleteEntityExtension() { + return Collections.singletonList(ResourceVO.class); + } + + @Override + public void postSoftDelete(Collection entityIds, Class EOClass) { + cleanupTenantRefs(entityIds); + } + + private void cleanupTenantRefs(Collection entityIds) { + if (entityIds == null || entityIds.isEmpty()) { + return; + } + + new SQLBatch() { + @Override + protected void scripts() { + sql("DELETE FROM ExternalTenantResourceRefVO WHERE resourceUuid IN (:uuids)") + .param("uuids", entityIds) + .execute(); + } + }.execute(); + } +} diff --git a/identity/src/main/java/org/zstack/identity/ExternalTenantZQLExtension.java b/identity/src/main/java/org/zstack/identity/ExternalTenantZQLExtension.java new file mode 100644 index 00000000000..f62098fc77e --- /dev/null +++ b/identity/src/main/java/org/zstack/identity/ExternalTenantZQLExtension.java @@ -0,0 +1,98 @@ +package org.zstack.identity; + +import org.zstack.core.db.EntityMetadata; +import org.zstack.header.identity.ExternalTenantContext; +import org.zstack.header.identity.SessionInventory; +import org.zstack.header.zql.ASTNode; +import org.zstack.header.zql.MarshalZQLASTTreeExtensionPoint; +import org.zstack.header.zql.RestrictByExprExtensionPoint; +import org.zstack.header.zql.ZQLExtensionContext; +import org.zstack.zql.ZQLContext; +import org.zstack.zql.ast.ZQLMetadata; + +import java.util.regex.Pattern; + +/** + * ZQL extension: automatically inject resource filter conditions when request carries external tenant context. + * + * Working principle (same two-phase mode as IdentityZQLExtension): + * 1. marshalZQLASTTree() -- Insert a placeholder RestrictExpr in the AST tree + * 2. restrictByExpr() -- Expand placeholder to actual SQL subquery + * + * Filter SQL looks like: + * entity.uuid IN (SELECT ref.resourceUuid FROM ExternalTenantResourceRefVO ref + * WHERE ref.source = :source AND ref.tenantId = :tenantId) + */ +public class ExternalTenantZQLExtension implements MarshalZQLASTTreeExtensionPoint, RestrictByExprExtensionPoint { + + private static final String ENTITY_NAME = "__EXTERNAL_TENANT_FILTER__"; + private static final String ENTITY_FIELD = "__EXTERNAL_TENANT_FILTER_FIELD__"; + private static final Pattern SAFE_TENANT_VALUE = Pattern.compile("^[a-zA-Z0-9_-]+$"); + + @Override + public void marshalZQLASTTree(ASTNode.Query node) { + SessionInventory session = ZQLContext.getAPISession(); + if (session == null || !session.hasExternalTenant()) { + return; + } + + ASTNode.RestrictExpr expr = new ASTNode.RestrictExpr(); + expr.setEntity(ENTITY_NAME); + expr.setField(ENTITY_FIELD); + + node.addRestrictExpr(expr); + } + + @Override + public String restrictByExpr(ZQLExtensionContext context, ASTNode.RestrictExpr expr) { + if (!ENTITY_NAME.equals(expr.getEntity()) || !ENTITY_FIELD.equals(expr.getField())) { + return null; + } + + SessionInventory session = context.getAPISession(); + if (session == null || !session.hasExternalTenant()) { + throw new SkipThisRestrictExprException(); + } + + ExternalTenantContext tenantCtx = session.getExternalTenantContext(); + if (tenantCtx == null || tenantCtx.getSource() == null || tenantCtx.getTenantId() == null) { + throw new SkipThisRestrictExprException(); + } + + // Defense-in-depth: reject values that don't match the safe charset. + // RestServer already enforces this whitelist, but a future entry point might not. + if (!SAFE_TENANT_VALUE.matcher(tenantCtx.getSource()).matches() + || !SAFE_TENANT_VALUE.matcher(tenantCtx.getTenantId()).matches()) { + throw new SkipThisRestrictExprException(); + } + + ZQLMetadata.InventoryMetadata src = ZQLMetadata.getInventoryMetadataByName(context.getQueryTargetInventoryName()); + String primaryKey = EntityMetadata.getPrimaryKeyField(src.inventoryAnnotation.mappingVOClass()).getName(); + String inventoryAlias = src.simpleInventoryName(); + + // Generate subquery, filter associated resources by source + tenantId + return String.format( + "(%s.%s IN (SELECT etref.resourceUuid FROM ExternalTenantResourceRefVO etref" + + " WHERE etref.source = '%s' AND etref.tenantId = '%s'))", + inventoryAlias, + primaryKey, + escapeSql(tenantCtx.getSource()), + escapeSql(tenantCtx.getTenantId()) + ); + } + + /** + * Secondary SQL escape — NOT a general-purpose sanitizer. + * This method only handles single-quote and backslash escaping, which is sufficient + * because upstream RestServer enforces a strict charset whitelist ([a-zA-Z0-9_-]) + * on source and tenantId before they reach this point. + * If a new entry point bypasses RestServer validation, this method alone is NOT + * sufficient to prevent SQL injection — callers must enforce their own whitelist. + */ + private static String escapeSql(String value) { + if (value == null) { + return ""; + } + return value.replace("'", "''").replace("\\", "\\\\"); + } +} diff --git a/rest/src/main/java/org/zstack/rest/RestConstants.java b/rest/src/main/java/org/zstack/rest/RestConstants.java index 467f3ab124f..9dd53e621b2 100755 --- a/rest/src/main/java/org/zstack/rest/RestConstants.java +++ b/rest/src/main/java/org/zstack/rest/RestConstants.java @@ -16,6 +16,10 @@ public interface RestConstants { String HEADER_JOB_SUCCESS = "X-Job-Success"; String HEADER_JOB_BATCH = "X-Job-Batch"; + String HEADER_TENANT_SOURCE = "X-Tenant-Source"; + String HEADER_TENANT_ID = "X-Tenant-Id"; + String HEADER_TENANT_USER = "X-Tenant-User"; + enum Batch { SUCCESS, FAIL, diff --git a/rest/src/main/java/org/zstack/rest/RestServer.java b/rest/src/main/java/org/zstack/rest/RestServer.java index 322089899f4..b20557aeb52 100755 --- a/rest/src/main/java/org/zstack/rest/RestServer.java +++ b/rest/src/main/java/org/zstack/rest/RestServer.java @@ -39,6 +39,8 @@ import org.zstack.header.identity.IdentityByPassCheck; import org.zstack.header.identity.SessionInventory; import org.zstack.header.identity.SuppressCredentialCheck; +import org.zstack.header.identity.ExternalTenantContext; +import org.zstack.header.identity.ExternalTenantProvider; import org.zstack.header.log.MaskSensitiveInfo; import org.zstack.header.message.*; import org.zstack.header.message.APIEvent; @@ -143,6 +145,10 @@ public class RestServer implements Component, CloudBusEventListener { RateLimiter rateLimiter = new RateLimiter(RestGlobalProperty.REST_RATE_LIMITS); private Map restAuthBackends = new HashMap(); + private Map externalTenantProviders = new HashMap<>(); + + private static final Pattern TENANT_HEADER_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$"); + private static final Pattern TENANT_USER_PATTERN = Pattern.compile("^[a-zA-Z0-9_.@-]{1,128}$"); private List interceptors = new ArrayList<>(); @@ -962,6 +968,46 @@ private void handleApi(Api api, Map body, String parameterName, HttpEntity