diff --git a/conf/db/upgrade/V5.5.1__schema.sql b/conf/db/upgrade/V5.5.1__schema.sql
new file mode 100644
index 00000000000..3c7955b8125
--- /dev/null
+++ b/conf/db/upgrade/V5.5.1__schema.sql
@@ -0,0 +1,19 @@
+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',
+ `lastOpDate` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP,
+ `createDate` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
+ INDEX idx_source_tenant (`source`, `tenantId`),
+ INDEX idx_source_tenant_user (`source`, `tenantId`, `userId`),
+ 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;
\ No newline at end of file
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..bb905597369 100644
--- a/header/src/main/java/org/zstack/header/aspect/OwnedByAccountAspectHelper.java
+++ b/header/src/main/java/org/zstack/header/aspect/OwnedByAccountAspectHelper.java
@@ -4,10 +4,28 @@
import org.zstack.header.identity.AccountResourceRefVO;
import org.zstack.header.identity.OwnedByAccount;
import org.zstack.header.vo.ResourceTypeMetadata;
+import org.zstack.utils.Utils;
+import org.zstack.utils.logging.CLogger;
import javax.persistence.EntityManager;
public class OwnedByAccountAspectHelper {
+ private static final CLogger logger = Utils.getLogger(OwnedByAccountAspectHelper.class);
+
+ // static field for resource ownership creation extension point callback
+ private static ResourceOwnershipCreationNotifier notifier;
+
+ // ThreadLocal to pass EntityManager from AOP context to notifier
+ private static final ThreadLocal currentEntityManager = new ThreadLocal<>();
+
+ public static void setResourceOwnershipCreationNotifier(ResourceOwnershipCreationNotifier notifier) {
+ OwnedByAccountAspectHelper.notifier = notifier;
+ }
+
+ public static EntityManager getCurrentEntityManager() {
+ return currentEntityManager.get();
+ }
+
public static void createAccountResourceRefVO(OwnedByAccount oa, EntityManager entityManager, Object entity) {
AccountResourceRefVO ref = new AccountResourceRefVO();
ref.setAccountUuid(oa.getAccountUuid());
@@ -19,5 +37,26 @@ public static void createAccountResourceRefVO(OwnedByAccount oa, EntityManager e
ref.setShared(false);
entityManager.persist(ref);
+
+ // notify resource ownership creation event
+ if (notifier != null) {
+ try {
+ currentEntityManager.set(entityManager);
+ notifier.notifyResourceOwnershipCreated(ref);
+ } catch (Exception e) {
+ logger.warn(String.format("failed to notify resource ownership creation for resource[uuid:%s, type:%s]: %s",
+ ref.getResourceUuid(), ref.getResourceType(), e.getMessage()), e);
+ throw e;
+ } finally {
+ currentEntityManager.remove();
+ }
+ }
+ }
+
+ /**
+ * Resource ownership creation notifier interface to avoid circular dependency
+ */
+ public static interface ResourceOwnershipCreationNotifier {
+ void notifyResourceOwnershipCreated(AccountResourceRefVO ref);
}
-}
+}
\ No newline at end of file
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..de69d8c8498
--- /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);
+ }
+}
\ No newline at end of file
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..ae4cf2208c2
--- /dev/null
+++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantProvider.java
@@ -0,0 +1,54 @@
+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 with HTTP 400.
+ * Any exception type is acceptable; non-RestException will be wrapped as 400 Bad Request by RestServer.
+ *
+ * @param ctx External tenant context (already parsed from Header)
+ * @throws RuntimeException if validation fails (e.g. invalid tenantId format)
+ */
+ 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..5539afadb92
--- /dev/null
+++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantResourceRefInventory.java
@@ -0,0 +1,125 @@
+package org.zstack.header.identity;
+
+import org.zstack.header.configuration.PythonClassInventory;
+import org.zstack.header.search.Inventory;
+
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 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) {
+ List invs = new ArrayList<>();
+ for (ExternalTenantResourceRefVO vo : vos) {
+ invs.add(valueOf(vo));
+ }
+ return invs;
+ }
+
+ 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;
+ }
+}
\ No newline at end of file
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..f1ff2ee8e14
--- /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;
+ }
+}
\ No newline at end of file
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..80a6ec0493d
--- /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;
+}
\ No newline at end of file
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..3a280a20d9c 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,22 @@ 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() {
+ if (externalTenantContext == null) {
+ return false;
+ }
+ String source = externalTenantContext.getSource();
+ String tenantId = externalTenantContext.getTenantId();
+ return source != null && !source.trim().isEmpty()
+ && tenantId != null && !tenantId.trim().isEmpty();
+ }
}
diff --git a/identity/src/main/java/org/zstack/identity/AccountManagerImpl.java b/identity/src/main/java/org/zstack/identity/AccountManagerImpl.java
index c00a6e11dd2..a34b052c655 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;
@@ -43,7 +44,6 @@
import org.zstack.header.vo.*;
import org.zstack.identity.rbac.PolicyUtils;
import org.zstack.utils.*;
-import org.zstack.utils.function.ForEachFunction;
import org.zstack.utils.gson.JSONObjectUtil;
import org.zstack.utils.logging.CLogger;
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..239a18b385d
--- /dev/null
+++ b/identity/src/main/java/org/zstack/identity/ExternalTenantResourceTracker.java
@@ -0,0 +1,266 @@
+package org.zstack.identity;
+
+import org.apache.logging.log4j.ThreadContext;
+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.Q;
+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.exception.CloudRuntimeException;
+import org.zstack.header.identity.*;
+import org.zstack.header.message.AbstractBeforeDeliveryMessageInterceptor;
+import org.zstack.header.message.APIMessage;
+import org.zstack.header.message.Message;
+import org.zstack.header.vo.ResourceVO;
+import org.zstack.utils.Utils;
+import org.zstack.utils.logging.CLogger;
+
+import javax.persistence.EntityManager;
+import java.util.*;
+
+public class ExternalTenantResourceTracker implements
+ HardDeleteEntityExtensionPoint,
+ SoftDeleteEntityByEOExtensionPoint,
+ Component,
+ OwnedByAccountAspectHelper.ResourceOwnershipCreationNotifier {
+
+ private static final CLogger logger = Utils.getLogger(ExternalTenantResourceTracker.class);
+
+ @Autowired
+ private DatabaseFacade dbf;
+ @Autowired
+ private PluginRegistry pluginRgty;
+ @Autowired
+ private CloudBus bus;
+
+ private final Map providers = new HashMap<>();
+
+ // MDC keys for cross-thread propagation via CloudBus message headers
+ private static final String MDC_TENANT_SOURCE = "ext-tenant-source";
+ private static final String MDC_TENANT_ID = "ext-tenant-id";
+ private static final String MDC_TENANT_USER = "ext-tenant-user";
+
+ public ExternalTenantProvider getProvider(String source) {
+ return providers.get(source);
+ }
+
+ public Collection getRegisteredSources() {
+ return Collections.unmodifiableCollection(providers.keySet());
+ }
+
+ @Override
+ public boolean start() {
+ for (ExternalTenantProvider p : pluginRgty.getExtensionList(ExternalTenantProvider.class)) {
+ ExternalTenantProvider old = providers.get(p.getSource());
+ if (old != null) {
+ throw new CloudRuntimeException(String.format("duplicate ExternalTenantProvider[%s, %s] for source[%s]",
+ old.getClass().getName(), p.getClass().getName(), p.getSource()));
+ }
+ providers.put(p.getSource(), p);
+ }
+
+ logger.debug(String.format("ExternalTenantResourceTracker started with %d providers: %s",
+ providers.size(), providers.keySet()));
+
+ // Set up AOP-level notifier, avoid circular dependency
+ OwnedByAccountAspectHelper.setResourceOwnershipCreationNotifier(this);
+
+ // Register message delivery interceptor: restore ExternalTenantContext ThreadLocal
+ // in CloudBus worker thread before handleMessage is called.
+ //
+ // Cross-thread propagation strategy:
+ // CloudBus copies Log4j ThreadContext (MDC) into message headers via evalThreadContextToMessage()
+ // before every bus.send(). The header key is "thread-context" (a Map).
+ //
+ // API messages (first delivery): extract tenant context from session → set ThreadLocal + MDC
+ // API messages (forwarded): session.externalTenantContext is stripped by @APINoSee during
+ // JSON serialization. CloudBus.setThreadLoggingContext() does NOT restore MDC from
+ // message headers for APIMessage instances (it only restores for non-API messages).
+ // So we read the "thread-context" header map directly from the message.
+ // Internal messages: MDC already restored from parent message headers → read MDC → set ThreadLocal
+ //
+ // This ensures tenant context propagates through the full call chain:
+ // RestServer (HTTP headers → session) → api.portal (session → MDC → msg headers)
+ // → vmInstance (msg headers → ThreadLocal) → persist(VmInstanceVO) → AOP
+ // → notifyResourceOwnershipCreated → ExternalTenantResourceRefVO
+ bus.installBeforeDeliveryMessageInterceptor(new AbstractBeforeDeliveryMessageInterceptor() {
+ @Override
+ public void beforeDeliveryMessage(Message msg) {
+ ExternalTenantContext.clearCurrent();
+
+ if (msg instanceof APIMessage) {
+ // API message: first try to extract from session (set by RestServer on first delivery)
+ SessionInventory session = ((APIMessage) msg).getSession();
+ if (session != null && session.hasExternalTenant()) {
+ ExternalTenantContext ctx = session.getExternalTenantContext();
+ ExternalTenantContext.setCurrent(ctx);
+ setTenantMDC(ctx);
+ logger.debug(String.format("BeforeDeliveryMessage: restored ExternalTenantContext[source=%s, tenant=%s] from session, msg=%s, thread=%s",
+ ctx.getSource(), ctx.getTenantId(),
+ msg.getClass().getSimpleName(), Thread.currentThread().getName()));
+ return;
+ }
+
+ // Session doesn't have tenant context — this happens when the API message
+ // is forwarded between services (e.g. api.portal → vmInstance), because
+ // @APINoSee on SessionInventory.externalTenantContext causes the field to be
+ // excluded during JSON serialization.
+ //
+ // CloudBus.setThreadLoggingContext() does NOT restore MDC from message headers
+ // for APIMessage instances — it only sets THREAD_CONTEXT_API and TASK_NAME.
+ // So we read the "thread-context" map directly from the message headers.
+ Map threadCtx = msg.getHeaderEntry("thread-context");
+ if (threadCtx != null) {
+ String source = threadCtx.get(MDC_TENANT_SOURCE);
+ String tenantId = threadCtx.get(MDC_TENANT_ID);
+ if (source != null && tenantId != null) {
+ String userId = threadCtx.get(MDC_TENANT_USER);
+ ExternalTenantContext ctx = new ExternalTenantContext(source, tenantId, userId);
+ ExternalTenantContext.setCurrent(ctx);
+ setTenantMDC(ctx);
+ logger.debug(String.format("BeforeDeliveryMessage: restored ExternalTenantContext[source=%s, tenant=%s] from msg headers for %s, thread=%s",
+ source, tenantId, msg.getClass().getSimpleName(), Thread.currentThread().getName()));
+ return;
+ }
+ }
+ }
+
+ // For internal messages: CloudBus restores MDC from message headers.
+ // Read MDC entries and set ThreadLocal so AOP can access tenant context.
+ String source = ThreadContext.get(MDC_TENANT_SOURCE);
+ String tenantId = ThreadContext.get(MDC_TENANT_ID);
+ if (source != null && tenantId != null) {
+ String userId = ThreadContext.get(MDC_TENANT_USER);
+ ExternalTenantContext ctx = new ExternalTenantContext(source, tenantId, userId);
+ ExternalTenantContext.setCurrent(ctx);
+ logger.debug(String.format("BeforeDeliveryMessage: restored ExternalTenantContext[source=%s, tenant=%s] from MDC for msg=%s, thread=%s",
+ source, tenantId, msg.getClass().getSimpleName(), Thread.currentThread().getName()));
+ } else {
+ // No tenant context from any source — clear MDC to avoid stale entries
+ clearTenantMDC();
+ }
+ }
+ });
+
+ return true;
+ }
+
+ private static void setTenantMDC(ExternalTenantContext ctx) {
+ ThreadContext.put(MDC_TENANT_SOURCE, ctx.getSource());
+ ThreadContext.put(MDC_TENANT_ID, ctx.getTenantId());
+ if (ctx.getUserId() != null) {
+ ThreadContext.put(MDC_TENANT_USER, ctx.getUserId());
+ }
+ }
+
+ private static void clearTenantMDC() {
+ ThreadContext.remove(MDC_TENANT_SOURCE);
+ ThreadContext.remove(MDC_TENANT_ID);
+ ThreadContext.remove(MDC_TENANT_USER);
+ }
+
+ @Override
+ public boolean stop() {
+ OwnedByAccountAspectHelper.setResourceOwnershipCreationNotifier(null);
+ return true;
+ }
+
+ // --- AOP-level resource creation notification ---
+ @Override
+ public void notifyResourceOwnershipCreated(AccountResourceRefVO ref) {
+ // AOP level cannot obtain session through method parameters, read from ThreadLocal
+ ExternalTenantContext ctx = ExternalTenantContext.getCurrent();
+ logger.debug(String.format("notifyResourceOwnershipCreated called for resource[uuid:%s, type:%s], thread=%s, ctx=%s",
+ ref.getResourceUuid(), ref.getResourceType(), Thread.currentThread().getName(),
+ ctx == null ? "null" : String.format("source=%s,tenant=%s", ctx.getSource(), ctx.getTenantId())));
+ if (ctx == null || ctx.getSource() == null || ctx.getTenantId() == null) {
+ return;
+ }
+
+ ExternalTenantProvider provider = providers.get(ctx.getSource());
+ if (provider == null) {
+ logger.warn(String.format("no ExternalTenantProvider found for source[%s], registered providers: %s",
+ ctx.getSource(), providers.keySet()));
+ return;
+ }
+
+ if (!provider.shouldTrackResource(ref.getResourceType())) {
+ return;
+ }
+
+ // Idempotent check: skip if binding already exists for this source + tenant + resource
+ long existing = Q.New(ExternalTenantResourceRefVO.class)
+ .eq(ExternalTenantResourceRefVO_.source, ctx.getSource())
+ .eq(ExternalTenantResourceRefVO_.tenantId, ctx.getTenantId())
+ .eq(ExternalTenantResourceRefVO_.resourceUuid, ref.getResourceUuid())
+ .count();
+ if (existing > 0) {
+ logger.debug(String.format("ExternalTenantResourceRefVO already exists for resource[uuid:%s] tenant[source:%s, id:%s], skip",
+ ref.getResourceUuid(), ctx.getSource(), ctx.getTenantId()));
+ 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());
+
+ // Use the EntityManager from the AOP context (passed via ThreadLocal)
+ // to persist in the same transaction as the AccountResourceRefVO
+ EntityManager em = OwnedByAccountAspectHelper.getCurrentEntityManager();
+ if (em != null) {
+ em.persist(extRef);
+ } else {
+ dbf.persist(extRef);
+ }
+
+ logger.debug(String.format("created ExternalTenantResourceRefVO for resource[uuid:%s, type:%s] tenant[source:%s, id:%s]",
+ ref.getResourceUuid(), ref.getResourceType(), ctx.getSource(), ctx.getTenantId()));
+
+ provider.onResourceBound(ctx, ref.getResourceUuid(), ref.getResourceType());
+ }
+
+ // --- Resource deletion cleanup ---
+ @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();
+ }
+}
\ No newline at end of file
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..fb0b1eba960
--- /dev/null
+++ b/identity/src/main/java/org/zstack/identity/ExternalTenantZQLExtension.java
@@ -0,0 +1,85 @@
+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;
+
+/**
+ * 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__";
+
+ @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();
+ }
+
+ 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())
+ );
+ }
+
+ /**
+ * Simple SQL escape, prevent injection.
+ * External tenant information has already been validated through Provider.validateTenant() in RestServer,
+ * this is a secondary safeguard.
+ */
+ 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..f7bb52ffdcf 100755
--- a/rest/src/main/java/org/zstack/rest/RestServer.java
+++ b/rest/src/main/java/org/zstack/rest/RestServer.java
@@ -39,6 +39,9 @@
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.identity.ExternalTenantResourceTracker;
import org.zstack.header.log.MaskSensitiveInfo;
import org.zstack.header.message.*;
import org.zstack.header.message.APIEvent;
@@ -139,6 +142,8 @@ public class RestServer implements Component, CloudBusEventListener {
private RESTFacade restf;
@Autowired
private PluginRegistry pluginRgty;
+ @Autowired
+ private ExternalTenantResourceTracker externalTenantResourceTracker;
RateLimiter rateLimiter = new RateLimiter(RestGlobalProperty.REST_RATE_LIMITS);
@@ -962,6 +967,44 @@ private void handleApi(Api api, Map body, String parameterName, HttpEntity