Skip to content

Commit 80375a7

Browse files
anuj-kumaryharshachgithub-actions[bot]claudeyan-3005
authored
Add data access request support (open-metadata#27879)
* Add DAR tasks * Removed UI related changes of DAR * nit * Update generated TypeScript types * fix linting issue * Removed all languages changes * nit * removed white space * add request data access button with owner/status conditions * fix lint issue * fix minor validation for data access button * fix lint issue * fix data access button visiable condition * fix java lint checks and fix test cases * nit * fix test * fix(tasks): model CreateTask.about as entityLink, validate target entity Replace `about` (FQN string) + `aboutType` (string) with a single `about` field of type entityLink (`<#E::{entityType}::{fqn}>`). The resource layer parses the link and resolves it via `Entity.getEntityReferenceByName(type, fqn, NON_DELETED)`, which guarantees the target asset exists and is not soft-deleted. Why: long-FQN data assets were rejected with `[query param name size must be between 1 and 256]` because the modal was constructing a Task `name` from the FQN. The `about` was modelled as a free string with no schema validation that the target was a real, non-deleted entity. The Threads API already uses entityLink for this exact purpose; tasks now align with that pattern. The link is supplied as a hidden field by the UI — users never see it. Also fixes the missing `@ExtendWith(TestNamespaceExtension.class)` on `DataAccessRequestIT` that caused four test failures in CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix unit test failure * fix(test): await workflow stage transition in DataAccessRequestIT The workflow advances the task from pending-workflow-start to review asynchronously. Asserting on the object returned by create() was a race condition. Use Awaitility to poll until the stage is review, matching the pattern in IncidentTaskIntegrationIT. --------- Co-authored-by: Sriharsha Chintalapani <harsha@getcollate.io> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Ram Narayan Balaji <ramnarayanb3005@gmail.com> Co-authored-by: Ram Narayan Balaji <81347100+yan-3005@users.noreply.github.com>
1 parent b12506f commit 80375a7

60 files changed

Lines changed: 1103 additions & 360 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ingestion/src/metadata/ingestion/ometa/task_models.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,7 @@ class CreateTaskRequest(BaseModel):
171171
category: TaskCategory
172172
type: TaskEntityType
173173
priority: Optional[TaskPriority] = None # noqa: UP045
174-
about: Optional[str] = None # noqa: UP045
175-
aboutType: Optional[str] = None # noqa: N815, UP045
174+
about: Optional[basic.EntityLink] = None # noqa: UP045
176175
domain: Optional[str] = None # noqa: UP045
177176
assignees: Optional[List[str]] = None # noqa: UP006, UP045
178177
reviewers: Optional[List[str]] = None # noqa: UP006, UP045

ingestion/tests/integration/ometa/test_ometa_suggestion_api.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,7 @@ def _create_description_suggestion_task(metadata: OpenMetadata, table: Table, de
153153
description="Create a description suggestion task",
154154
category=TaskCategory.MetadataUpdate,
155155
type=TaskEntityType.Suggestion,
156-
about=table.fullyQualifiedName.root,
157-
aboutType="table",
156+
about=f"<#E::table::{table.fullyQualifiedName.root}>",
158157
payload={
159158
"suggestionType": "Description",
160159
"fieldPath": "description",
@@ -172,8 +171,7 @@ def _create_tag_suggestion_task(metadata: OpenMetadata, table: Table, labels: li
172171
description="Create a tag suggestion task",
173172
category=TaskCategory.MetadataUpdate,
174173
type=TaskEntityType.Suggestion,
175-
about=table.fullyQualifiedName.root,
176-
aboutType="table",
174+
about=f"<#E::table::{table.fullyQualifiedName.root}>",
177175
payload={
178176
"suggestionType": "Tag",
179177
"fieldPath": "tags",

ingestion/tests/unit/metadata/ingestion/ometa/test_task_announcement_feed_mixins.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,7 @@ def test_create_and_resolve_task(self):
115115
name="task-client-create",
116116
category=TaskCategory.MetadataUpdate,
117117
type=TaskEntityType.Suggestion,
118-
about="sample.table",
119-
aboutType="table",
118+
about="<#E::table::sample.table>",
120119
payload={"fieldPath": "description"},
121120
)
122121
resolve_request = ResolveTaskRequest(
@@ -525,8 +524,7 @@ def test_task_models_validate_nested_payloads(self):
525524
category=TaskCategory.MetadataUpdate,
526525
type=TaskEntityType.Suggestion,
527526
priority=TaskPriority.High,
528-
about="sample.table",
529-
aboutType="table",
527+
about="<#E::table::sample.table>",
530528
domain="Marketing",
531529
assignees=["owner"],
532530
reviewers=["reviewer"],

openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeSummaryResourceIT.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,7 @@ private Task createSuggestionTask(
348348
.withDescription("Change summary suggestion")
349349
.withCategory(TaskCategory.MetadataUpdate)
350350
.withType(TaskEntityType.Suggestion)
351-
.withAbout(entityFqn)
352-
.withAboutType(aboutType)
351+
.withAbout(String.format("<#E::%s::%s>", aboutType, entityFqn))
353352
.withAssignees(List.of(assignee))
354353
.withPayload(
355354
Map.of(
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/*
2+
* Copyright 2026 Collate
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package org.openmetadata.it.tests;
16+
17+
import static org.awaitility.Awaitility.await;
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertFalse;
20+
import static org.junit.jupiter.api.Assertions.assertNotNull;
21+
import static org.junit.jupiter.api.Assertions.assertThrows;
22+
import static org.junit.jupiter.api.Assertions.assertTrue;
23+
24+
import java.time.Duration;
25+
import java.util.List;
26+
import java.util.Map;
27+
import java.util.concurrent.atomic.AtomicReference;
28+
import org.junit.jupiter.api.Test;
29+
import org.junit.jupiter.api.extension.ExtendWith;
30+
import org.junit.jupiter.api.parallel.Execution;
31+
import org.junit.jupiter.api.parallel.ExecutionMode;
32+
import org.openmetadata.it.factories.DatabaseSchemaTestFactory;
33+
import org.openmetadata.it.factories.DatabaseServiceTestFactory;
34+
import org.openmetadata.it.factories.TableTestFactory;
35+
import org.openmetadata.it.util.SdkClients;
36+
import org.openmetadata.it.util.TestNamespace;
37+
import org.openmetadata.it.util.TestNamespaceExtension;
38+
import org.openmetadata.schema.api.tasks.CreateTask;
39+
import org.openmetadata.schema.api.tasks.ResolveTask;
40+
import org.openmetadata.schema.entity.data.DatabaseSchema;
41+
import org.openmetadata.schema.entity.data.Table;
42+
import org.openmetadata.schema.entity.feed.TaskFormSchema;
43+
import org.openmetadata.schema.entity.services.DatabaseService;
44+
import org.openmetadata.schema.entity.tasks.Task;
45+
import org.openmetadata.schema.governance.workflows.WorkflowDefinition;
46+
import org.openmetadata.schema.type.TaskAvailableTransition;
47+
import org.openmetadata.schema.type.TaskCategory;
48+
import org.openmetadata.schema.type.TaskEntityStatus;
49+
import org.openmetadata.schema.type.TaskEntityType;
50+
import org.openmetadata.schema.type.TaskPriority;
51+
import org.openmetadata.schema.type.TaskResolutionType;
52+
import org.openmetadata.sdk.exceptions.InvalidRequestException;
53+
54+
/**
55+
* Integration tests for the Data Access Request task type.
56+
*
57+
* <p>Exercises the full lifecycle through the REST API:
58+
*
59+
* <ul>
60+
* <li>Seed: DataAccessRequest form schema and DataAccessRequestTaskWorkflow are loaded on boot.
61+
* <li>Create: POST /tasks with category=DataAccess, type=DataAccessRequest and an
62+
* accessType+reason payload succeeds and lands the task at the "review" stage.
63+
* <li>Approve: /resolve transitions the task to status=InProgress, stage="approved",
64+
* and surfaces a "revoke" available transition (matches the IncidentResolution pattern).
65+
* <li>Revoke: /resolve from the approved stage closes the task with status=Revoked and
66+
* resolution.type=Revoked.
67+
* <li>Reject: alternative terminal path lands at status=Rejected.
68+
* <li>Validation: missing required fields (accessType/reason) are rejected by the form
69+
* schema validator.
70+
* </ul>
71+
*/
72+
@Execution(ExecutionMode.CONCURRENT)
73+
@ExtendWith(TestNamespaceExtension.class)
74+
public class DataAccessRequestIT {
75+
76+
private static final String DAR_FORM_SCHEMA_NAME = "DataAccessRequest";
77+
private static final String DAR_WORKFLOW_NAME = "DataAccessRequestTaskWorkflow";
78+
79+
private static String createTargetTable(TestNamespace ns) {
80+
DatabaseService service = DatabaseServiceTestFactory.createPostgres(ns);
81+
DatabaseSchema dbSchema = DatabaseSchemaTestFactory.createSimple(ns, service);
82+
Table table = TableTestFactory.createSimple(ns, dbSchema.getFullyQualifiedName());
83+
84+
return table.getFullyQualifiedName();
85+
}
86+
87+
private static String tableEntityLink(String tableFqn) {
88+
return String.format("<#E::table::%s>", tableFqn);
89+
}
90+
91+
private static CreateTask buildDarRequest(TestNamespace ns, String tableFqn, String accessType) {
92+
return new CreateTask()
93+
.withName(ns.prefix("dar-task"))
94+
.withDisplayName("Test DAR")
95+
.withCategory(TaskCategory.DataAccess)
96+
.withType(TaskEntityType.DataAccessRequest)
97+
.withPriority(TaskPriority.Medium)
98+
.withAbout(tableEntityLink(tableFqn))
99+
.withPayload(
100+
Map.of(
101+
"accessType", accessType,
102+
"requestedAccess", "Read",
103+
"reason", "Need access for IT test",
104+
"duration", "P14D"));
105+
}
106+
107+
@Test
108+
void darFormSchemaIsSeeded() {
109+
TaskFormSchema schema =
110+
SdkClients.adminClient().taskFormSchemas().getByName(DAR_FORM_SCHEMA_NAME);
111+
112+
assertNotNull(schema, "DataAccessRequest form schema must be seeded on boot");
113+
assertEquals(TaskEntityType.DataAccessRequest.value(), schema.getTaskType());
114+
assertEquals(TaskCategory.DataAccess.value(), schema.getTaskCategory());
115+
assertNotNull(schema.getTransitionForms());
116+
assertTrue(
117+
schema.getTransitionForms().getAdditionalProperties().containsKey("approve"),
118+
"approve transition form must exist");
119+
assertTrue(
120+
schema.getTransitionForms().getAdditionalProperties().containsKey("reject"),
121+
"reject transition form must exist");
122+
assertTrue(
123+
schema.getTransitionForms().getAdditionalProperties().containsKey("revoke"),
124+
"revoke transition form must exist");
125+
}
126+
127+
@Test
128+
void darWorkflowDefinitionIsSeeded() {
129+
WorkflowDefinition workflow =
130+
SdkClients.adminClient().workflowDefinitions().getByName(DAR_WORKFLOW_NAME);
131+
132+
assertNotNull(workflow, "DataAccessRequestTaskWorkflow must be seeded on boot");
133+
List<String> nodeNames = workflow.getNodes().stream().map(n -> n.getName()).toList();
134+
assertTrue(nodeNames.contains("TaskReview"));
135+
assertTrue(nodeNames.contains("ApprovedAccess"));
136+
assertTrue(nodeNames.contains("RejectedEnd"));
137+
assertTrue(nodeNames.contains("RevokedEnd"));
138+
}
139+
140+
@Test
141+
void createApproveAndRevokeLifecycle(TestNamespace ns) {
142+
String tableFqn = createTargetTable(ns);
143+
144+
Task created =
145+
SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess"));
146+
147+
AtomicReference<Task> reviewTaskRef = new AtomicReference<>();
148+
await()
149+
.atMost(Duration.ofSeconds(20))
150+
.pollInterval(Duration.ofMillis(250))
151+
.untilAsserted(
152+
() -> {
153+
Task t =
154+
SdkClients.adminClient()
155+
.tasks()
156+
.get(
157+
created.getId().toString(),
158+
"status,workflowStageId,availableTransitions");
159+
assertEquals(TaskEntityStatus.Open, t.getStatus());
160+
assertEquals("review", t.getWorkflowStageId());
161+
List<String> transitions =
162+
t.getAvailableTransitions().stream().map(TaskAvailableTransition::getId).toList();
163+
assertTrue(transitions.contains("approve"));
164+
assertTrue(transitions.contains("reject"));
165+
reviewTaskRef.set(t);
166+
});
167+
168+
Task reviewed = reviewTaskRef.get();
169+
170+
// Approve → moves to ApprovedAccess userTask. Status stays non-terminal so the workflow
171+
// can continue to a Revoke transition (matches IncidentResolution pattern).
172+
Task approved =
173+
SdkClients.adminClient()
174+
.tasks()
175+
.resolve(
176+
reviewed.getId().toString(),
177+
new ResolveTask().withTransitionId("approve").withComment("approved"));
178+
179+
assertEquals(TaskEntityStatus.InProgress, approved.getStatus());
180+
assertEquals("approved", approved.getWorkflowStageId());
181+
List<String> approvedTransitions =
182+
approved.getAvailableTransitions().stream().map(TaskAvailableTransition::getId).toList();
183+
assertEquals(List.of("revoke"), approvedTransitions);
184+
185+
// Revoke → terminal Revoked status with resolution.
186+
Task revoked =
187+
SdkClients.adminClient()
188+
.tasks()
189+
.resolve(
190+
approved.getId().toString(),
191+
new ResolveTask().withTransitionId("revoke").withComment("revoking"));
192+
193+
assertEquals(TaskEntityStatus.Revoked, revoked.getStatus());
194+
assertNotNull(revoked.getResolution());
195+
assertEquals(TaskResolutionType.Revoked, revoked.getResolution().getType());
196+
assertTrue(revoked.getAvailableTransitions().isEmpty());
197+
}
198+
199+
@Test
200+
void rejectLandsAtTerminalRejectedStatus(TestNamespace ns) {
201+
String tableFqn = createTargetTable(ns);
202+
Task created =
203+
SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "ColumnLevel"));
204+
205+
Task rejected =
206+
SdkClients.adminClient()
207+
.tasks()
208+
.resolve(
209+
created.getId().toString(),
210+
new ResolveTask().withTransitionId("reject").withComment("not justified"));
211+
212+
assertEquals(TaskEntityStatus.Rejected, rejected.getStatus());
213+
assertEquals(TaskResolutionType.Rejected, rejected.getResolution().getType());
214+
assertFalse(
215+
rejected.getAvailableTransitions().stream().anyMatch(t -> "revoke".equals(t.getId())));
216+
}
217+
218+
@Test
219+
void columnLevelPayloadStoresColumns(TestNamespace ns) {
220+
String tableFqn = createTargetTable(ns);
221+
222+
CreateTask req =
223+
new CreateTask()
224+
.withName(ns.prefix("dar-cols"))
225+
.withCategory(TaskCategory.DataAccess)
226+
.withType(TaskEntityType.DataAccessRequest)
227+
.withAbout(tableEntityLink(tableFqn))
228+
.withPayload(
229+
Map.of(
230+
"accessType", "ColumnLevel",
231+
"columns", List.of(tableFqn + ".id", tableFqn + ".name"),
232+
"reason", "Need a couple of columns",
233+
"duration", "P7D"));
234+
235+
Task created = SdkClients.adminClient().tasks().create(req);
236+
237+
Map<?, ?> payload = (Map<?, ?>) created.getPayload();
238+
assertEquals("ColumnLevel", payload.get("accessType"));
239+
assertEquals(2, ((List<?>) payload.get("columns")).size());
240+
}
241+
242+
@Test
243+
void missingAccessTypeIsRejectedByFormSchema(TestNamespace ns) {
244+
String tableFqn = createTargetTable(ns);
245+
246+
CreateTask invalid =
247+
new CreateTask()
248+
.withName(ns.prefix("dar-invalid"))
249+
.withCategory(TaskCategory.DataAccess)
250+
.withType(TaskEntityType.DataAccessRequest)
251+
.withAbout(tableEntityLink(tableFqn))
252+
// accessType missing — required by both the JSON Schema payload
253+
// (dataAccessRequestPayload.json) and the seeded form schema.
254+
.withPayload(Map.of("reason", "I need it"));
255+
256+
assertThrows(
257+
InvalidRequestException.class, () -> SdkClients.adminClient().tasks().create(invalid));
258+
}
259+
}

0 commit comments

Comments
 (0)