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