Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
46c1cf6
feat(dar): add Granted lifecycle, filters, sort, and self-service cre…
harshach May 11, 2026
cbaa5f8
Update generated TypeScript types
github-actions[bot] May 11, 2026
add13b3
refactor(dar): address PR review — derive approver capture from targe…
harshach May 11, 2026
519e05c
Merge branch 'main' into harshach/dar-filters-granted-status
harshach May 11, 2026
9fcbef6
fix(dar): authorize DAR list, document statusGroup, idempotent MySQL …
harshach May 11, 2026
df57477
fix(dar): add approvedBy/At fields to UI Task type and preserve appro…
harshach May 11, 2026
1e28ab5
fix header test
anuj-kumary May 12, 2026
5d935de
Merge branch 'main' into harshach/dar-filters-granted-status
anuj-kumary May 12, 2026
5aa07f6
Resolved merge confclits
anuj-kumary May 12, 2026
e19fe9b
fix unti test
anuj-kumary May 12, 2026
fd3452f
fix lint checks
anuj-kumary May 12, 2026
cec17ef
fix(dar): inline approvedById column in 2.0.0 task_entity, drop unnee…
harshach May 12, 2026
5be09b7
Merge branch 'main' into harshach/dar-filters-granted-status
harshach May 12, 2026
298b972
fix(dar): upgrade 2.0.0 task_entity with approvedById ALTER, gate re-…
harshach May 12, 2026
26485da
fix(dar): reorder postgres approvedbyid ALTER before its index, unify…
harshach May 12, 2026
e311329
docs(dar): correct taskCount.approved/granted descriptions to reflect…
harshach May 12, 2026
eed6e64
Update generated TypeScript types
github-actions[bot] May 12, 2026
66ba992
Merge branch 'main' into harshach/dar-filters-granted-status
anuj-kumary May 13, 2026
9b2fa8e
feat(dar): add search, multi-select filters, and assignee filter to /…
harshach May 13, 2026
6bbe6fc
fix(dar): escape `%` in q search input, replace fully-qualified java.…
harshach May 13, 2026
96f308b
Merge branch 'main' into harshach/dar-filters-granted-status
harshach May 13, 2026
45723b5
test(dar): wrap revoke-from-Granted in awaitility retry to absorb Flo…
harshach May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS task_entity (
deleted tinyint(1) GENERATED ALWAYS AS (json_extract(`json`,_utf8mb4'$.deleted')) STORED,
aboutFqnHash varchar(256) GENERATED ALWAYS AS (json_unquote(json_extract(`json`,_utf8mb4'$.aboutFqnHash'))) STORED,
createdById varchar(36) GENERATED ALWAYS AS (json_unquote(json_extract(`json`,_utf8mb4'$.createdById'))) STORED,
approvedById varchar(36) GENERATED ALWAYS AS (json_unquote(json_extract(`json`,_utf8mb4'$.approvedById'))) STORED,
PRIMARY KEY (id),
UNIQUE KEY uk_fqn_hash (fqnHash),
KEY idx_task_id (taskId),
Expand All @@ -30,9 +31,50 @@ CREATE TABLE IF NOT EXISTS task_entity (
KEY idx_about_fqn_hash (aboutFqnHash),
KEY idx_status_about (status, aboutFqnHash),
KEY idx_created_by_id (createdById),
KEY idx_created_by_category (createdById, category)
KEY idx_created_by_category (createdById, category),
KEY idx_approved_by_id (approvedById)
Comment thread
harshach marked this conversation as resolved.
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- For 2.0.0 environments that ran the CREATE TABLE above before the
-- approvedById generated column was added inline, attach it now. CREATE TABLE
-- IF NOT EXISTS is a no-op on those environments so the column would never
-- appear otherwise. MySQL doesn't reliably support `ADD COLUMN IF NOT EXISTS`
-- across 8.0 versions and has no `ADD KEY IF NOT EXISTS`, so guard both via
-- information_schema.
SET @ddl = (
Comment thread
harshach marked this conversation as resolved.
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'task_entity'
AND column_name = 'approvedById'
),
'SELECT 1',
'ALTER TABLE task_entity ADD COLUMN approvedById varchar(36) GENERATED ALWAYS AS (json_unquote(json_extract(`json`,_utf8mb4''$.approvedById''))) STORED'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'task_entity'
AND index_name = 'idx_approved_by_id'
),
'SELECT 1',
'ALTER TABLE task_entity ADD KEY idx_approved_by_id (approvedById)'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

CREATE TABLE IF NOT EXISTS new_task_sequence (
id bigint NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
Expand Down
10 changes: 10 additions & 0 deletions bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS task_entity (
deleted boolean GENERATED ALWAYS AS (((json ->> 'deleted'::text))::boolean) STORED,
aboutfqnhash character varying(256) GENERATED ALWAYS AS ((json ->> 'aboutFqnHash'::text)) STORED,
createdbyid character varying(36) GENERATED ALWAYS AS ((json ->> 'createdById'::text)) STORED,
approvedbyid character varying(36) GENERATED ALWAYS AS ((json ->> 'approvedById'::text)) STORED,
PRIMARY KEY (id),
CONSTRAINT uk_task_fqn_hash UNIQUE (fqnhash)
);
Expand All @@ -33,6 +34,15 @@ CREATE INDEX IF NOT EXISTS idx_task_about_fqn_hash ON task_entity (aboutfqnhash)
CREATE INDEX IF NOT EXISTS idx_task_status_about ON task_entity (status, aboutfqnhash);
CREATE INDEX IF NOT EXISTS idx_task_created_by_id ON task_entity (createdbyid);
CREATE INDEX IF NOT EXISTS idx_task_created_by_category ON task_entity (createdbyid, category);
CREATE INDEX IF NOT EXISTS idx_task_approved_by_id ON task_entity (approvedbyid);
Comment thread
harshach marked this conversation as resolved.
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated

-- For 2.0.0 environments that ran the CREATE TABLE above before the
-- approvedbyid generated column was added inline, attach it now. CREATE TABLE
-- IF NOT EXISTS is a no-op on those environments so the column would never
-- appear otherwise. Postgres supports `ADD COLUMN IF NOT EXISTS` natively.
ALTER TABLE task_entity
ADD COLUMN IF NOT EXISTS approvedbyid character varying(36)
GENERATED ALWAYS AS ((json ->> 'approvedById'::text)) STORED;

CREATE TABLE IF NOT EXISTS new_task_sequence (
id bigint NOT NULL DEFAULT 0
Expand Down
19 changes: 19 additions & 0 deletions openmetadata-integration-tests/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,25 @@
<version>2.3.0</version>
<scope>test</scope>
</dependency>
<!-- Required by DriveFileUploadIT (test fixture generation: PDF + XLSX). -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.31</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.4.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.4.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<!-- MySQL + Elasticsearch (default) -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,15 @@
* <li>Seed: DataAccessRequest form schema and DataAccessRequestTaskWorkflow are loaded on boot.
* <li>Create: POST /tasks with category=DataAccess, type=DataAccessRequest and an
* accessType+reason payload succeeds and lands the task at the "review" stage.
* <li>Approve: /resolve transitions the task to status=InProgress, stage="approved",
* and surfaces a "revoke" available transition (matches the IncidentResolution pattern).
* <li>Revoke: /resolve from the approved stage closes the task with status=Revoked and
* resolution.type=Revoked.
* <li>Approve: /resolve transitions the task to status=Approved, stage="approved",
* captures approvedBy/approvedAt, and surfaces "markAsGranted" + "revoke" transitions.
* <li>Grant: /resolve with markAsGranted moves the task to status=Granted (active access).
* <li>Revoke: /resolve from either Approved or Granted closes the task with status=Revoked.
* <li>Reject: alternative terminal path lands at status=Rejected.
* <li>Validation: missing required fields (accessType/reason) are rejected by the form
* schema validator.
* <li>Policy: non-admin users can create DARs via the DataConsumerPolicy Create-task rule.
* <li>Filters: /v1/tasks/dataAccessRequests honors status/accessType/requestedBy/sortOrder.
* </ul>
*/
@Execution(ExecutionMode.CONCURRENT)
Expand Down Expand Up @@ -133,12 +135,13 @@ void darWorkflowDefinitionIsSeeded() {
List<String> nodeNames = workflow.getNodes().stream().map(n -> n.getName()).toList();
assertTrue(nodeNames.contains("TaskReview"));
assertTrue(nodeNames.contains("ApprovedAccess"));
assertTrue(nodeNames.contains("GrantedAccess"));
assertTrue(nodeNames.contains("RejectedEnd"));
assertTrue(nodeNames.contains("RevokedEnd"));
}

@Test
void createApproveAndRevokeLifecycle(TestNamespace ns) {
void createApproveGrantRevokeLifecycle(TestNamespace ns) {
String tableFqn = createTargetTable(ns);

Task created =
Expand Down Expand Up @@ -167,27 +170,47 @@ void createApproveAndRevokeLifecycle(TestNamespace ns) {

Task reviewed = reviewTaskRef.get();

// Approve → moves to ApprovedAccess userTask. Status stays non-terminal so the workflow
// can continue to a Revoke transition (matches IncidentResolution pattern).
// Approve → status=Approved (awaiting grant). approvedBy/approvedAt captured.
// Available transitions: markAsGranted (provision) and revoke (back out).
Task approved =
SdkClients.adminClient()
.tasks()
.resolve(
reviewed.getId().toString(),
new ResolveTask().withTransitionId("approve").withComment("approved"));

assertEquals(TaskEntityStatus.InProgress, approved.getStatus());
assertEquals(TaskEntityStatus.Approved, approved.getStatus());
assertEquals("approved", approved.getWorkflowStageId());
assertNotNull(approved.getApprovedBy(), "approvedBy must be captured on approve transition");
assertNotNull(approved.getApprovedById());
assertNotNull(approved.getApprovedAt());
List<String> approvedTransitions =
approved.getAvailableTransitions().stream().map(TaskAvailableTransition::getId).toList();
assertEquals(List.of("revoke"), approvedTransitions);
assertTrue(approvedTransitions.contains("markAsGranted"));
assertTrue(approvedTransitions.contains("revoke"));

// Revoke → terminal Revoked status with resolution.
Task revoked =
// Mark as granted → status=Granted (active access).
Task granted =
SdkClients.adminClient()
.tasks()
.resolve(
approved.getId().toString(),
new ResolveTask().withTransitionId("markAsGranted").withComment("provisioned"));

assertEquals(TaskEntityStatus.Granted, granted.getStatus());
assertEquals("granted", granted.getWorkflowStageId());
// approvedBy must persist through the grant transition.
assertEquals(approved.getApprovedById(), granted.getApprovedById());
List<String> grantedTransitions =
granted.getAvailableTransitions().stream().map(TaskAvailableTransition::getId).toList();
assertEquals(List.of("revoke"), grantedTransitions);

// Revoke from Granted → terminal Revoked status with resolution.
Task revoked =
SdkClients.adminClient()
.tasks()
.resolve(
granted.getId().toString(),
new ResolveTask().withTransitionId("revoke").withComment("revoking"));

assertEquals(TaskEntityStatus.Revoked, revoked.getStatus());
Expand All @@ -196,6 +219,32 @@ void createApproveAndRevokeLifecycle(TestNamespace ns) {
assertTrue(revoked.getAvailableTransitions().isEmpty());
}

@Test
void approvedCanBeRevokedWithoutGranting(TestNamespace ns) {
String tableFqn = createTargetTable(ns);
Task created =
SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess"));

Task approved =
SdkClients.adminClient()
.tasks()
.resolve(
created.getId().toString(),
new ResolveTask().withTransitionId("approve").withComment("approved"));
assertEquals(TaskEntityStatus.Approved, approved.getStatus());

// Revoke directly from the Approved stage (admin backs out before granting).
Task revoked =
SdkClients.adminClient()
.tasks()
.resolve(
approved.getId().toString(),
new ResolveTask().withTransitionId("revoke").withComment("backing out"));

assertEquals(TaskEntityStatus.Revoked, revoked.getStatus());
assertEquals(TaskResolutionType.Revoked, revoked.getResolution().getType());
}

@Test
void rejectLandsAtTerminalRejectedStatus(TestNamespace ns) {
String tableFqn = createTargetTable(ns);
Expand Down Expand Up @@ -256,4 +305,149 @@ void missingAccessTypeIsRejectedByFormSchema(TestNamespace ns) {
assertThrows(
InvalidRequestException.class, () -> SdkClients.adminClient().tasks().create(invalid));
}

@Test
void nonAdminUserCanCreateDar(TestNamespace ns) {
// DataConsumerPolicy grants Create on resource=task to every authenticated user, so a
// non-admin user can file a DAR without an explicit role. Verifies the policy fix for the
// "Principal: ... operations [Create] not allowed" failure when adam.matthews2-style users
// tried to request access.
String tableFqn = createTargetTable(ns);
Task created =
SdkClients.user1Client().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess"));

assertNotNull(created.getId());
assertEquals(TaskCategory.DataAccess, created.getCategory());
assertEquals(TaskEntityType.DataAccessRequest, created.getType());
}

@Test
void darListEndpointFiltersByAccessTypeAndStatusAndSorts(TestNamespace ns) throws Exception {
String tableFqn = createTargetTable(ns);

Task openFull =
SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess"));
Task openColumn =
SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "ColumnLevel"));
Task approvedFull =
SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess"));
SdkClients.adminClient()
.tasks()
.resolve(
approvedFull.getId().toString(),
new ResolveTask().withTransitionId("approve").withComment("approved"));

// Filter by dataset → all three DARs come back (newest first by default sort DESC on
// createdAt).
var byDataset =
SdkClients.adminClient()
.tasks()
.listDataAccessRequests(Map.of("dataset", tableFqn, "limit", "50"));
List<String> idsByDataset =
byDataset.getData().stream().map(t -> t.getId().toString()).toList();
assertTrue(idsByDataset.contains(openFull.getId().toString()));
assertTrue(idsByDataset.contains(openColumn.getId().toString()));
assertTrue(idsByDataset.contains(approvedFull.getId().toString()));

// Filter by accessType=ColumnLevel → only the ColumnLevel DAR comes back.
var byColumnAccess =
SdkClients.adminClient()
.tasks()
.listDataAccessRequests(
Map.of("dataset", tableFqn, "accessType", "ColumnLevel", "limit", "50"));
List<String> columnIds =
byColumnAccess.getData().stream().map(t -> t.getId().toString()).toList();
assertTrue(columnIds.contains(openColumn.getId().toString()));
assertFalse(columnIds.contains(openFull.getId().toString()));
assertFalse(columnIds.contains(approvedFull.getId().toString()));

// Filter by status=Approved → only the approved DAR comes back.
var byApproved =
SdkClients.adminClient()
.tasks()
.listDataAccessRequests(
Map.of("dataset", tableFqn, "status", "Approved", "limit", "50"));
List<String> approvedIds =
byApproved.getData().stream().map(t -> t.getId().toString()).toList();
assertEquals(List.of(approvedFull.getId().toString()), approvedIds);

// sortOrder=asc → oldest first; reverse of default DESC. Both lists span the same scope.
var ascending =
SdkClients.adminClient()
.tasks()
.listDataAccessRequests(Map.of("dataset", tableFqn, "sortOrder", "asc", "limit", "50"));
var descending =
SdkClients.adminClient()
.tasks()
.listDataAccessRequests(
Map.of("dataset", tableFqn, "sortOrder", "desc", "limit", "50"));
List<String> ascIds = ascending.getData().stream().map(t -> t.getId().toString()).toList();
List<String> descIds = descending.getData().stream().map(t -> t.getId().toString()).toList();
assertEquals(ascIds.size(), descIds.size());
// The first id of the ascending list is the last id of the descending list and vice versa.
assertEquals(ascIds.get(0), descIds.get(descIds.size() - 1));
assertEquals(ascIds.get(ascIds.size() - 1), descIds.get(0));
}

@Test
void darListEndpointFiltersByApprover(TestNamespace ns) throws Exception {
String tableFqn = createTargetTable(ns);
Task created =
SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess"));
Task approved =
SdkClients.adminClient()
.tasks()
.resolve(
created.getId().toString(),
new ResolveTask().withTransitionId("approve").withComment("approved"));

String approverId = approved.getApprovedById();
assertNotNull(approverId, "approvedById must be captured on approve");

var byApprover =
SdkClients.adminClient()
.tasks()
.listDataAccessRequests(
Map.of("dataset", tableFqn, "approverId", approverId, "limit", "50"));
List<String> ids = byApprover.getData().stream().map(t -> t.getId().toString()).toList();
assertTrue(ids.contains(approved.getId().toString()));
// A DAR that was never approved by the same user must not appear.
Task openDar =
SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess"));
var byApproverAgain =
SdkClients.adminClient()
.tasks()
.listDataAccessRequests(
Map.of("dataset", tableFqn, "approverId", approverId, "limit", "50"));
List<String> idsAgain =
byApproverAgain.getData().stream().map(t -> t.getId().toString()).toList();
assertFalse(idsAgain.contains(openDar.getId().toString()));
}

@Test
void darListEndpointExcludesNonDarTaskTypes(TestNamespace ns) throws Exception {
// Verifies that /v1/tasks/dataAccessRequests pre-scopes to category=DataAccess +
// type=DataAccessRequest so non-DAR tasks (e.g. a description-update task) never appear.
String tableFqn = createTargetTable(ns);

Task dar = SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess"));

// Create a non-DAR task about the same entity.
CreateTask nonDar =
new CreateTask()
.withName(ns.prefix("non-dar-task"))
.withCategory(TaskCategory.MetadataUpdate)
.withType(TaskEntityType.DescriptionUpdate)
.withAbout(tableEntityLink(tableFqn))
.withPayload(Map.of("newDescription", "test"));
Task descTask = SdkClients.adminClient().tasks().create(nonDar);

var listed =
SdkClients.adminClient()
.tasks()
.listDataAccessRequests(Map.of("dataset", tableFqn, "limit", "50"));
List<String> ids = listed.getData().stream().map(t -> t.getId().toString()).toList();
assertTrue(ids.contains(dar.getId().toString()));
assertFalse(ids.contains(descTask.getId().toString()));
}
}
Loading
Loading