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..179d85acf69
--- /dev/null
+++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantProvider.java
@@ -0,0 +1,59 @@
+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.
+ *
+ * Input charset contract: Implementations MUST reject tenantId/userId values containing
+ * characters outside the safe set {@code [a-zA-Z0-9_-]}. This is a framework-level security
+ * requirement to prevent SQL injection in downstream ZQL query extensions.
+ * The framework does NOT enforce this automatically — each Provider is responsible for validation.
+ *
+ * @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/ExternalTenantProviderRegistry.java b/header/src/main/java/org/zstack/header/identity/ExternalTenantProviderRegistry.java
new file mode 100644
index 00000000000..c865ede5a67
--- /dev/null
+++ b/header/src/main/java/org/zstack/header/identity/ExternalTenantProviderRegistry.java
@@ -0,0 +1,17 @@
+package org.zstack.header.identity;
+
+/**
+ * Registry interface for looking up {@link ExternalTenantProvider} instances by source identifier.
+ *
+ * This interface lives in the header module so that modules like rest can look up providers
+ * without depending on the identity implementation module directly.
+ */
+public interface ExternalTenantProviderRegistry {
+ /**
+ * Get the registered provider for the given source identifier.
+ *
+ * @param source the external service identifier (e.g. "zcf")
+ * @return the provider, or null if no provider is registered for the source
+ */
+ ExternalTenantProvider getProvider(String source);
+}
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..2d21dd82800
--- /dev/null
+++ b/identity/src/main/java/org/zstack/identity/ExternalTenantResourceTracker.java
@@ -0,0 +1,331 @@
+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.core.ThreadLocalPropagation;
+import org.zstack.header.core.ThreadLocalPropagator;
+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 javax.persistence.PersistenceException;
+import java.util.*;
+
+/**
+ * Tracks external tenant resource bindings across the ZStack resource lifecycle.
+ *
+ * Resource binding is performed through the AOP path via
+ * {@link OwnedByAccountAspectHelper.ResourceOwnershipCreationNotifier}, NOT through
+ * the {@code ResourceOwnershipCreatedExtensionPoint} extension point. This is intentional:
+ * the AOP notifier runs within the same EntityManager transaction as the resource creation,
+ * ensuring atomicity (binding either succeeds with the resource or both roll back).
+ * If we also registered as a {@code ResourceOwnershipCreatedExtensionPoint}, the same
+ * resource would be persisted twice, violating the UNIQUE KEY constraint.
+ */
+public class ExternalTenantResourceTracker implements
+ ExternalTenantProviderRegistry,
+ 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";
+
+ @Override
+ 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 ThreadLocal propagator so ExternalTenantContext survives
+ // cross-thread submissions (e.g. thdf.syncSubmit, thdf.chainSubmit).
+ // This is the generic SPI — DispatchQueueImpl captures/restores all
+ // registered propagators automatically.
+ ThreadLocalPropagation.register(new ThreadLocalPropagator() {
+ @Override
+ public Object capture() {
+ return ExternalTenantContext.getCurrent();
+ }
+
+ @Override
+ public void restore(Object state) {
+ if (state != null) {
+ ExternalTenantContext.setCurrent((ExternalTenantContext) state);
+ } else {
+ ExternalTenantContext.clearCurrent();
+ }
+ }
+
+ @Override
+ public void clear() {
+ ExternalTenantContext.clearCurrent();
+ }
+ });
+
+ // 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);
+ if (logger.isTraceEnabled()) {
+ logger.trace(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);
+ if (logger.isTraceEnabled()) {
+ logger.trace(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);
+ if (logger.isTraceEnabled()) {
+ logger.trace(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();
+ if (logger.isTraceEnabled()) {
+ logger.trace(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
+ boolean existing = Q.New(ExternalTenantResourceRefVO.class)
+ .eq(ExternalTenantResourceRefVO_.source, ctx.getSource())
+ .eq(ExternalTenantResourceRefVO_.tenantId, ctx.getTenantId())
+ .eq(ExternalTenantResourceRefVO_.resourceUuid, ref.getResourceUuid())
+ .isExists();
+ if (existing) {
+ 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.
+ // Catch PersistenceException (e.g. UNIQUE KEY violation under extreme concurrency)
+ // to avoid rolling back the host resource creation transaction.
+ try {
+ EntityManager em = OwnedByAccountAspectHelper.getCurrentEntityManager();
+ if (em != null) {
+ em.persist(extRef);
+ } else {
+ dbf.persist(extRef);
+ }
+ } catch (PersistenceException e) {
+ // UNIQUE KEY violation under concurrent creation of the same resource —
+ // extremely rare, but must not roll back the host transaction.
+ logger.warn(String.format("failed to persist ExternalTenantResourceRefVO for resource[uuid:%s] tenant[source:%s, id:%s]: %s",
+ ref.getResourceUuid(), ctx.getSource(), ctx.getTenantId(), e.getMessage()));
+ return;
+ }
+
+ 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);
+ }
+
+ // Soft delete cleanup: returns ResourceVO.class as the EO class.
+ // ZStack's SoftDeleteEntityByEOExtensionPoint uses isAssignableFrom matching,
+ // so registering the base ResourceVO.class catches all concrete EO subclasses
+ // (e.g. VmInstanceEO, VolumeEO). If the framework ever changes to exact-match,
+ // this would stop firing — but FK CASCADE on hard delete (expunge) ensures
+ // no orphan ExternalTenantResourceRefVO records remain.
+ @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..0709645896a
--- /dev/null
+++ b/identity/src/main/java/org/zstack/identity/ExternalTenantZQLExtension.java
@@ -0,0 +1,86 @@
+package org.zstack.identity;
+
+import org.zstack.core.db.EntityMetadata;
+import org.zstack.header.identity.ExternalTenantContext;
+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.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) {
+ // Read from ThreadLocal (restored by ExternalTenantResourceTracker.beforeDeliveryMessage)
+ // instead of session, because @APINoSee strips externalTenantContext during JSON serialization
+ // when the API message is forwarded between services.
+ ExternalTenantContext ctx = ExternalTenantContext.getCurrent();
+ if (ctx == null || ctx.getSource() == null || ctx.getTenantId() == null) {
+ 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;
+ }
+
+ ExternalTenantContext tenantCtx = ExternalTenantContext.getCurrent();
+ 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.
+ *
+ * Note: backslash escaping assumes MySQL default mode (NO_BACKSLASH_ESCAPES is not enabled).
+ * If NO_BACKSLASH_ESCAPES is set, backslash-based escaping has no effect, but since
+ * Provider.validateTenant() already restricts input to [a-zA-Z0-9_-], this is a defense-in-depth
+ * measure only.
+ */
+ 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..644c1f5cd81 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.header.identity.ExternalTenantProviderRegistry;
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 ExternalTenantProviderRegistry externalTenantResourceTracker;
RateLimiter rateLimiter = new RateLimiter(RestGlobalProperty.REST_RATE_LIMITS);
@@ -962,11 +967,63 @@ private void handleApi(Api api, Map body, String parameterName, HttpEntity