Files
open-metadata--openmetadata/docs/impersonation-design.md
T
wehub-resource-sync bf2343b7e4
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:35:45 +08:00

1688 lines
63 KiB
Markdown

# Bot Impersonation Feature - Design Document
## 1. Overview
### 1.1 Purpose
Enable bots in OpenMetadata to impersonate users when performing actions, ensuring proper attribution of changes to the actual user while maintaining audit trail of bot involvement.
### 1.2 Background
Currently, when bots perform actions (e.g., ingestion pipelines, automation workflows), the `updatedBy` field shows the bot's name. This obscures who actually initiated the action. With impersonation, we can track:
- **updatedBy**: The actual user who initiated the action
- **impersonatedBy**: The bot that executed the action on behalf of the user
### 1.3 Goals
- Allow bots to act on behalf of users with proper authorization
- Maintain complete audit trail showing both user and bot
- Use impersonated user's permissions for authorization checks
- Provide secure, policy-based control over impersonation capabilities
- Display impersonation context in UI and activity feeds
### 1.4 Non-Goals
- User-to-user impersonation (only bot-to-user)
- Impersonation for authentication purposes
- Bypassing authorization checks
## 2. Design Principles
1. **Security First**: Only authorized bots can impersonate, with strict validation
2. **Transparency**: All impersonated actions are clearly visible in audit logs and UI
3. **Permission Inheritance**: Use impersonated user's permissions, not bot's
4. **Backward Compatibility**: Existing entities without impersonation continue to work
5. **Auditability**: Complete trail of who did what via which bot
## 3. Architecture
### 3.1 Token Exchange Flow (On-Demand Impersonation)
Since JWT tokens are pre-generated and bots cannot dynamically create tokens with different `impersonatedUser` claims, we need a **token exchange API** that allows bots to obtain short-lived impersonation tokens on-demand.
```
┌─────────────────────────────────────────────────────────┐
│ Bot Client (Ingestion/Automation) │
│ │
│ 1. Bot has standard JWT token: │
│ - isBot: true │
│ - sub: "ingestion-bot" │
│ - No impersonatedUser claim │
└─────────────────┬────────────────────────────────────────┘
│ POST /api/v1/users/impersonate
│ Authorization: Bearer <bot-token>
│ Body: { "targetUser": "alice" }
┌─────────────────────────────────────────────────────────┐
│ Token Exchange API │
│ │
│ 2. Validates bot token │
│ 3. Checks bot has allowImpersonation=true │
│ 4. Validates IMPERSONATE permission via policy │
│ 5. Generates new JWT with: │
│ - isBot: true │
│ - sub: "ingestion-bot" │
│ - impersonatedUser: "alice" │
│ - exp: short TTL (1 hour) │
│ 6. Returns impersonation token │
└─────────────────┬────────────────────────────────────────┘
│ Response: { "accessToken": "eyJ..." }
┌─────────────────────────────────────────────────────────┐
│ Bot Client │
│ │
│ 7. Uses impersonation token for API calls │
│ 8. Token includes impersonatedUser claim │
└─────────────────┬────────────────────────────────────────┘
│ PATCH /api/v1/tables/{id}
│ Authorization: Bearer <impersonation-token>
┌─────────────────────────────────────────────────────────┐
│ JwtFilter (Authentication) │
│ │
│ 2. Validates JWT and extracts claims │
│ 3. Verifies: isBot == true │
│ 4. Checks bot has allowImpersonation flag │
│ 5. Creates CatalogSecurityContext: │
│ - principal: "ingestion-bot" │
│ - impersonatedUser: "alice" │
└─────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ DefaultAuthorizer (Authorization) │
│ │
│ 6. Validates impersonation permission: │
│ - Check policy: bot can IMPERSONATE user? │
│ - Can be restricted by team/domain │
│ 7. Creates SubjectContext for "alice" │
│ 8. Evaluates permissions using alice's context │
└─────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ EntityRepository (Persistence) │
│ │
│ 9. Sets entity fields: │
│ - updatedBy: "alice" │
│ - impersonatedBy: "ingestion-bot" │
│ - updatedAt: current_timestamp │
│ 10. Stores to database │
└─────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Database & Search Index │
│ │
│ entity_table: │
│ ┌──────────┬────────┬───────────┬─────────────────┐ │
│ │ id │ name │ updatedBy │ impersonatedBy │ │
│ ├──────────┼────────┼───────────┼─────────────────┤ │
│ │ uuid-123 │ table1 │ alice │ ingestion-bot │ │
│ └──────────┴────────┴───────────┴─────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 3.2 Component Details
#### 3.2.1 JWT Token Structure
**Standard Token (No Impersonation)**
```json
{
"sub": "alice",
"email": "alice@example.com",
"isBot": false,
"exp": 1234567890
}
```
**Impersonation Token**
```json
{
"sub": "ingestion-bot",
"email": "ingestion-bot@example.com",
"isBot": true,
"impersonatedUser": "alice",
"exp": 1234567890
}
```
#### 3.2.2 Security Context
**CatalogSecurityContext Extension**
```java
public record CatalogSecurityContext(
Principal principal, // The bot
String scheme,
String authenticationScheme,
Set<String> userRoles,
boolean isBot,
String impersonatedUser // NEW: The actual user
) implements SecurityContext
```
**SubjectContext Enhancement**
```java
public record SubjectContext(
User user, // The impersonated user (or actual user)
String impersonatedBy // NEW: The bot name (if impersonating)
)
```
## 4. Authorization Model
### 4.1 Three-Level Security Check
#### Level 1: Bot Capability Flag
```java
// User entity schema
{
"name": "ingestion-bot",
"isBot": true,
"allowImpersonation": true // NEW: Must be explicitly enabled
}
```
#### Level 2: Impersonation Permission
```java
// New MetadataOperation enum value
public enum MetadataOperation {
// ... existing operations
IMPERSONATE // NEW: Permission to impersonate users
}
```
#### Level 3: Policy-Based Control
```json
{
"name": "IngestionBotImpersonationPolicy",
"description": "Allow ingestion bot to impersonate users in Engineering domain",
"rules": [
{
"name": "ImpersonateEngineeringUsers",
"resources": ["user"],
"operations": ["Impersonate"],
"effect": "allow",
"condition": "hasDomain('Engineering')"
}
]
}
```
### 4.2 Authorization Flow
```java
// Pseudo-code for validation in JwtFilter
void validateImpersonation(String botName, String targetUser) {
// 1. Bot must have isBot=true (already validated)
// 2. Check bot has allowImpersonation flag
User bot = Entity.getEntityByName(USER, botName, "allowImpersonation", NON_DELETED);
if (!Boolean.TRUE.equals(bot.getAllowImpersonation())) {
throw new AuthorizationException("Bot not authorized to impersonate");
}
// 3. Check policy grants IMPERSONATE permission
SubjectContext botContext = SubjectContext.getSubjectContext(botName);
User targetUserEntity = Entity.getEntityByName(USER, targetUser, "", NON_DELETED);
ResourceContext resourceContext = new ResourceContext(USER, targetUserEntity.getId(), null);
OperationContext operationContext = new OperationContext(USER, MetadataOperation.IMPERSONATE);
// This throws AuthorizationException if not permitted
PolicyEvaluator.hasPermission(botContext, resourceContext, operationContext);
// 4. Validation passed - impersonation allowed
}
```
### 4.3 Permission Evaluation
When a bot impersonates a user, authorization checks use the **impersonated user's permissions**, not the bot's:
```java
// In DefaultAuthorizer
public static SubjectContext getSubjectContext(SecurityContext securityContext) {
CatalogSecurityContext catalogContext = (CatalogSecurityContext) securityContext;
// Use impersonated user if present, otherwise use principal
String userName = catalogContext.impersonatedUser() != null
? catalogContext.impersonatedUser()
: catalogContext.getUserPrincipal().getName();
User user = Entity.getEntityByName(Entity.USER, userName, USER_FIELDS, NON_DELETED);
// Track who is impersonating (if applicable)
String impersonatedBy = catalogContext.impersonatedUser() != null
? catalogContext.getUserPrincipal().getName()
: null;
return new SubjectContext(user, impersonatedBy);
}
```
**Example**: If `ingestion-bot` impersonates `alice`:
- CRUD operations are authorized using alice's roles/teams/policies
- If alice lacks permission to update a table, the operation fails
- This prevents privilege escalation
### 4.4 Bot Impersonation Grants and RBAC Scoping (v1.1 — June 2026)
This section supersedes the parts of 4.1/4.2 that the initial implementation simplified, and addresses two issues:
- **OpenMetadata #28043**: custom bots cannot enable impersonation. The `allowImpersonation` flag exists only on the `user` entity, is not part of `createUser.json`/`createBot.json`, and PATCHing it on the bot user fails (500 in 1.12.x). Only application bots (`CreateApp.allowBotImpersonation`) could ever receive the flag.
- **Collate #3581**: bots with impersonation enabled can impersonate **admin** users. Admins cannot be restricted via RBAC, so this is privilege escalation. Blocking it outright breaks workflows that legitimately need it (e.g. AskCollate), so the control must be configurable.
#### 4.4.1 Design Decision: Single Flag + RBAC Scope
The split of responsibility is:
| Question | Mechanism |
|----------|-----------|
| *Can this bot impersonate at all?* | `allowImpersonation` boolean on the bot user — an admin-granted capability, set at bot creation |
| *Who can this bot impersonate?* | Standard policy evaluation of the `Impersonate` operation with the **target user as the resource** |
A second boolean (`allowAdminImpersonation`) was considered and rejected: target scoping belongs in policies/rules, where it is already expressive (conditions, deny rules, teams, domains) and admin-manageable without code changes.
#### 4.4.1.1 Why a dedicated flag and not RBAC alone
This question recurs ("`Impersonate` is already an operation — drop the flag, grant it through a role like everything else"). It has been evaluated and **rejected**. The flag and RBAC answer two different questions, and collapsing them opens a real privilege-escalation gap:
- **The flag is the enablement-authority gate**: *may this bot impersonate at all?* — admin-only, set at bot creation, enforced in `DefaultAuthorizer` **independently of RBAC**.
- **RBAC is the target-scope gate**: *whom may it impersonate?* — policies, conditions, deny rules.
If enablement were pure RBAC, "who can enable impersonation" would collapse into "who can grant the `Impersonate` operation" — i.e. anyone with `EditRoles` on a bot user or `EditPolicy`+`Create` on policies. **That permission set is routinely delegated to non-admins** (orgs delegate RBAC management). Impersonation — which lets a bot act as any user, including bypassing controls admins can't otherwise be restricted by — would then become grantable by non-admins. The flag keeps enablement admin-only **regardless of how RBAC editing is delegated**.
The flag is also the *robust* control, not merely an extra layer. Under RBAC-only, the `Impersonate` grant can arrive through **many** paths — a direct role, a team's `defaultRoles` the bot inherits, an inherited policy, or a broad `["All"]`-operations policy (subsumption). Guaranteeing "admin-only" would mean guarding every one of those write paths. The flag is instead a **single chokepoint**: one admin-only, creation-time write, checked before any policy evaluation. It is belt-and-suspenders — **even if `Impersonate` leaks into a bot's effective policy, no flag means no impersonation.**
Two facts bound the surface and explain what the flag does and does not cover:
- `JwtFilter` rejects the `X-Impersonate-User` header for any non-bot principal (`"Only bot users can impersonate other users"`). So a **regular user self-granting** `Impersonate` achieves nothing — they are not a bot. By default they also cannot edit RBAC. This case is already closed without the flag.
- The case the flag uniquely closes is a **non-admin RBAC delegate enabling impersonation on a bot**. RBAC-only would permit it; the flag does not.
Conclusion: the flag is not redundant with RBAC — it is the admin-only, single-chokepoint enablement gate that RBAC delegation cannot widen. Do not remove it without replacing this property. The only redundancy ever identified was cosmetic: `BotImpersonationRole` is auto-attached when the flag is set, so the two travel together for UX. That convenience does not make the flag itself redundant.
#### 4.4.2 Granting the Capability (`createBot.json`)
`createBot.json` gains an optional `allowImpersonation` boolean with **tri-state semantics** (no schema default, so an absent field is `null`):
- `null` (absent) — keep the bot user's current value. Critical for PUT-based upserts (ingestion re-applies bots via `PUT /v1/bots`); an absent field must not silently revoke the grant.
- `true` — grant. **Admin-only**, and **only when the bot is being created**. Enabling impersonation on an existing bot is rejected with 400: an existing bot's token is already distributed, and flipping the flag would silently upgrade every holder of that token. Granting at creation forces a new bot + new token + deliberate admin action.
- `false` — revoke. Admin-only, allowed at any time (privilege reduction).
When the grant is applied, `BotResource` propagates the flag to the **bot user** entity (single source of truth — the authorizer reads it from the user) and attaches the seeded `BotImpersonationRole` to the bot user so the capability works out of the box. Revoking removes the flag and detaches that role.
`User.allowImpersonation` becomes effectively read-only on the user APIs:
- `PATCH /v1/users` changing it → 400 `user attribute allowImpersonation can't be modified` (replaces the unhandled 500 from #28043).
- `PUT /v1/users` always carries over the stored value (fixes a latent bug where any PUT on a bot user nulled the flag, because `CreateUser` has no such field).
#### 4.4.3 Scoping Who Can Be Impersonated (policies)
`checkImpersonationAuthorization` in `DefaultAuthorizer` evaluates, on every impersonated request:
1. The impersonating principal is a bot with `allowImpersonation=true` (capability gate, unchanged).
2. `PolicyEvaluator.hasPermission(botSubjectContext, targetUserResourceContext, OperationContext(user, IMPERSONATE))` — full policy evaluation, with the target user as the resource. This replaces the previous flat scan of role policies for the `Impersonate` operation, which ignored rule `effect` (a `deny` rule counted as allow) and could not discriminate targets.
Because this is the standard evaluation path, deny-overrides-allow, SpEL conditions, and compiled-rule caching all apply. Two new condition functions make target discrimination possible:
| Function | True when |
|----------|-----------|
| `isAdminUser()` | the resource (target user) is an admin |
| `isBotUser()` | the resource (target user) is a bot |
Existing functions compose for finer scoping: `matchTeam()` (team-scoped impersonation), `hasDomain()`, `matchAnyTag()`.
The `Impersonate` operation is registered as a `user`-resource operation, so the policy editor offers it on the `user` resource and the functions appear in the condition dropdown (`/v1/policies/functions`).
`Impersonate` is **explicit-grant-only**: `CompiledRule.matchOperation` excludes it from `ALL`/`EditAll`/`ViewAll` subsumption. A broad god-mode policy (`operations: ["All"]`) therefore neither grants nor *appears* to grant impersonation; only a rule naming `Impersonate` does. With the flag in place this is defense-in-depth (the flag already gates enablement), but it keeps permission listings honest and prevents impersonation from silently riding along with broad policies.
#### 4.4.4 Seed Policies and Defaults
- **`BotImpersonationPolicy`** (new, the default): allow `Impersonate` on `user`, **no deny rules**. **Backward-compatible by default**: a freshly granted bot can impersonate any user, *including admins*, matching the pre-existing `ApplicationBotImpersonationPolicy` (`All`/allow) behavior. This is deliberate — silently denying admin impersonation would break workflows (e.g. AskCollate) that rely on it.
- **`BotImpersonationRole`** (new): `DefaultBotPolicy` + `BotImpersonationPolicy`. Auto-attached at grant time.
- **`BotNonAdminImpersonationPolicy`** (new, opt-in restriction): allow `Impersonate` on `user` + **deny `isAdminUser()`**. Admins who want to prevent a bot from impersonating admins attach this instead of the permissive default.
- **`BotNonAdminImpersonationRole`** (new): `DefaultBotPolicy` + `BotNonAdminImpersonationPolicy`. The directly-assignable form of the opt-in restriction — swap a bot's `BotImpersonationRole` for this to deny admin targets.
- **`ApplicationBotImpersonationPolicy`** (existing, `All`/allow) is unchanged: application bots created with `allowBotImpersonation` (e.g. AskCollate) keep their current behavior, including admin impersonation.
Restriction is therefore **opt-in**, not default — the "configurable policy control" Collate #3581 asks for, delivered without breaking any bot that currently impersonates admins. The `isAdminUser()` / `isBotUser()` condition functions and deny-overrides-allow semantics let admins author any narrower scope (per-team, per-domain, deny-bot, etc.).
**No migration needed.** All four impersonation seeds (`BotImpersonationPolicy`, `BotNonAdminImpersonationPolicy`, and their roles) are new in this change — no released version has them. Seed loading is insert-if-missing, so a fresh install and any upgrade from a prior release both get the current (permissive) JSON seeded directly. There is no prior on-disk state to convert, so no Flyway migration or startup reconciliation is required.
#### 4.4.5 Threat-Model Delta
| Threat | Before | After |
|--------|--------|-------|
| Bot impersonates admin | Allowed for any impersonation bot | Allowed by default (backward-compatible); deniable opt-in via `BotNonAdminImpersonationPolicy` (`isAdminUser()` deny) |
| Bot impersonates another bot (token laundering) | Allowed | Allowed by default; deniable via an `isBotUser()` deny rule in a custom policy |
| Flag flipped on live bot via PATCH | 500, undefined behavior (works on newer builds) | 400, read-only; grant is creation-time via `createBot` |
| Flag silently wiped by PUT on bot user | Yes (latent bug) | PUT carries over stored value |
| `deny` rule with `Impersonate` op | Counted as **allow** | Honored (standard evaluator) |
| Non-admin RBAC delegate enables impersonation on a bot | n/a | Blocked — enablement is the admin-only flag, independent of RBAC editing (see 4.4.1.1) |
| Broad `["All"]` policy grants impersonation by subsumption | Would grant | `Impersonate` excluded from `ALL`/`EditAll`/`ViewAll` subsumption; flag still required |
## 5. Data Model Changes
### 5.1 JSON Schema Updates
#### 5.1.1 Base Type Definition
```json
// openmetadata-spec/src/main/resources/json/schema/type/basic.json
{
"definitions": {
"impersonatedBy": {
"description": "Bot user that performed the action on behalf of the actual user.",
"type": "string"
}
}
}
```
#### 5.1.2 Entity Schema Pattern
```json
// Example: openmetadata-spec/src/main/resources/json/schema/entity/data/table.json
{
"properties": {
"updatedBy": {
"description": "User who made the update.",
"type": "string"
},
"impersonatedBy": {
"description": "Bot that performed the update on behalf of updatedBy user.",
"$ref": "../../type/basic.json#/definitions/impersonatedBy"
},
"updatedAt": {
"$ref": "../../type/basic.json#/definitions/timestamp"
}
}
}
```
#### 5.1.3 User Schema for Bot Capability
```json
// openmetadata-spec/src/main/resources/json/schema/entity/teams/user.json
{
"properties": {
"isBot": {
"description": "When true, indicates this is a bot user.",
"type": "boolean",
"default": false
},
"allowImpersonation": {
"description": "When true, this bot is allowed to impersonate users (subject to policy checks).",
"type": "boolean",
"default": false
}
}
}
```
### 5.2 Java Entity Interface
```java
// openmetadata-spec/src/main/java/org/openmetadata/schema/EntityInterface.java
public interface EntityInterface {
// ... existing methods
String getUpdatedBy();
void setUpdatedBy(String updatedBy);
// NEW: Impersonation tracking
default String getImpersonatedBy() {
return null;
}
default void setImpersonatedBy(String botName) {
/* no-op implementation to be overridden */
}
}
```
### 5.3 Database Schema Migration
#### MySQL Migration
```sql
-- bootstrap/sql/migrations/native/1.7.0/mysql/schemaChanges.sql
-- Add impersonatedBy column to all entity tables
ALTER TABLE table_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE dashboard_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE pipeline_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE topic_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE container_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE database_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE database_schema_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE chart_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE report_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE metric_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE ml_model_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE glossary_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE glossary_term_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE tag_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE classification_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE policy_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE test_suite_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE test_case_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
ALTER TABLE event_subscription_entity ADD COLUMN impersonatedBy VARCHAR(256) DEFAULT NULL;
-- Add allowImpersonation flag to user_entity
ALTER TABLE user_entity ADD COLUMN allowImpersonation BOOLEAN DEFAULT FALSE;
-- Add index for querying by impersonatedBy
CREATE INDEX idx_table_entity_impersonatedBy ON table_entity(impersonatedBy);
-- Repeat for high-traffic entities...
```
#### PostgreSQL Migration
```sql
-- bootstrap/sql/migrations/native/1.7.0/postgres/schemaChanges.sql
-- Same as MySQL with appropriate syntax
ALTER TABLE table_entity ADD COLUMN IF NOT EXISTS impersonatedBy VARCHAR(256);
-- ... etc
```
### 5.4 Elasticsearch/OpenSearch Index Mapping
```json
// Update index mappings to include impersonatedBy
{
"mappings": {
"properties": {
"updatedBy": {
"type": "keyword"
},
"impersonatedBy": {
"type": "keyword"
},
"updatedAt": {
"type": "date"
}
}
}
}
```
## 6. Implementation Details
### 6.1 Token Exchange API (New Endpoint)
**API Specification:**
```yaml
POST /api/v1/users/impersonate
Authorization: Bearer <bot-token>
Content-Type: application/json
Request Body:
{
"targetUser": "alice",
"expirySeconds": 3600 // Optional, defaults to 1 hour, max 24 hours
}
Response:
{
"accessToken": "eyJhbGciOiJSUzI1NiIsIn...",
"tokenType": "Bearer",
"expiresIn": 3600,
"impersonatedUser": "alice"
}
```
**Implementation:**
```java
// openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java
@POST
@Path("/impersonate")
@Operation(
operationId = "generateImpersonationToken",
summary = "Generate impersonation token for bot",
description = "Generate a short-lived JWT token that allows the bot to impersonate a specific user",
responses = {
@ApiResponse(
responseCode = "200",
description = "Impersonation token generated successfully",
content = @Content(
mediaType = APPLICATION_JSON,
schema = @Schema(implementation = JWTAuthMechanism.class))),
@ApiResponse(responseCode = "403", description = "Bot not authorized to impersonate"),
@ApiResponse(responseCode = "404", description = "Target user not found")
})
@Produces(APPLICATION_JSON)
@Consumes(APPLICATION_JSON)
public Response generateImpersonationToken(
@Context SecurityContext securityContext,
@Valid ImpersonationRequest request) {
// 1. Extract bot from security context
String botName = SecurityUtil.getUserName(securityContext);
CatalogSecurityContext catalogContext = (CatalogSecurityContext) securityContext;
// 2. Validate this is a bot user
if (!catalogContext.isBot()) {
throw new AuthorizationException("Only bot users can generate impersonation tokens");
}
// 3. Get bot user and verify allowImpersonation flag
User bot = repository.getByName(
null,
botName,
new EntityUtil.Fields(Set.of("allowImpersonation")),
Include.NON_DELETED
);
if (!Boolean.TRUE.equals(bot.getAllowImpersonation())) {
throw new AuthorizationException(
String.format("Bot '%s' is not authorized to impersonate users", botName)
);
}
// 4. Validate target user exists
String targetUser = request.getTargetUser();
User user = repository.getByName(
null,
targetUser,
new EntityUtil.Fields(Set.of("email", "roles", "teams", "isAdmin")),
Include.NON_DELETED
);
// 5. Check IMPERSONATE permission via policy
SubjectContext botContext = SubjectContext.getSubjectContext(botName);
ResourceContext userResourceContext = new ResourceContext(
Entity.USER,
user.getId(),
null
);
OperationContext impersonateOperation = new OperationContext(
Entity.USER,
MetadataOperation.IMPERSONATE
);
try {
PolicyEvaluator.hasPermission(botContext, userResourceContext, impersonateOperation);
} catch (AuthorizationException e) {
throw new AuthorizationException(
String.format("Bot '%s' is not authorized to impersonate user '%s'",
botName, targetUser),
e
);
}
// 6. Generate impersonation token with short expiry
long expirySeconds = request.getExpirySeconds() != null
? Math.min(request.getExpirySeconds(), 24 * 3600) // Max 24 hours
: 3600; // Default 1 hour
JWTAuthMechanism authMechanism = JWTTokenGenerator.getInstance()
.generateImpersonationToken(
botName,
targetUser,
getRoleListFromUser(bot),
bot.getIsAdmin(),
bot.getEmail(),
expirySeconds
);
// 7. Audit log
LOG.info("Impersonation token generated: bot={}, target={}, expirySeconds={}",
botName, targetUser, expirySeconds);
return Response.ok(authMechanism).build();
}
```
**New Request Schema:**
```json
// openmetadata-spec/src/main/resources/json/schema/auth/impersonationRequest.json
{
"$id": "https://open-metadata.org/schema/auth/impersonationRequest.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ImpersonationRequest",
"description": "Request to generate an impersonation token",
"type": "object",
"javaType": "org.openmetadata.schema.auth.ImpersonationRequest",
"properties": {
"targetUser": {
"description": "Username of the user to impersonate",
"type": "string"
},
"expirySeconds": {
"description": "Token expiry in seconds (default 3600, max 86400)",
"type": "integer",
"minimum": 60,
"maximum": 86400,
"default": 3600
}
},
"required": ["targetUser"],
"additionalProperties": false
}
```
### 6.2 JWTTokenGenerator Enhancement
Add new method to generate impersonation tokens:
```java
// openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java
public class JWTTokenGenerator {
public static final String IMPERSONATED_USER_CLAIM = "impersonatedUser"; // NEW
// NEW: Generate impersonation token
public JWTAuthMechanism generateImpersonationToken(
String botName,
String targetUser,
Set<String> botRoles,
boolean isBotAdmin,
String botEmail,
long expiryInSeconds
) {
try {
Algorithm algorithm = getAlgorithm(tokenValidationAlgorithm, null, privateKey);
Date expiryDate = getCustomExpiryDate(expiryInSeconds);
String token = JWT.create()
.withSubject(botName)
.withClaim(EMAIL_CLAIM, botEmail)
.withClaim(IS_BOT_CLAIM, true)
.withClaim(TOKEN_TYPE, ServiceTokenType.BOT.value())
.withClaim(IMPERSONATED_USER_CLAIM, targetUser) // NEW: Add impersonation claim
.withExpiresAt(expiryDate)
.withIssuer(issuer)
.withKeyId(kid)
.sign(algorithm);
JWTAuthMechanism jwtAuthMechanism = new JWTAuthMechanism()
.withJWTToken(token)
.withJWTTokenExpiresAt(expiryDate.getTime());
return jwtAuthMechanism;
} catch (JWTCreationException e) {
throw AuthenticationException.invalidToken("Failed to generate impersonation token", e);
}
}
private Date getCustomExpiryDate(long expiryInSeconds) {
return Date.from(
LocalDateTime.now()
.plusSeconds(expiryInSeconds)
.atZone(ZoneId.systemDefault())
.toInstant()
);
}
}
```
### 6.3 JwtFilter Enhancement
```java
// openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java
public class JwtFilter implements ContainerRequestFilter {
public static final String IMPERSONATED_USER_CLAIM = "impersonatedUser"; // NEW
@Override
public void filter(ContainerRequestContext requestContext) {
// ... existing code ...
String tokenFromHeader = extractToken(requestContext.getHeaders());
Map<String, Claim> claims = validateJwtAndGetClaims(tokenFromHeader);
String userName = findUserNameFromClaims(jwtPrincipalClaimsMapping, jwtPrincipalClaims, claims);
String email = findEmailFromClaims(jwtPrincipalClaimsMapping, jwtPrincipalClaims, claims, principalDomain);
boolean isBotUser = isBot(claims);
// NEW: Extract and validate impersonation
String impersonatedUser = null;
if (claims.containsKey(IMPERSONATED_USER_CLAIM)) {
impersonatedUser = claims.get(IMPERSONATED_USER_CLAIM).asString();
validateImpersonation(userName, impersonatedUser, isBotUser);
}
// Check existing validations
checkValidationsForToken(claims, tokenFromHeader, userName);
// Create security context with impersonation
CatalogPrincipal catalogPrincipal = new CatalogPrincipal(userName, email);
String scheme = requestContext.getUriInfo().getRequestUri().getScheme();
CatalogSecurityContext catalogSecurityContext = new CatalogSecurityContext(
catalogPrincipal,
scheme,
SecurityContext.DIGEST_AUTH,
getUserRolesFromClaims(claims, isBotUser),
isBotUser,
impersonatedUser // NEW
);
requestContext.setSecurityContext(catalogSecurityContext);
}
private void validateImpersonation(String botName, String targetUser, boolean isBot) {
// 1. Only bots can impersonate
if (!isBot) {
throw new AuthorizationException("Only bot users can impersonate");
}
// 2. Check bot has allowImpersonation flag
User bot = Entity.getEntityByName(Entity.USER, botName, "allowImpersonation", Include.NON_DELETED);
if (!Boolean.TRUE.equals(bot.getAllowImpersonation())) {
throw new AuthorizationException(
String.format("Bot '%s' is not authorized to impersonate users", botName)
);
}
// 3. Verify target user exists
try {
Entity.getEntityByName(Entity.USER, targetUser, "", Include.NON_DELETED);
} catch (EntityNotFoundException e) {
throw new AuthorizationException(
String.format("Cannot impersonate non-existent user '%s'", targetUser)
);
}
// 4. Check policy permission (done in DefaultAuthorizer during actual operation)
// We defer this to avoid loading full context here
}
}
```
### 6.3 DefaultAuthorizer Update
```java
// openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java
public class DefaultAuthorizer implements Authorizer {
@Override
public void authorize(
SecurityContext securityContext,
OperationContext operationContext,
ResourceContextInterface resourceContext
) {
CatalogSecurityContext catalogContext = (CatalogSecurityContext) securityContext;
// NEW: If impersonating, verify impersonation permission first
if (catalogContext.impersonatedUser() != null) {
validateImpersonationPermission(
catalogContext.getUserPrincipal().getName(),
catalogContext.impersonatedUser()
);
}
// Use impersonated user's context for authorization
SubjectContext subjectContext = getSubjectContext(securityContext);
if (subjectContext.isAdmin()) {
return;
}
if (isReviewer(resourceContext, subjectContext)) {
return;
}
PolicyEvaluator.hasPermission(subjectContext, resourceContext, operationContext);
}
// NEW: Validate impersonation permission
private void validateImpersonationPermission(String botName, String targetUser) {
SubjectContext botContext = SubjectContext.getSubjectContext(botName);
User targetUserEntity = Entity.getEntityByName(
Entity.USER,
targetUser,
"",
Include.NON_DELETED
);
ResourceContext userResourceContext = new ResourceContext(
Entity.USER,
targetUserEntity.getId(),
null
);
OperationContext impersonateOperation = new OperationContext(
Entity.USER,
MetadataOperation.IMPERSONATE
);
try {
PolicyEvaluator.hasPermission(botContext, userResourceContext, impersonateOperation);
} catch (AuthorizationException e) {
throw new AuthorizationException(
String.format("Bot '%s' is not authorized to impersonate user '%s'",
botName, targetUser),
e
);
}
}
public static SubjectContext getSubjectContext(SecurityContext securityContext) {
if (securityContext == null || securityContext.getUserPrincipal() == null) {
throw new AuthenticationException("No principal in security context");
}
CatalogSecurityContext catalogContext = (CatalogSecurityContext) securityContext;
// NEW: Use impersonated user if present
String userName = catalogContext.impersonatedUser() != null
? catalogContext.impersonatedUser()
: SecurityUtil.getUserName(securityContext);
String impersonatedBy = catalogContext.impersonatedUser() != null
? catalogContext.getUserPrincipal().getName()
: null;
User user = Entity.getEntityByName(Entity.USER, userName, USER_FIELDS, Include.NON_DELETED);
return new SubjectContext(user, impersonatedBy);
}
}
```
### 6.4 EntityRepository Update
```java
// openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java
public abstract class EntityRepository<T extends EntityInterface> {
protected void prepareInternal(T entity, CreateEntity request, String updatedBy) {
// ... existing code ...
entity.setUpdatedBy(updatedBy);
entity.setUpdatedAt(System.currentTimeMillis());
// NEW: Set impersonatedBy from SubjectContext
SubjectContext subjectContext = SubjectContext.getSubjectContext(updatedBy);
if (subjectContext.impersonatedBy() != null) {
entity.setImpersonatedBy(subjectContext.impersonatedBy());
}
// ... rest of existing code ...
}
public final PutResponse<T> update(UriInfo uriInfo, T original, T updated, String updatedBy) {
setFieldsInternal(original, putFields);
updated.setUpdatedBy(updatedBy);
updated.setUpdatedAt(System.currentTimeMillis());
// NEW: Set impersonatedBy
SubjectContext subjectContext = SubjectContext.getSubjectContext(updatedBy);
if (subjectContext.impersonatedBy() != null) {
updated.setImpersonatedBy(subjectContext.impersonatedBy());
}
// ... rest of existing code ...
}
}
```
## 7. Frontend Changes
### 7.1 Type Definitions (Auto-generated)
```typescript
// Generated from schema
export interface EntityReference {
id: string;
name: string;
fullyQualifiedName?: string;
// ... other fields
}
export interface Table extends EntityInterface {
// ... other fields
updatedBy?: string;
impersonatedBy?: string; // NEW
updatedAt?: number;
}
```
### 7.2 UI Components
#### Entity Header Component
```tsx
// Display impersonation badge
const EntityHeader = ({ entity }: { entity: EntityInterface }) => {
return (
<div className="entity-header">
<div className="updated-info">
{entity.impersonatedBy ? (
<Tooltip
title={`Action performed by ${entity.updatedBy} via bot ${entity.impersonatedBy}`}
>
<span>
Updated by <strong>{entity.updatedBy}</strong>
<Tag color="blue" className="impersonation-tag">
via {entity.impersonatedBy}
</Tag>
</span>
</Tooltip>
) : (
<span>Updated by <strong>{entity.updatedBy}</strong></span>
)}
<span className="timestamp">{formatDateTime(entity.updatedAt)}</span>
</div>
</div>
);
};
```
#### Activity Feed
```tsx
// Show impersonation in activity feed
const ActivityFeedItem = ({ activity }: { activity: ChangeEvent }) => {
const displayName = activity.impersonatedBy
? `${activity.userName} (via ${activity.impersonatedBy})`
: activity.userName;
return (
<div className="activity-item">
<Avatar name={activity.userName} />
<div>
<strong>{displayName}</strong> {activity.action} {activity.entityType}
<time>{formatRelativeTime(activity.timestamp)}</time>
</div>
</div>
);
};
```
### 7.3 Admin Settings UI
```tsx
// Bot configuration page
const BotSettings = ({ bot }: { bot: User }) => {
const [allowImpersonation, setAllowImpersonation] = useState(bot.allowImpersonation);
return (
<div className="bot-settings">
<h3>Impersonation Settings</h3>
<Switch
checked={allowImpersonation}
onChange={(checked) => {
updateBotSettings(bot.id, { allowImpersonation: checked });
setAllowImpersonation(checked);
}}
/>
<p className="help-text">
Allow this bot to impersonate users (subject to policy restrictions)
</p>
{allowImpersonation && (
<Alert type="warning">
This bot can perform actions on behalf of users. Ensure appropriate
impersonation policies are configured.
</Alert>
)}
</div>
);
};
```
## 8. Usage Examples
### 8.1 Python SDK Usage
```python
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.generated.schema.entity.services.connections.metadata.openMetadataConnection import (
OpenMetadataConnection,
AuthProvider
)
from metadata.generated.schema.security.client.openMetadataJWTClientConfig import (
OpenMetadataJWTClientConfig
)
# Initialize OM client with bot token
bot_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." # Bot's standard JWT token
server_config = OpenMetadataConnection(
hostPort="http://localhost:8585/api",
authProvider=AuthProvider.openmetadata,
securityConfig=OpenMetadataJWTClientConfig(jwtToken=bot_token)
)
metadata = OpenMetadata(server_config)
# Step 1: Request impersonation token for a specific user
impersonation_response = metadata.client.post(
"/users/impersonate",
data={
"targetUser": "alice",
"expirySeconds": 3600
}
)
impersonation_token = impersonation_response["accessToken"]
print(f"Obtained impersonation token for alice: {impersonation_token[:50]}...")
# Step 2: Create new OM client with impersonation token
impersonation_config = OpenMetadataConnection(
hostPort="http://localhost:8585/api",
authProvider=AuthProvider.openmetadata,
securityConfig=OpenMetadataJWTClientConfig(jwtToken=impersonation_token)
)
impersonation_metadata = OpenMetadata(impersonation_config)
# Step 3: Perform operations as alice (via bot)
from metadata.generated.schema.entity.data.table import Table
table = impersonation_metadata.get_by_name(
entity=Table,
fqn="sample_data.ecommerce_db.shopify.dim_customer"
)
table.description = "Updated by alice via ingestion-bot"
updated_table = impersonation_metadata.patch(table)
# Result:
# - updatedBy will be "alice"
# - impersonatedBy will be "ingestion-bot"
# - Authorization checks use alice's permissions
print(f"Updated table: {updated_table.fullyQualifiedName}")
print(f"Updated by: {updated_table.updatedBy}")
print(f"Impersonated by: {updated_table.impersonatedBy}")
```
### 8.2 Ingestion Workflow Example
```python
# In ingestion connector code
class CustomIngestionSource(Source):
def __init__(self, config, metadata_config):
self.config = config
self.metadata = OpenMetadata(metadata_config)
self.impersonation_token = None
self.impersonation_metadata = None
def prepare(self):
"""Request impersonation token for the user who triggered ingestion"""
if self.config.impersonateUser:
response = self.metadata.client.post(
"/users/impersonate",
data={
"targetUser": self.config.impersonateUser,
"expirySeconds": 7200 # 2 hours for ingestion job
}
)
self.impersonation_token = response["accessToken"]
# Create new metadata client with impersonation
impersonation_config = OpenMetadataConnection(
hostPort=self.metadata.config.hostPort,
authProvider=AuthProvider.openmetadata,
securityConfig=OpenMetadataJWTClientConfig(
jwtToken=self.impersonation_token
)
)
self.impersonation_metadata = OpenMetadata(impersonation_config)
def yield_create_request(self, entity_data):
"""Use impersonation client if available"""
client = self.impersonation_metadata or self.metadata
# All operations will be attributed to impersonateUser
# but tracked as performed by ingestion bot
return CreateTableRequest(
name=entity_data.name,
description=entity_data.description,
# ... other fields
)
```
### 8.3 REST API Examples
#### Get Impersonation Token
```bash
curl -X POST https://localhost:8585/api/v1/users/impersonate \
-H "Authorization: Bearer <bot-token>" \
-H "Content-Type: application/json" \
-d '{
"targetUser": "alice",
"expirySeconds": 3600
}'
# Response:
{
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ik...",
"tokenType": "Bearer",
"expiresIn": 3600,
"impersonatedUser": "alice",
"jwtTokenExpiry": {
"expiresAt": 1699123456789
}
}
```
#### Use Impersonation Token
```bash
# Update table using impersonation token
IMPERSONATION_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ik..."
curl -X PATCH https://localhost:8585/api/v1/tables/{table-id} \
-H "Authorization: Bearer $IMPERSONATION_TOKEN" \
-H "Content-Type: application/json-patch+json" \
-d '[{
"op": "add",
"path": "/description",
"value": "Updated via impersonation"
}]'
# Response includes:
{
"id": "...",
"name": "dim_customer",
"updatedBy": "alice",
"impersonatedBy": "ingestion-bot",
"updatedAt": 1699120000000
}
```
#### Verify Impersonation in Activity Feed
```bash
curl -X GET "https://localhost:8585/api/v1/feed?entityLink=<#E::table::..." \
-H "Authorization: Bearer <token>"
# Response shows:
{
"data": [
{
"id": "...",
"type": "entityUpdated",
"userName": "alice",
"impersonatedBy": "ingestion-bot",
"timestamp": 1699120000000,
"changeDescription": {
"fieldsUpdated": [
{
"name": "description",
"oldValue": "...",
"newValue": "Updated via impersonation"
}
]
}
}
]
}
```
### 8.4 Error Scenarios
#### Bot Without Impersonation Permission
```bash
curl -X POST https://localhost:8585/api/v1/users/impersonate \
-H "Authorization: Bearer <bot-token-without-permission>" \
-d '{"targetUser": "alice"}'
# Response: 403 Forbidden
{
"code": 403,
"message": "Bot 'my-bot' is not authorized to impersonate users",
"exceptionType": "AuthorizationException"
}
```
#### Bot Without allowImpersonation Flag
```bash
# Response: 403 Forbidden
{
"code": 403,
"message": "Bot 'my-bot' is not authorized to impersonate users",
"exceptionType": "AuthorizationException"
}
```
#### Policy Restriction Violation
```bash
# Bot tries to impersonate user from different domain
curl -X POST https://localhost:8585/api/v1/users/impersonate \
-H "Authorization: Bearer <bot-token>" \
-d '{"targetUser": "bob-from-marketing"}'
# Response: 403 Forbidden
{
"code": 403,
"message": "Bot 'engineering-bot' is not authorized to impersonate user 'bob-from-marketing'",
"exceptionType": "AuthorizationException"
}
```
#### Non-Bot User Attempt
```bash
# Regular user tries to get impersonation token
curl -X POST https://localhost:8585/api/v1/users/impersonate \
-H "Authorization: Bearer <regular-user-token>" \
-d '{"targetUser": "alice"}'
# Response: 403 Forbidden
{
"code": 403,
"message": "Only bot users can generate impersonation tokens",
"exceptionType": "AuthorizationException"
}
```
## 9. API Changes
### 9.1 REST API Behavior
All existing CRUD endpoints remain unchanged. Impersonation is handled transparently through JWT token claims.
### 9.2 New Endpoint Summary
| Method | Endpoint | Description | Auth Required |
|--------|----------|-------------|---------------|
| POST | `/api/v1/users/impersonate` | Request impersonation token | Bot with `allowImpersonation=true` |
### 8.2 Search and Filter Support
```bash
# Search for entities updated by specific user via bot
GET /api/v1/search/query?q=impersonatedBy:ingestion-bot
# Search for specific user's actions (including impersonated)
GET /api/v1/search/query?q=updatedBy:alice
# Activity feed with impersonation filter
GET /api/v1/feed?filterType=impersonated&bot=ingestion-bot
```
## 9. Security Considerations
### 9.1 Threat Model
| Threat | Mitigation |
|--------|-----------|
| **Unauthorized Impersonation** | Three-level validation: isBot + allowImpersonation flag + policy permission |
| **Privilege Escalation** | Use impersonated user's permissions, not bot's |
| **Token Theft** | Standard JWT security + short expiry + token validation |
| **Audit Trail Tampering** | Immutable audit logs with both user and bot tracked |
| **Policy Bypass** | Impersonation permission checked on every request |
### 9.2 Security Best Practices
1. **Least Privilege**: Only enable `allowImpersonation` for bots that absolutely need it
2. **Policy Restrictions**: Use domain/team-based policies to limit scope
3. **Token Expiry**: Impersonation tokens should have shorter expiry (e.g., 1 hour)
4. **Audit Monitoring**: Alert on impersonation usage patterns
5. **Regular Review**: Periodic audit of which bots have impersonation enabled
### 9.3 Attack Scenarios and Defenses
#### Scenario 1: Malicious Bot Attempts Impersonation
```
Attack: Bot without allowImpersonation tries to include impersonatedUser claim
Defense: JwtFilter validates allowImpersonation flag, rejects request
Result: 403 Forbidden - "Bot not authorized to impersonate"
```
#### Scenario 2: Bot Tries to Impersonate Admin
```
Attack: Bot with impersonation tries to impersonate admin user
Defense: Policy checks if bot can impersonate that specific user
Result: 403 Forbidden - "Bot 'X' not authorized to impersonate user 'admin'"
```
#### Scenario 3: Stolen Impersonation Token
```
Attack: Attacker obtains valid impersonation token
Defense:
- Short token expiry (1 hour)
- Token bound to specific user
- Audit logs show suspicious activity patterns
- Can revoke bot's allowImpersonation flag
Result: Limited blast radius, quick detection and mitigation
```
## 10. Testing Strategy
### 10.1 Unit Tests
```java
// JwtFilterTest.java
@Test
public void testImpersonationValidation_Success() {
// Given: Bot with allowImpersonation=true and valid token
String token = generateImpersonationToken("test-bot", "alice");
// When: Filter processes request
CatalogSecurityContext context = jwtFilter.getCatalogSecurityContext(token);
// Then: Context contains impersonation info
assertEquals("test-bot", context.getUserPrincipal().getName());
assertEquals("alice", context.impersonatedUser());
}
@Test
public void testImpersonationValidation_BotNotAuthorized() {
// Given: Bot with allowImpersonation=false
User bot = createBot("test-bot", false);
String token = generateImpersonationToken("test-bot", "alice");
// When/Then: Validation fails
assertThrows(AuthorizationException.class, () -> {
jwtFilter.validateJwtAndGetClaims(token);
});
}
@Test
public void testNonBotCannotImpersonate() {
// Given: Regular user tries to impersonate
String token = JWT.create()
.withSubject("alice")
.withClaim("isBot", false)
.withClaim("impersonatedUser", "bob")
.sign(algorithm);
// When/Then: Validation fails
assertThrows(AuthorizationException.class, () -> {
jwtFilter.validateJwtAndGetClaims(token);
});
}
```
```java
// EntityRepositoryTest.java
@Test
public void testEntityUpdate_WithImpersonation() {
// Given: Bot impersonating user
SubjectContext context = new SubjectContext(
createUser("alice"),
"ingestion-bot" // impersonatedBy
);
Table table = createTable();
// When: Update entity
tableRepository.update(null, table, updatedTable, "alice");
// Then: Both fields are set
assertEquals("alice", updatedTable.getUpdatedBy());
assertEquals("ingestion-bot", updatedTable.getImpersonatedBy());
}
```
### 10.2 Integration Tests
```java
// ImpersonationIntegrationTest.java
@Test
public void testEndToEndImpersonation() {
// 1. Create bot with impersonation enabled
User bot = createBot("test-bot", true);
bot.setAllowImpersonation(true);
// 2. Create policy allowing impersonation
Policy policy = createImpersonationPolicy("test-bot", "alice");
// 3. Generate impersonation token
String token = generateImpersonationToken("test-bot", "alice");
// 4. Make API call to update entity
Response response = updateTable(tableId, token, newDescription);
// 5. Verify response
assertEquals(200, response.getStatus());
// 6. Verify database state
Table updated = getTable(tableId);
assertEquals("alice", updated.getUpdatedBy());
assertEquals("test-bot", updated.getImpersonatedBy());
// 7. Verify audit log
List<ChangeEvent> events = getChangeEvents(tableId);
assertEquals("alice", events.get(0).getUserName());
assertEquals("test-bot", events.get(0).getImpersonatedBy());
}
@Test
public void testImpersonationWithoutPolicy_Fails() {
// Setup: Bot with flag but no policy
User bot = createBot("test-bot", true);
String token = generateImpersonationToken("test-bot", "alice");
// When: Attempt operation
Response response = updateTable(tableId, token, newDescription);
// Then: Fails with 403
assertEquals(403, response.getStatus());
assertTrue(response.getEntity().toString().contains("not authorized to impersonate"));
}
```
### 10.3 Security Tests
```java
@Test
public void testPrivilegeEscalation_Prevented() {
// Given: alice can edit tables in Engineering domain
// bob can only view tables
// bot impersonates bob
User alice = createUser("alice", "Engineering", "DataSteward");
User bob = createUser("bob", "Engineering", "DataConsumer");
User bot = createBot("test-bot", true);
Table table = createTableInDomain("Engineering");
// When: Bot impersonates bob and tries to update table
String token = generateImpersonationToken("test-bot", "bob");
Response response = updateTable(table.getId(), token, "New description");
// Then: Operation fails (bob lacks permission)
assertEquals(403, response.getStatus());
}
@Test
public void testCrossDomainImpersonation_Restricted() {
// Given: Policy allows impersonation only within Engineering domain
User bot = createBot("test-bot", true);
createDomainRestrictedImpersonationPolicy("test-bot", "Engineering");
User engineeringUser = createUserInDomain("alice", "Engineering");
User marketingUser = createUserInDomain("bob", "Marketing");
// When: Try to impersonate within domain
String token1 = generateImpersonationToken("test-bot", "alice");
Response response1 = updateEntity(token1);
assertEquals(200, response1.getStatus()); // Success
// When: Try to impersonate outside domain
String token2 = generateImpersonationToken("test-bot", "bob");
Response response2 = updateEntity(token2);
assertEquals(403, response2.getStatus()); // Failure
}
```
### 10.4 Performance Tests
```java
@Test
public void testImpersonationOverhead() {
// Measure overhead of impersonation validation
// Baseline: Normal request
long baseline = measureRequestTime(() -> updateTableNormal());
// With impersonation
long withImpersonation = measureRequestTime(() -> updateTableWithImpersonation());
// Assert: Overhead < 10%
double overhead = (withImpersonation - baseline) / (double) baseline;
assertTrue(overhead < 0.10, "Impersonation overhead should be < 10%");
}
```
## 11. Migration Plan
### Phase 1: Schema and Backend (Week 1-2)
1. Add JSON schema definitions
2. Add database migrations
3. Update EntityInterface
4. Regenerate Java/Python/TypeScript models
5. Update JwtFilter, CatalogSecurityContext, SubjectContext
6. Deploy to dev environment
### Phase 2: Authorization (Week 2-3)
7. Add IMPERSONATE to MetadataOperation enum
8. Update DefaultAuthorizer with validation logic
9. Update EntityRepository to populate impersonatedBy
10. Add unit tests and integration tests
11. Deploy to staging environment
### Phase 3: Bot Support (Week 3-4)
12. Add allowImpersonation to User schema
13. Update bot creation APIs
14. Update Python SDK to support impersonation tokens
15. Update ingestion framework
16. Test with sample ingestion workflows
### Phase 4: Frontend (Week 4-5)
17. Update UI components to display impersonation
18. Add bot configuration page
19. Update activity feed
20. Add filtering by impersonatedBy
### Phase 5: Documentation and Rollout (Week 5-6)
21. Write user documentation
22. Write admin guide for policy configuration
23. Create migration guide for bot users
24. Gradual rollout to production
25. Monitor and adjust policies
## 12. Monitoring and Observability
### 12.1 Metrics
```java
// Metrics to track
- impersonation.requests.total (counter)
- impersonation.requests.by_bot (counter, labeled by bot name)
- impersonation.requests.by_target_user (counter, labeled by user)
- impersonation.validation.failures (counter, labeled by reason)
- impersonation.authorization.duration (histogram)
```
### 12.2 Logging
```java
// Log examples
LOG.info("Impersonation validated: bot={}, target={}, operation={}",
botName, targetUser, operation);
LOG.warn("Impersonation attempt blocked: bot={}, target={}, reason={}",
botName, targetUser, reason);
LOG.debug("Impersonation token processed: bot={}, user={}, entity={}",
botName, userName, entityType);
```
### 12.3 Alerts
```yaml
# Alert on suspicious patterns
- name: HighImpersonationFailureRate
condition: impersonation.validation.failures > 10/min
severity: warning
- name: UnauthorizedImpersonationAttempts
condition: impersonation.requests{reason="unauthorized"} > 5/min
severity: critical
- name: CrossDomainImpersonationSpike
condition: impersonation.requests{cross_domain="true"} > 20/min
severity: warning
```
## 13. Documentation
### 13.1 User Guide
**For Bot Users:**
- How to enable impersonation for your bot
- How to generate impersonation tokens
- Examples in Python SDK and REST API
- Troubleshooting common issues
**For Admins:**
- How to configure impersonation policies
- Best practices for security
- How to audit impersonation usage
- How to investigate suspicious activity
### 13.2 API Documentation
Update OpenAPI specification:
```yaml
components:
securitySchemes:
BotImpersonation:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT token with impersonation claim. Bot must have allowImpersonation=true
and policy permission to impersonate the target user.
Token claims:
- isBot: true (required)
- impersonatedUser: string (target username)
```
## 14. Future Enhancements
### 14.1 Impersonation Audit Report
- UI dashboard showing all impersonated actions
- Exportable audit reports
- Anomaly detection for unusual patterns
### 14.2 Temporary Impersonation Tokens
- Time-limited impersonation grants
- Automatic expiry after specific duration
- Revocable impersonation sessions
### 14.3 Impersonation Approval Workflow
- Require admin approval for sensitive impersonations
- Approval via UI or API
- Notification to impersonated user
### 14.4 Delegation Instead of Impersonation
- User explicitly delegates actions to bot
- Delegation scope limited to specific operations/entities
- User can revoke delegation anytime
## 15. Appendix
### 15.1 Related Work
- AWS IAM Role Assumption
- Kubernetes Service Account Impersonation
- Google Cloud Service Account Impersonation
### 15.2 Security Standards
- OWASP API Security Top 10
- NIST 800-53 Access Control Guidelines
- SOC 2 Audit Requirements
### 15.3 Glossary
- **Impersonation**: Bot acting on behalf of a user
- **Subject**: The entity performing authorization checks (user or bot)
- **Principal**: The authenticated entity in security context
- **Policy**: Rule defining what operations are allowed
- **Resource Context**: Entity being accessed
- **Operation Context**: Type of action being performed
---
**Document Version**: 1.1
**Last Updated**: 2026-06-12
**Authors**: OpenMetadata Engineering
**v1.1**: Custom-bot impersonation grants via `createBot.json`, RBAC target scoping (`isAdminUser()`/`isBotUser()` conditions), read-only `User.allowImpersonation` on user APIs, `Impersonate` excluded from `ALL` subsumption. Default `BotImpersonationPolicy` is **permissive/backward-compatible** (admin impersonation stays allowed); admin restriction is opt-in via `BotNonAdminImpersonationPolicy`/`BotNonAdminImpersonationRole`. All seeds are new, so no migration is needed. See section 4.4.
**Status**: Draft for Review