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