diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 74f72ed..df5b6a6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,7 +26,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.25.0' + go-version: '1.26.5' - name: Install dependencies run: go mod download @@ -40,7 +40,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 with: - version: v2.4.0 + version: v2.12.2 # Every green build of main is tagged with the next patch version. # The release workflow then publishes a chosen tag on demand. diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 43d825e..b2e9efe 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -50,7 +50,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.25.0' + go-version: '1.26.5' - name: Install dependencies run: go mod download diff --git a/README.md b/README.md index 73e1752..9165679 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,15 @@ This Go-based MCP server acts as a bridge between AI applications and Collibra, ### Read Tools +- [`check_user_data_object_access`](pkg/tools/check_user_data_object_access/) - Check whether a user has access to one or more data objects (by ID) and through which roles (access controls). Defaults to the current user - [`discover_business_glossary`](pkg/tools/discover_business_glossary/) - Ask questions about terms and definitions. Note that this tool leverages Collibra AI and therefore consumes Collibra Units (CUs). **Requires:** `dgc.ai-copilot` - [`discover_data_assets`](pkg/tools/discover_data_assets/) - Query available data assets using natural language. Note that this tool leverages Collibra AI and therefore consumes Collibra Units (CUs). **Requires:** `dgc.ai-copilot` - [`get_assessment`](pkg/tools/get_assessment/) - Retrieve conducted assessment(s) from the Assessments application (these are not catalog assets). Direct lookup of a single assessment by name or UUID (or by its linked Assessment Review asset), or a filtered lookup combining name (partial), status, template, conducted asset, and a last-modified range (paginated) - [`get_asset_details`](pkg/tools/get_asset_details/) - Retrieve detailed information about specific assets by UUID, including the asset's assignable attribute schema (every attribute it can hold, including empty ones) - [`get_business_term_data`](pkg/tools/get_business_term_data/) - Trace a business term back to its connected physical data assets - [`get_column_semantics`](pkg/tools/get_column_semantics/) - Retrieve data attributes, measures, and business assets connected to a column +- [`get_data_access_control_details`](pkg/tools/get_data_access_control_details/) - Retrieve detailed information about a specific Collibra Data Access control by its id +- [`get_data_access_data_source`](pkg/tools/get_data_access_data_source/) - Fetch a Collibra Data Access data source by ID, resolving an opaque data source ID to its name, type, and description - [`get_data_quality_rule`](pkg/tools/get_dq_rule/) - Read the definition of a single DQ rule (monitor) on a job — its type, SQL, filter, tolerance and active/suppressed state - [`get_data_quality_rule_results`](pkg/tools/get_dq_rule_results/) - Read a rule's per-run results after a job run — score, breaking/passing record counts, pass/fail status and any exception. Paginated (`offset`/`limit`), newest first by default - [`validate_data_quality_rule`](pkg/tools/validate_dq_rule/) - Validate a rule's SQL/definition against the source before saving or running it, so a malformed rule is caught up front. Returns whether the rule is valid plus the engine's message. Requires `edgeSiteId`/`connectionId`/`schemaName` (from `prepare_create_data_quality_job`) @@ -35,6 +38,8 @@ This Go-based MCP server acts as a bridge between AI applications and Collibra, - [`pull_data_contract_manifest`](pkg/tools/pull_data_contract_manifest/) - Download manifest for a data contract - [`search_asset_keyword`](pkg/tools/search_asset_keyword/) - Wildcard keyword search for assets; filters (status, community, domain, domain type, asset type, created-by) accept names or UUIDs - [`search_catalog_columns`](pkg/tools/search_catalog_columns/) - Find catalog Column assets by metadata that keyword search can't filter on — Description/Data Type (attribute values), a Data Steward role, or relations to a Business Term/Business Rule/Data Element/Data Attribute (by name); AND-combined. Uses the DGC Knowledge Graph GraphQL API (must be enabled on the instance). Classification-tag filtering is not supported +- [`search_data_access_identities`](pkg/tools/search_data_access_identities/) - Search for Data Access users (identities) by name and/or email +- [`search_data_access_objects`](pkg/tools/search_data_access_objects/) - Search for data objects in Collibra Data Access (tables, columns, schemas, views, and other entities tracked in registered data sources) - [`search_data_class`](pkg/tools/search_data_classes/) - Search for data classes with filters. **Requires:** `dgc.data-classes-read` - [`search_data_classification_match`](pkg/tools/search_data_classification_matches/) - Search for associations between data classes and assets. **Requires:** `dgc.classify`, `dgc.catalog` - [`search_lineage_entities`](pkg/tools/search_lineage_entities/) - Search for entities in the technical lineage graph @@ -45,6 +50,7 @@ This Go-based MCP server acts as a bridge between AI applications and Collibra, - [`add_data_classification_match`](pkg/tools/add_data_classification_match/) - Associate a data class with an asset. **Requires:** `dgc.classify`, `dgc.catalog` - [`create_assessment`](pkg/tools/create_assessment/) - Conduct a new assessment from a template (given by name or UUID) in the Assessments application. Returns the template's (unanswered) questions to fill in afterward with `edit_assessment` — no separate prepare step needed - [`create_asset`](pkg/tools/create_asset/) - Create a new asset of any type. Resolves `assetType` (UUID, publicId, or display name), `domain` (UUID or name), `status` (UUID or name), and attributes (by name or typeId) server-side; converts Markdown to HTML for `RICH_TEXT` attributes; gates on duplicate-name (default `allowDuplicate: false`) +- [`create_data_access_request`](pkg/tools/create_data_access_request/) - Create a new Collibra Data Access request on behalf of one or more users for one or more data objects - [`create_data_quality_rule`](pkg/tools/create_dq_rule/) - Create a data quality rule (monitor) on an existing DQ job. `monitorType` is `FREEFORM_SQL` (full SQL query) or `SIMPLE_SQL` (single-column check); defaults to active and not suppressed. Confirm checkpoint: `confirm=false` (default) returns a preview of the rule + SQL without creating; `confirm=true` creates. Uses the DQ monitoring API and requires permission to create rules on the target job. **Experimental** (`data-quality` feature flag) - [`deploy_data_quality_rule_template`](pkg/tools/deploy_dq_rule_template/) - Instantiate a rule template as concrete rules across one or more job/column targets (bulk). The DQ service resolves dialect-specific SQL and names each rule `{templateName}_{columnName}`. Confirm checkpoint: `confirm=false` (default) previews the template + targets without deploying; `confirm=true` deploys. Requires permission to deploy templates and create rules on the target jobs. **Experimental** (`data-quality` feature flag) - [`edit_assessment`](pkg/tools/edit_assessment/) - Edit a conducted assessment (identified by name or UUID) via a list of typed operations, applied as a single atomic PATCH (all-or-nothing): diff --git a/SKILLS.md b/SKILLS.md index 2c9e872..cf16fa3 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -10,13 +10,14 @@ those tools. Skill content lives in [`pkg/skills/files/collibra/`](pkg/skills/fi ## Current skills -| Name | Topic | -|---|---| -| `collibra/index` | Navigator — start here when unsure which skill applies | -| `collibra/discovery` | Semantic vs keyword search; resolving names to UUIDs | -| `collibra/lineage` | Technical lineage; DGC UUID ↔ lineage entity ID bridge; column-level workaround | +| Name | Topic | +|-------------------------|---| +| `collibra/index` | Navigator — start here when unsure which skill applies | +| `collibra/discovery` | Semantic vs keyword search; resolving names to UUIDs | +| `collibra/lineage` | Technical lineage; DGC UUID ↔ lineage entity ID bridge; column-level workaround | | `collibra/asset-create` | `create_asset` workflow; RICH_TEXT Markdown handling; duplicate gating | -| `collibra/asset-edit` | `edit_asset` operation types | +| `collibra/asset-edit` | `edit_asset` operation types | +| `collibra/data-access` | Manages who can access what data, through grants, masks and filters | Each skill is one `SKILL.md` per directory, with frontmatter (`description`, `related`) and an optional `references/` directory for bundled reference documents. diff --git a/cmd/chip/main.go b/cmd/chip/main.go index 8e4856a..f8e1beb 100644 --- a/cmd/chip/main.go +++ b/cmd/chip/main.go @@ -34,8 +34,8 @@ func main() { EnabledTools: config.Mcp.EnabledTools, DisabledTools: config.Mcp.DisabledTools, EnableDebugTools: config.Mcp.EnableDebugTools, - Experimental: config.Mcp.Experimental, - SkillsDir: config.Mcp.SkillsDir, + Experimental: config.Mcp.Experimental, + SkillsDir: config.Mcp.SkillsDir, } serverOpts := []chip.ServerOption{ diff --git a/go.mod b/go.mod index b661198..3d3c3ab 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ module github.com/collibra/chip -go 1.25.0 +go 1.26.5 require ( + github.com/collibra/data-access-go-sdk v0.0.71 github.com/google/go-querystring v1.2.0 github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 @@ -13,8 +14,12 @@ require ( ) require ( + github.com/Khan/genqlient v0.8.2-0.20260527022710-6bbde3684dd6 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect @@ -23,10 +28,11 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/vektah/gqlparser/v2 v2.5.36 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.34.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 41bffe3..f358e74 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,15 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/Khan/genqlient v0.8.2-0.20260527022710-6bbde3684dd6 h1:Y+TbviEJfdsIS2jwgSLkSK2t0O1eVaRsvtW5oPQ9it4= +github.com/Khan/genqlient v0.8.2-0.20260527022710-6bbde3684dd6/go.mod h1:guUHwMi8ByjIvs3TyAPM+V9ryaW305CtK7+aCeP2Jzc= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/collibra/data-access-go-sdk v0.0.70 h1:1GMfb1vhNc2IXpUqTyibutfBqqjSKDWUzldiZywR2Y8= +github.com/collibra/data-access-go-sdk v0.0.70/go.mod h1:oT1RYAOH5ZVZzjo4ut96QjtZLshqBXIieeVOstVyRTw= +github.com/collibra/data-access-go-sdk v0.0.71 h1:r6sQJbQa0ICRBMwBVxX/aVJPqKvNq9s4SwM7EU/0Ic8= +github.com/collibra/data-access-go-sdk v0.0.71/go.mod h1:oT1RYAOH5ZVZzjo4ut96QjtZLshqBXIieeVOstVyRTw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -17,20 +27,28 @@ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+ github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= @@ -49,6 +67,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= +github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= @@ -61,8 +81,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/clients/data_access_client.go b/pkg/clients/data_access_client.go new file mode 100644 index 0000000..887c819 --- /dev/null +++ b/pkg/clients/data_access_client.go @@ -0,0 +1,707 @@ +package clients + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/collibra/chip/pkg/chip" + sdk "github.com/collibra/data-access-go-sdk" + "github.com/collibra/data-access-go-sdk/services" + "github.com/collibra/data-access-go-sdk/types" +) + +// DataAccessControlDetails holds the details of a single data access control. +type DataAccessControlDetails struct { + ID string `json:"id" jsonschema:"Unique identifier of the access control"` + Name string `json:"name" jsonschema:"Name of the access control"` + Description string `json:"description" jsonschema:"Detailed description of the access control"` + State string `json:"state" jsonschema:"State of the access control: ACTIVE, INACTIVE, or DELETED"` + Action string `json:"action" jsonschema:"Action type of the access control: GRANT, MASK, FILTER, SHARE, GROUP, or FILTERRULE"` + Category *DataAccessGrantCategory `json:"category,omitempty" jsonschema:"Grant category details, present only for GRANT action type"` + External bool `json:"external" jsonschema:"Whether the access control is managed externally in the data source rather than in Collibra Data Access"` + NamingHint *string `json:"namingHint,omitempty" jsonschema:"Naming hint used for generating names in target systems"` + PolicyRule *string `json:"policyRule,omitempty" jsonschema:"Policy rule string, used for imported row-level filters and column masks"` + NotInternalizable bool `json:"notInternalizable" jsonschema:"Whether the external access control cannot be internalized"` + Complete *bool `json:"complete,omitempty" jsonschema:"Whether the external access control is complete (all linked entities known in Collibra Data Access)"` + WhatUnknown bool `json:"whatUnknown" jsonschema:"Whether the WHAT scope of this access control could not be parsed on import"` + WhoUnknown bool `json:"whoUnknown" jsonschema:"Whether the WHO scope of this access control could not be parsed on import"` + CreatedAt time.Time `json:"createdAt" jsonschema:"Timestamp when the access control was created"` + ModifiedAt time.Time `json:"modifiedAt" jsonschema:"Timestamp when the access control was last modified"` + What []DataAccessWhatItem `json:"what" jsonschema:"List of access controls that this control applies to (the WHAT scope)"` + Who []DataAccessWhoItem `json:"who" jsonschema:"List of principals (users, access controls, data sources) that are granted access by this control"` + SyncData []DataAccessSyncData `json:"syncData" jsonschema:"Synchronization status per linked data source. Valid sync statuses: Notconnected, Failed, Outofdate, Inprogress, Synced, Outofsync."` + Url string `json:"url" jsonschema:"Url in the Collibra UI to view access control"` +} + +// DataAccessSyncData holds the sync status of an access control for a single data source. +type DataAccessSyncData struct { + DataSourceID string `json:"dataSourceId" jsonschema:"Unique identifier of the linked data source"` + DataSourceName string `json:"dataSourceName" jsonschema:"Name of the linked data source"` + SyncStatus string `json:"syncStatus" jsonschema:"Sync status for this data source. Valid values: Notconnected, Failed, Outofdate, Inprogress, Synced, Outofsync."` +} + +// DataAccessWhatItem represents a single entry in the WHAT list of an access control — +// another access control that this one applies to. +type DataAccessWhatItem struct { + ID string `json:"id" jsonschema:"Unique identifier of the access control in the WHAT list"` + Name string `json:"name" jsonschema:"Name of the access control in the WHAT list"` + State string `json:"state" jsonschema:"State of the access control: Active, Inactive, or Deleted"` + Action string `json:"action" jsonschema:"Action type of the access control: Grant, Mask, Filter, Share, Group, or FilterRule"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" jsonschema:"Optional expiration time for this WHAT entry"` +} + +// DataAccessWhoItem represents a single entry in the WHO list of an access control. +type DataAccessWhoItem struct { + // Type is either "WhoGrant" (direct access) or "WhoPromise" (pre-approved access on request). + Type string `json:"type" jsonschema:"Grant type: WhoGrant (direct access) or WhoPromise (pre-approved on request)"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" jsonschema:"Optional expiration time for this WHO entry"` + PromiseDuration *int64 `json:"promiseDuration,omitempty" jsonschema:"For WhoPromise: duration in seconds of the grant when access is requested"` + // ItemType is the GraphQL typename of the item: User, AccessControl, DataShareRecipient, DataSource. + ItemType string `json:"itemType" jsonschema:"Type of the granted principal: User, AccessControl, DataShareRecipient, or DataSource"` + ItemID string `json:"itemId,omitempty" jsonschema:"ID of the granted principal (present for User and AccessControl item types)"` + ItemName string `json:"itemName,omitempty" jsonschema:"Display name of the granted principal (present for User and AccessControl item types)"` + Email *string `json:"email,omitempty" jsonschema:"Email address of the user (present for User item type only)"` + UserType string `json:"userType,omitempty" jsonschema:"Whether the user is a Human or Machine user (present for User item type only)"` +} + +// DataAccessGrantCategory holds the details of a grant category. +type DataAccessGrantCategory struct { + ID string `json:"id" jsonschema:"Unique identifier of the grant category"` + Name string `json:"name" jsonschema:"Display name of the grant category"` + NamePlural string `json:"namePlural" jsonschema:"Plural display name of the grant category"` + IsSystem bool `json:"isSystem" jsonschema:"Whether this grant category is system-defined and cannot be edited or removed"` + IsDefault bool `json:"isDefault" jsonschema:"Whether this is the default grant category for new access controls"` +} + +// GetDataAccessControl retrieves a single data access control by ID. +// It creates an sdk.CollibraClient using chip's existing HTTP client via sdk.WithHTTPClient, +// so URL routing and authentication are handled by chip's RoundTripper. +func GetDataAccessControl(ctx context.Context, httpClient *http.Client, id string) (*DataAccessControlDetails, error) { + collibraHost, ok := chip.GetCollibraHost(ctx) + if !ok { + return nil, fmt.Errorf("collibra host not configured in context") + } + dataAccessURL := strings.TrimSuffix(collibraHost, "/") + "/dataAccess" + + collibraClient, err := sdk.NewClient(dataAccessURL, sdk.WithHTTPClient(httpClient)) + if err != nil { + return nil, fmt.Errorf("failed to create data access client: %w", err) + } + + accessControlClient := collibraClient.AccessControl() + + ac, err := accessControlClient.GetAccessControl(ctx, id) + if err != nil { + return nil, err + } + + details := mapToDataAccessControlDetails(ctx, ac) + + for whatItem, err := range accessControlClient.GetAccessControlWhatAccessControlList(ctx, id) { + if err != nil { + return nil, fmt.Errorf("failed to retrieve what list: %w", err) + } + details.What = append(details.What, mapToDataAccessWhatItem(whatItem)) + } + + for whoItem, err := range accessControlClient.GetAccessControlWhoList(ctx, id) { + if err != nil { + return nil, fmt.Errorf("failed to retrieve who list: %w", err) + } + details.Who = append(details.Who, mapToDataAccessWhoItem(whoItem)) + } + + return details, nil +} + +func mapToDataAccessWhatItem(w *types.AccessWhatAccessControlItem) DataAccessWhatItem { + item := DataAccessWhatItem{ + ExpiresAt: w.ExpiresAt, + } + if w.AccessControl != nil { + item.ID = w.AccessControl.Id + item.Name = w.AccessControl.Name + item.State = string(w.AccessControl.State) + item.Action = string(w.AccessControl.Action) + } + return item +} + +func mapToDataAccessWhoItem(w *types.AccessWhoItem) DataAccessWhoItem { + item := DataAccessWhoItem{ + Type: string(w.Type), + ExpiresAt: w.ExpiresAt, + PromiseDuration: w.PromiseDuration, + } + + switch v := w.Item.(type) { + case *types.AccessWhoItemItemUser: + item.ItemType = "User" + item.ItemID = v.Id + item.ItemName = v.Name + item.Email = v.Email + item.UserType = string(v.Type) + case *types.AccessWhoItemItemAccessControl: + item.ItemType = "AccessControl" + item.ItemID = v.Id + item.ItemName = v.Name + case *types.AccessWhoItemItemDataShareRecipient: + item.ItemType = "DataShareRecipient" + case *types.AccessWhoItemItemDataSource: + item.ItemType = "DataSource" + default: + if v != nil { + if t := w.Item.GetTypename(); t != nil { + item.ItemType = *t + } + } + } + + return item +} + +// DataAccessDataSource holds the details of a single Data Access data source. +type DataAccessDataSource struct { + ID string `json:"id" jsonschema:"Unique identifier of the data source"` + Name string `json:"name" jsonschema:"Display name of the data source"` + Type string `json:"type" jsonschema:"Type identifier of the data source, set by the connector during a sync (e.g. snowflake, databricks, bigquery)"` + Description string `json:"description" jsonschema:"Description of the data source"` + ParentID string `json:"parentId,omitempty" jsonschema:"Identifier of the parent data source, when this data source has one"` + CreatedAt time.Time `json:"createdAt" jsonschema:"Timestamp when the data source was created"` + ModifiedAt time.Time `json:"modifiedAt" jsonschema:"Timestamp when the data source was last modified"` + Url string `json:"url" jsonschema:"Url in the Collibra UI to view data source"` +} + +// GetDataAccessDataSource retrieves a single Data Access data source by ID. +// It creates an sdk.CollibraClient using chip's existing HTTP client via sdk.WithHTTPClient, +// so URL routing and authentication are handled by chip's RoundTripper. +func GetDataAccessDataSource(ctx context.Context, httpClient *http.Client, id string) (*DataAccessDataSource, error) { + collibraHost, ok := chip.GetCollibraHost(ctx) + if !ok { + return nil, fmt.Errorf("collibra host not configured in context") + } + dataAccessURL := strings.TrimSuffix(collibraHost, "/") + "/dataAccess" + + collibraClient, err := sdk.NewClient(dataAccessURL, sdk.WithHTTPClient(httpClient)) + if err != nil { + return nil, fmt.Errorf("failed to create data access client: %w", err) + } + + ds, err := collibraClient.DataSource().GetDataSource(ctx, id) + if err != nil { + return nil, err + } + + return mapToDataAccessDataSource(ctx, ds), nil +} + +func mapToDataAccessDataSource(ctx context.Context, ds *types.DataSource) *DataAccessDataSource { + uiURL := buildUiUrl(ctx, "data-sources", ds.Id) + out := &DataAccessDataSource{ + ID: ds.Id, + Name: ds.Name, + Type: ds.Type, + Description: ds.Description, + CreatedAt: ds.CreatedAt, + ModifiedAt: ds.ModifiedAt, + Url: uiURL, + } + if ds.Parent != nil { + out.ParentID = ds.Parent.Id + } + return out +} + +// DataAccessIdentity represents a user in Collibra Data Access. +type DataAccessIdentity struct { + ID string `json:"id" jsonschema:"Unique identifier of the user"` + Name string `json:"name" jsonschema:"Display name of the user"` + Email *string `json:"email,omitempty" jsonschema:"Email address of the user"` + Type string `json:"type" jsonschema:"User type: Human or Machine"` + Url string `json:"url" jsonschema:"Url in the Collibra UI to view identity"` +} + +// SearchDataAccessIdentitiesResult holds a page of identities. +type SearchDataAccessIdentitiesResult struct { + Items []*DataAccessIdentity +} + +// SearchDataAccessIdentities searches for Data Access users by name and/or email. +// When email is provided, an exact lookup via GetUserByEmail is performed. Name is then applied +// as an optional client-side case-insensitive contains filter on the result. +// When only name is provided, ListUsers is called with the Search filter (case-insensitive +// contains). The returned list is capped at pageSize items (default 25). +func SearchDataAccessIdentities(ctx context.Context, httpClient *http.Client, name, email string, pageSize int) (*SearchDataAccessIdentitiesResult, error) { + collibraHost, ok := chip.GetCollibraHost(ctx) + if !ok { + return nil, fmt.Errorf("collibra host not configured in context") + } + dataAccessURL := strings.TrimSuffix(collibraHost, "/") + "/dataAccess" + + collibraClient, err := sdk.NewClient(dataAccessURL, sdk.WithHTTPClient(httpClient)) + if err != nil { + return nil, fmt.Errorf("failed to create data access client: %w", err) + } + + if email != "" { + user, err := collibraClient.User().GetUserByEmail(ctx, email) + if err != nil { + var notFound *types.ErrNotFound + if errors.As(err, ¬Found) { + return &SearchDataAccessIdentitiesResult{Items: []*DataAccessIdentity{}}, nil + } + return nil, err + } + + identity := mapToDataAccessIdentity(ctx, user) + if name != "" && !strings.Contains(strings.ToLower(identity.Name), strings.ToLower(name)) { + return &SearchDataAccessIdentitiesResult{Items: []*DataAccessIdentity{}}, nil + } + return &SearchDataAccessIdentitiesResult{Items: []*DataAccessIdentity{identity}}, nil + } + + filter := &types.UserFilterInput{} + if name != "" { + filter.Search = &name + } + + limit := pageSize + if limit <= 0 { + limit = 25 + } + + result := &SearchDataAccessIdentitiesResult{ + Items: make([]*DataAccessIdentity, 0, limit), + } + for user, iterErr := range collibraClient.User().ListUsers(ctx, services.WithUserListFilter(filter)) { + if iterErr != nil { + return nil, iterErr + } + result.Items = append(result.Items, mapToDataAccessIdentity(ctx, user)) + if len(result.Items) >= limit { + break + } + } + return result, nil +} + +// DataAccessObject represents a single data object in Collibra Data Access. +type DataAccessObject struct { + ID string `json:"id" jsonschema:"Unique identifier of the data object"` + Name string `json:"name" jsonschema:"Name of the data object"` + FullName string `json:"fullName" jsonschema:"Fully qualified name of the data object within its data source"` + Type string `json:"type" jsonschema:"Type of the data object (e.g. table, column, schema, view)"` + DataType *string `json:"dataType,omitempty" jsonschema:"Data type of the object (typically used for columns)"` + Deleted bool `json:"deleted" jsonschema:"Whether the data object is deleted (no longer present in the source)"` + Description string `json:"description" jsonschema:"Description of the data object"` + DataSourceID string `json:"dataSourceId,omitempty" jsonschema:"Identifier of the data source the object belongs to"` + ApplicablePermissions []DataAccessPermission `json:"applicablePermissions,omitempty" jsonschema:"Source-system permissions that can be requested or granted on this data object (and its descendants). Each permission carries its name and description."` + Url string `json:"url" jsonschema:"Url in the Collibra UI to view the data object"` +} + +// DataAccessPermission is a permission that can be set on a data object. +type DataAccessPermission struct { + Name string `json:"name" jsonschema:"Permission name as defined by the data source (e.g. SELECT, INSERT)"` + Description string `json:"description" jsonschema:"Human-readable description of the permission"` +} + +// SearchDataAccessObjectsResult holds a page of data objects. +type SearchDataAccessObjectsResult struct { + Items []*DataAccessObject `json:"items"` +} + +// SearchDataAccessObjects returns a list of data objects matching the supplied filters. +// Name search is case-insensitive contains. The returned list is capped at pageSize items +// (default 25), drawn from the SDK's ListDataObjects iterator. +func SearchDataAccessObjects(ctx context.Context, httpClient *http.Client, name string, dataSources []string, dataObjectTypes []string, parents []string, ancestors []string, includeDeleted bool, pageSize int) (*SearchDataAccessObjectsResult, error) { + collibraHost, ok := chip.GetCollibraHost(ctx) + if !ok { + return nil, fmt.Errorf("collibra host not configured in context") + } + dataAccessURL := strings.TrimSuffix(collibraHost, "/") + "/dataAccess" + + collibraClient, err := sdk.NewClient(dataAccessURL, sdk.WithHTTPClient(httpClient)) + if err != nil { + return nil, fmt.Errorf("failed to create data access client: %w", err) + } + + filter := &types.DataObjectFilterInput{} + if name != "" { + filter.Search = &name + } + if len(dataSources) > 0 { + filter.DataSources = dataSources + } + if len(dataObjectTypes) > 0 { + filter.Types = dataObjectTypes + } + if len(parents) > 0 { + filter.Parents = parents + } + if len(ancestors) > 0 { + filter.Ancestors = ancestors + } + if includeDeleted { + filter.IncludeDeleted = &includeDeleted + } + + limit := pageSize + if limit <= 0 { + limit = 25 + } + + result := &SearchDataAccessObjectsResult{ + Items: make([]*DataAccessObject, 0, limit), + } + for obj, iterErr := range collibraClient.DataObject().ListDataObjects(ctx, services.WithDataObjectListFilter(filter)) { + if iterErr != nil { + return nil, iterErr + } + result.Items = append(result.Items, mapToDataAccessObject(ctx, obj)) + if len(result.Items) >= limit { + break + } + } + return result, nil +} + +// DataObjectAccessRole is an access control (a "role") that grants a user access to a data +// object. It is the trimmed form surfaced in NearestAccessControls. +type DataObjectAccessRole struct { + ID string `json:"id" jsonschema:"Unique identifier of the access control granting the access"` + Name string `json:"name" jsonschema:"Name of the access control (role) granting the access"` + Action string `json:"action" jsonschema:"Action type of the access control: Grant, Mask, Filter, Share, Group, or FilterRule"` + State string `json:"state" jsonschema:"State of the access control: Active, Inactive, or Deleted"` + Category *DataAccessGrantCategory `json:"category,omitempty" jsonschema:"Grant category details, present only for Grant action type"` +} + +// UserDataObjectAccess describes the access a single user has on a single data object, and the +// roles (access controls) that grant it. +type UserDataObjectAccess struct { + HasAccess bool `json:"hasAccess" jsonschema:"Whether the user has any access to the data object"` + Permissions []string `json:"permissions,omitempty" jsonschema:"Source-system permissions the user has on the data object (e.g. SELECT)"` + GlobalPermissions []string `json:"globalPermissions,omitempty" jsonschema:"Global permissions the user has on the data object (e.g. READ)"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" jsonschema:"When the access expires. Only populated when access is granted through a single access control; nil when multiple roles grant access."` + Roles []DataObjectAccessRole `json:"roles" jsonschema:"The access controls (roles) that grant the user access to the data object"` +} + +// ObjectAccessResult ties a resolved data object to the user's access on it. +type ObjectAccessResult struct { + DataObject *DataAccessObject `json:"dataObject" jsonschema:"The resolved data object"` + Access *UserDataObjectAccess `json:"access" jsonschema:"The user's access to the data object, including the granting roles"` +} + +// UnresolvedObjectID is a requested data object ID that could not be resolved to a data object. +type UnresolvedObjectID struct { + ID string `json:"id" jsonschema:"The supplied data object ID that could not be resolved"` + Reason string `json:"reason" jsonschema:"Why it could not be resolved: not_found (no data object exists with this ID)"` +} + +// CheckUserDataObjectAccessResult is the result of checking a user's access to one or more data +// objects identified by ID. +type CheckUserDataObjectAccessResult struct { + User *DataAccessIdentity `json:"user" jsonschema:"The user the access was checked for (the current user when no userId/email was supplied)"` + Results []*ObjectAccessResult `json:"results" jsonschema:"Per-object access results for the IDs that resolved to a data object"` + Unresolved []*UnresolvedObjectID `json:"unresolved,omitempty" jsonschema:"IDs that did not resolve to a data object. Ask the user to correct or drop these."` +} + +// CheckUserDataObjectAccess looks up each supplied data object ID and reports the access the user +// has on it, including the access controls (roles) that grant the access. +// +// The user is resolved as: userID if set, otherwise the user with email, otherwise the current +// user. IDs that do not correspond to an existing data object are returned in Unresolved so the +// caller can ask the user to correct or drop them. Resolve names to IDs via search_data_access_objects. +func CheckUserDataObjectAccess(ctx context.Context, httpClient *http.Client, objectIDs []string, userID, email string) (*CheckUserDataObjectAccessResult, error) { + collibraHost, ok := chip.GetCollibraHost(ctx) + if !ok { + return nil, fmt.Errorf("collibra host not configured in context") + } + dataAccessURL := strings.TrimSuffix(collibraHost, "/") + "/dataAccess" + + collibraClient, err := sdk.NewClient(dataAccessURL, sdk.WithHTTPClient(httpClient)) + if err != nil { + return nil, fmt.Errorf("failed to create data access client: %w", err) + } + + user, err := resolveDataAccessUser(ctx, collibraClient, userID, email) + if err != nil { + return nil, err + } + + result := &CheckUserDataObjectAccessResult{ + User: mapToDataAccessIdentity(ctx, user), + Results: []*ObjectAccessResult{}, + Unresolved: []*UnresolvedObjectID{}, + } + + for _, id := range objectIDs { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + continue + } + + obj, err := collibraClient.DataObject().GetDataObject(ctx, trimmed) + if err != nil { + return nil, fmt.Errorf("failed to fetch data object %q: %w", trimmed, err) + } + if obj == nil || obj.Id == "" { + result.Unresolved = append(result.Unresolved, &UnresolvedObjectID{ + ID: trimmed, + Reason: "not_found", + }) + continue + } + + item, err := collibraClient.DataObject().GetUserAccessToDataObject(ctx, obj.Id, user.Id) + if err != nil { + return nil, fmt.Errorf("failed to check access for data object %q: %w", trimmed, err) + } + + result.Results = append(result.Results, &ObjectAccessResult{ + DataObject: mapToDataAccessObject(ctx, obj), + Access: mapToUserDataObjectAccess(item), + }) + } + + return result, nil +} + +// resolveDataAccessUser resolves the user to check access for: by ID, then by email, then the +// current user. +func resolveDataAccessUser(ctx context.Context, collibraClient *sdk.CollibraClient, userID, email string) (*types.User, error) { + switch { + case strings.TrimSpace(userID) != "": + return collibraClient.User().GetUser(ctx, userID) + case strings.TrimSpace(email) != "": + return collibraClient.User().GetUserByEmail(ctx, email) + default: + return collibraClient.User().GetCurrentUser(ctx) + } +} + +func mapToUserDataObjectAccess(item *types.GroupedDataAccessReturnItem) *UserDataObjectAccess { + if item == nil { + return &UserDataObjectAccess{HasAccess: false, Roles: []DataObjectAccessRole{}} + } + + access := &UserDataObjectAccess{ + HasAccess: true, + Permissions: derefStrings(item.Permissions), + GlobalPermissions: derefStrings(item.GlobalPermissions), + ExpiresAt: item.ExpiresAt, + Roles: make([]DataObjectAccessRole, 0, len(item.NearestAccessControls)), + } + + for _, ac := range item.NearestAccessControls { + if ac == nil { + continue + } + role := DataObjectAccessRole{ + ID: ac.Id, + Name: ac.Name, + Action: string(ac.Action), + State: string(ac.State), + } + if ac.Category != nil { + role.Category = &DataAccessGrantCategory{ + ID: ac.Category.Id, + Name: ac.Category.Name, + NamePlural: ac.Category.NamePlural, + IsSystem: ac.Category.IsSystem, + IsDefault: ac.Category.IsDefault, + } + } + access.Roles = append(access.Roles, role) + } + + return access +} + +// derefStrings dereferences a slice of string pointers, skipping nils. +func derefStrings(in []*string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, 0, len(in)) + for _, s := range in { + if s != nil { + out = append(out, *s) + } + } + return out +} + +// CreateDataAccessRequestWhatInput describes a single WHAT item (a data object) for a new +// data access request, with optional requested permissions. +type CreateDataAccessRequestWhatInput struct { + DataObjectID string `json:"dataObjectId" jsonschema:"The ID of the data object the requesters want access to. Obtain via search_data_access_objects."` + Permissions []string `json:"permissions,omitempty" jsonschema:"Source-system permissions requested on this data object (e.g. SELECT). Should always be empty."` + GlobalPermissions []string `json:"globalPermissions,omitempty" jsonschema:"Global permissions requested on this data object. Must always be READ."` +} + +// CreateDataAccessRequestInput holds the parameters required to create a new data access request. +type CreateDataAccessRequestInput struct { + Name *string + Description string + UserIDs []string + What []CreateDataAccessRequestWhatInput +} + +// DataAccessRequestSummary is the simplified result of creating an access request. +type DataAccessRequestSummary struct { + ID string `json:"id" jsonschema:"Unique identifier of the created access request"` + Name *string `json:"name,omitempty" jsonschema:"Display name of the access request"` + Description string `json:"description" jsonschema:"Description of the access request"` + Status string `json:"status" jsonschema:"Current status of the access request (e.g. Created, Approval, Implementation, Closed)"` + Outcome string `json:"outcome" jsonschema:"Current outcome of the access request"` + Url string `json:"url" jsonschema:"Url in the Collibra UI to view access request"` +} + +// CreateDataAccessRequest creates a new Data Access request via the SDK's AccessRequestClient. +func CreateDataAccessRequest(ctx context.Context, httpClient *http.Client, input CreateDataAccessRequestInput) (*DataAccessRequestSummary, error) { + collibraHost, ok := chip.GetCollibraHost(ctx) + if !ok { + return nil, fmt.Errorf("collibra host not configured in context") + } + dataAccessURL := strings.TrimSuffix(collibraHost, "/") + "/dataAccess" + + collibraClient, err := sdk.NewClient(dataAccessURL, sdk.WithHTTPClient(httpClient)) + if err != nil { + return nil, fmt.Errorf("failed to create data access client: %w", err) + } + + what := make([]types.AccessRequestWhatInput, 0, len(input.What)) + for _, w := range input.What { + what = append(what, types.AccessRequestWhatInput{ + DataObject: &types.AccessRequestDataObjectWhatInput{ + Id: w.DataObjectID, + Permissions: w.Permissions, + GlobalPermissions: w.GlobalPermissions, + }, + }) + } + + req := types.AccessRequestInput{ + Name: input.Name, + Description: &input.Description, + Who: &types.AccessRequestWhoInput{ + Users: input.UserIDs, + }, + What: what, + } + + ar, err := collibraClient.AccessRequest().CreateAccessRequest(ctx, req) + if err != nil { + return nil, err + } + + uiURL := buildUiUrl(ctx, "access-requests", ar.Id) + + return &DataAccessRequestSummary{ + ID: ar.Id, + Name: ar.Name, + Description: ar.Description, + Status: string(ar.Status), + Outcome: string(ar.Outcome), + Url: uiURL, + }, nil +} + +func mapToDataAccessObject(ctx context.Context, o *types.DataObject) *DataAccessObject { + uiURL := buildUiUrl(ctx, "data-objects", o.Id) + out := &DataAccessObject{ + ID: o.Id, + Name: o.Name, + FullName: o.FullName, + Type: o.Type, + DataType: o.DataType, + Deleted: o.Deleted, + Description: o.Description, + Url: uiURL, + } + if o.DataSource != nil { + out.DataSourceID = o.DataSource.Id + } + if len(o.ApplicablePermissions) > 0 { + out.ApplicablePermissions = make([]DataAccessPermission, 0, len(o.ApplicablePermissions)) + for _, p := range o.ApplicablePermissions { + out.ApplicablePermissions = append(out.ApplicablePermissions, DataAccessPermission{ + Name: p.Name, + Description: p.Description, + }) + } + } + return out +} + +func mapToDataAccessIdentity(ctx context.Context, u *types.User) *DataAccessIdentity { + uiURL := buildUiUrl(ctx, "identities", u.Id) + return &DataAccessIdentity{ + ID: u.Id, + Name: u.Name, + Email: u.Email, + Type: string(u.Type), + Url: uiURL, + } +} + +func mapToDataAccessControlDetails(ctx context.Context, ac *types.AccessControl) *DataAccessControlDetails { + categoryName := "default" + if ac.GetCategory() != nil { + categoryName = ac.GetCategory().Name + } + uiURL := buildUiUrl(ctx, "access-controls/"+categoryName, ac.Id) + details := &DataAccessControlDetails{ + What: []DataAccessWhatItem{}, + Who: []DataAccessWhoItem{}, + SyncData: []DataAccessSyncData{}, + ID: ac.Id, + Name: ac.Name, + Description: ac.Description, + State: string(ac.State), + Action: string(ac.Action), + External: ac.External, + NamingHint: ac.NamingHint, + PolicyRule: ac.PolicyRule, + NotInternalizable: ac.NotInternalizable, + Complete: ac.Complete, + WhatUnknown: ac.WhatUnknown, + WhoUnknown: ac.WhoUnknown, + CreatedAt: ac.CreatedAt, + ModifiedAt: ac.ModifiedAt, + Url: uiURL, + } + + if ac.Category != nil { + details.Category = &DataAccessGrantCategory{ + ID: ac.Category.Id, + Name: ac.Category.Name, + NamePlural: ac.Category.NamePlural, + IsSystem: ac.Category.IsSystem, + IsDefault: ac.Category.IsDefault, + } + } + + for _, sd := range ac.SyncData { + ds := sd.GetDataSource() + details.SyncData = append(details.SyncData, DataAccessSyncData{ + DataSourceID: ds.GetId(), + DataSourceName: ds.GetName(), + SyncStatus: string(sd.GetSyncStatus()), + }) + } + + return details +} + +func buildUiUrl(ctx context.Context, resourceType string, id string) string { + collibraHost, ok := chip.GetCollibraHost(ctx) + if !ok { + return "" + } + return strings.TrimSuffix(collibraHost, "/") + "/data-access/" + resourceType + "/" + id +} diff --git a/pkg/skills/files/collibra/data-access/SKILL.md b/pkg/skills/files/collibra/data-access/SKILL.md new file mode 100644 index 0000000..0e8c405 --- /dev/null +++ b/pkg/skills/files/collibra/data-access/SKILL.md @@ -0,0 +1,52 @@ +--- +description: Manages who can access what data, through grants, masks and filters. +related: collibra/discovery, collibra/index +--- + +# Data Access + +Data Access is the system that manages who can access what data, through grants, masks and filters. + +## Hard rules + +1. Data Access users are not necessarily DGC users. Data Access users are only also DGC users if their email address is the same. +2. Ownership of a data object does not mean that user has access to it. + +## Search identities + +`search_data_access_identities` is a tool to search for Data Access users (identities) by name and/or email. Providing `email` performs an exact lookup via `GetUserByEmail`. Providing `name` performs a server-side case-insensitive contains search via `SearchUsers`. Both can be combined: email resolves the user, name filters the result client-side. Name-only searches return up to `pageSize` matches (default 25, max 25); narrow the `name` filter to find results beyond that cap. + +## Search Data Access objects + +`search_data_access_objects` is a tool to search for data objects in Collibra Data Access (tables, columns, schemas, views, and other entities tracked in registered data sources). Filters can be combined: `name` (case-insensitive contains), `dataSources` (data source IDs), `types` (e.g. `table`, `column`, `schema`, `view`), `parents` / `ancestors` (other data object IDs to scope the search to a sub-tree), and `includeDeleted`. Returns up to `pageSize` matches (default 25, max 25). Each result includes the data object ID, name, fully qualified name, type, data type, deleted flag, description, data source ID, and `applicablePermissions` — the list of source-system permissions (each with a `name` and `description`) that can be requested on the object. Use those names when populating `what[].permissions` for `create_data_access_request`. + +## Resolve a data source + +`get_data_access_data_source` fetches a single data source by its **ID**, returning its `name`, `type` (e.g. `snowflake`, `databricks`, `bigquery`), `description`, `parentId`, and `createdAt` / `modifiedAt` timestamps. Use it to turn an opaque data source ID into a human-readable name. Data objects carry their data source as the `dataSourceId` field on results from `search_data_access_objects` and `check_user_data_object_access` — when the same object name exists in several data sources, fetch each `dataSourceId` to report which data source is which instead of printing raw IDs. If the ID does not resolve, the tool returns a `message` (and no `dataSource`); ask the user to correct it and call again. + +## Check user access to data objects + +`check_user_data_object_access` answers "Does a user have access to a data object (database, schema, table, view, column, etc.) and through which roles?". It takes one or more data object **IDs** in `objectIds` — not names. For every object it reports whether the user has access (`hasAccess`), the granted `permissions` and `globalPermissions`, and `roles` — the access controls that grant the access (each with `id`, `name`, `action`, `state`, and grant `category`). Required behavior: + +- **WHO defaults to the current user.** Leave `userId` and `email` empty to check the calling user. To check a different user, resolve them first via `search_data_access_identities` and pass the returned ID in `userId` (or pass the user's `email`). Never pass a raw name. +- **WHAT must be resolved to IDs first.** Use `search_data_access_objects` to find the data object the user means, then pass its `id` in `objectIds`. When the name the user gave matches several objects (e.g. the same table in different schemas or data sources), present the candidates and let the user pick before checking access — do not guess. +- **Handle unresolved IDs.** IDs that do not correspond to an existing data object (`reason: not_found`) are returned in `result.unresolved`, and the tool sets a `message`. Ask the user to correct or drop those IDs, then call again. Objects that did resolve are still reported in `result.results`. +- **`expiresAt` caveat.** It is only populated when access is granted through a single access control; when multiple roles grant access it is `null`. + +## Create Data Access request + +`create_data_access_request` is a tool to create a new Collibra Data Access request on behalf of one or more users for one or more data objects. Destructive. Required behavior: + +- **Minimum input is WHO, WHAT, and a purpose.** Do not call this tool until all three are supplied. +- **WHO** must be resolved via `search_data_access_identities` (by email or name) — pass the returned user IDs in `userIds`. Never pass raw emails or names. +- **WHAT** must be resolved via `search_data_access_objects` — pass the returned data object IDs in `what[].dataObjectId`. Per item, `permissions` should be empty and `globalPermissions` must always be READ. +- **Purpose** is mandatory and must come from the user — it is the business justification for the request. If the user has not stated a purpose, ask them for one before calling the tool. Do not invent a purpose. The tool always appends a note stating that the request was created by AI. +- **Name** is optional. If the user does not provide one, omit `name` on the first call. The tool will return status `needs_name_confirmation` with a `suggestedName` derived from the purpose — present that suggestion to the user, get their confirmation (or an alternative), and call again with the confirmed value in `name`. + +## Inspect an access control + +`get_data_access_control_details` fetches a single Collibra Data Access control by its **ID**, returning its name, description, state (`ACTIVE`/`INACTIVE`/`DELETED`), action type (`GRANT`/`MASK`/`FILTER`/`SHARE`/`GROUP`/`FILTERRULE`), grant category, policy rule, external-management status, and the full `what`/`who` scope lists. Use this to inspect an individual access control once you have its ID — for example, from a `roles` entry returned by `check_user_data_object_access`. + +## Common follow-ups + +- Found a **data source id** → `get_data_access_data_source` to resolve its `name` and `type` (e.g. `snowflake`, `databricks`, `bigquery`). diff --git a/pkg/tools/check_user_data_object_access/tool.go b/pkg/tools/check_user_data_object_access/tool.go new file mode 100644 index 0000000..cb04bfb --- /dev/null +++ b/pkg/tools/check_user_data_object_access/tool.go @@ -0,0 +1,64 @@ +package check_user_data_object_access + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Input struct { + ObjectIds []string `json:"objectIds" jsonschema:"Required. One or more data object IDs (database, schema, table, view, column, etc.) to check access for. Obtain IDs via search_data_access_objects."` + UserID string `json:"userId,omitempty" jsonschema:"Optional. ID of the user to check access for. Resolve names/emails to a user ID via search_data_access_identities. When omitted along with email, the current user is used."` + Email string `json:"email,omitempty" jsonschema:"Optional. Email of the user to check access for, used when userId is not supplied. When omitted along with userId, the current user is used."` +} + +type Output struct { + Result *clients.CheckUserDataObjectAccessResult `json:"result,omitempty" jsonschema:"The access check result: the resolved user, per-object access (with granting roles), and any IDs that could not be resolved."` + Message string `json:"message,omitempty" jsonschema:"Guidance for the agent, set when one or more IDs could not be resolved — ask the user to correct or drop them."` + Error string `json:"error,omitempty" jsonschema:"Error message if the access check could not be completed."` +} + +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "check_user_data_object_access", + Title: "Check User Data Object Access", + Description: "Checks if a user has access to a data object (database, schema, table, view, column, etc.) and through which access controls (ie roles). Takes one or more data object IDs (obtain them via search_data_access_objects). For every object it reports whether the user has access, the granted permissions, and the access controls (roles) that grant the access. Checks the current user unless userId or email is supplied (resolve names via search_data_access_identities). IDs that do not correspond to an existing data object are returned in result.unresolved with a message asking the user to correct or drop them.", + Handler: handle(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: new(false), IdempotentHint: true, OpenWorldHint: new(false)}, + } +} + +func handle(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + ids := make([]string, 0, len(input.ObjectIds)) + for _, id := range input.ObjectIds { + if strings.TrimSpace(id) != "" { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return Output{Error: "at least one object ID is required"}, nil + } + + result, err := clients.CheckUserDataObjectAccess(ctx, collibraClient, ids, input.UserID, input.Email) + if err != nil { + return Output{Error: fmt.Sprintf("Failed to check data object access: %s", err.Error())}, nil + } + + out := Output{Result: result} + if len(result.Unresolved) > 0 { + unresolved := make([]string, 0, len(result.Unresolved)) + for _, u := range result.Unresolved { + unresolved = append(unresolved, fmt.Sprintf("%q (%s)", u.ID, u.Reason)) + } + out.Message = fmt.Sprintf("Could not resolve %d ID(s) to a data object: %s. Ask the user to correct or drop them, then call again.", len(result.Unresolved), strings.Join(unresolved, ", ")) + } + return out, nil + } +} diff --git a/pkg/tools/check_user_data_object_access/tool_test.go b/pkg/tools/check_user_data_object_access/tool_test.go new file mode 100644 index 0000000..58af607 --- /dev/null +++ b/pkg/tools/check_user_data_object_access/tool_test.go @@ -0,0 +1,155 @@ +package check_user_data_object_access_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/chip" + tool "github.com/collibra/chip/pkg/tools/check_user_data_object_access" + "github.com/collibra/chip/pkg/tools/testutil" +) + +// The data access SDK posts all queries to the same GraphQL endpoint (/dataAccess/query). +// Tests dispatch on the operation name embedded in the query body. +const gqlPath = "/dataAccess/query" + +const currentUserResp = `{"data":{"currentUser":{"id":"user-1","name":"Alice","email":"alice@example.com","type":"Human"}}}` + +const accessResp = `{"data":{"dataObject":{"distinctAccess":{"__typename":"GroupedDataAccessReturnItemConnection",` + + `"pageInfo":{"hasNextPage":false,"startCursor":null},` + + `"edges":[{"cursor":"a1","node":{"permissions":["SELECT"],"globalPermissions":["READ"],"expiresAt":null,` + + `"user":{"id":"user-1","name":"Alice","email":"alice@example.com","type":"Human"},` + + `"nearestAccessControls":[{"id":"ac-1","name":"Analysts","action":"Grant","state":"Active",` + + `"category":{"id":"cat-1","name":"Read","namePlural":"Reads","isSystem":true,"isDefault":true}}]}}]}}}}` + +func dataObjectResp(id, name, fullName string) string { + return `{"data":{"dataObject":{"id":"` + id + `","name":"` + name + `","fullName":"` + fullName + + `","type":"table","dataType":null,"deleted":false,"description":"","dataSource":{"id":"ds-1"},"applicablePermissions":[]}}}` +} + +// emptyDataObjectResp mimics the GraphQL response for an ID that matches no data object: the +// dataObject field is null, which genqlient decodes to a zero-value object (empty id). +const emptyDataObjectResp = `{"data":{"dataObject":null}}` + +// newGQLServer returns a test server that answers the three operations the tool relies on. The +// GetDataObject response is supplied by the caller so each test can control ID resolution. +func newGQLServer(t *testing.T, getObjectResp string) *httptest.Server { + t.Helper() + handler := http.NewServeMux() + handler.HandleFunc(gqlPath, func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + q := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(q, "query CurrentUser"): + _, _ = io.WriteString(w, currentUserResp) + case strings.Contains(q, "query GetDataObjectAccessList"): + _, _ = io.WriteString(w, accessResp) + case strings.Contains(q, "query GetDataObject"): + _, _ = io.WriteString(w, getObjectResp) + default: + http.Error(w, "unexpected query: "+q, http.StatusBadRequest) + } + }) + return httptest.NewServer(handler) +} + +func TestCheckUserDataObjectAccess_HasAccess(t *testing.T) { + server := newGQLServer(t, dataObjectResp("do-1", "customers", "db.public.customers")) + defer server.Close() + + ctx := chip.SetCollibraHost(t.Context(), "http://collibra.test") + output, err := tool.NewTool(testutil.NewClient(server)).Handler(ctx, tool.Input{ + ObjectIds: []string{"do-1"}, + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if output.Error != "" { + t.Fatalf("Expected no tool error, got: %q", output.Error) + } + if output.Message != "" { + t.Fatalf("Expected no message, got: %q", output.Message) + } + if output.Result == nil { + t.Fatal("Expected a result") + } + if output.Result.User == nil || output.Result.User.ID != "user-1" { + t.Fatalf("Expected user-1, got: %+v", output.Result.User) + } + if len(output.Result.Unresolved) != 0 { + t.Fatalf("Expected no unresolved names, got: %+v", output.Result.Unresolved) + } + if len(output.Result.Results) != 1 { + t.Fatalf("Expected 1 result, got: %d", len(output.Result.Results)) + } + + res := output.Result.Results[0] + if res.DataObject == nil || res.DataObject.ID != "do-1" { + t.Fatalf("Unexpected resolved object: %+v", res.DataObject) + } + if res.Access == nil || !res.Access.HasAccess { + t.Fatalf("Expected hasAccess true, got: %+v", res.Access) + } + if len(res.Access.Permissions) != 1 || res.Access.Permissions[0] != "SELECT" { + t.Fatalf("Expected permissions [SELECT], got: %v", res.Access.Permissions) + } + if len(res.Access.GlobalPermissions) != 1 || res.Access.GlobalPermissions[0] != "READ" { + t.Fatalf("Expected globalPermissions [READ], got: %v", res.Access.GlobalPermissions) + } + if len(res.Access.Roles) != 1 { + t.Fatalf("Expected 1 role, got: %d", len(res.Access.Roles)) + } + role := res.Access.Roles[0] + if role.ID != "ac-1" || role.Name != "Analysts" || role.Action != "Grant" || role.State != "Active" { + t.Fatalf("Unexpected role: %+v", role) + } + if role.Category == nil || role.Category.Name != "Read" { + t.Fatalf("Expected role category Read, got: %+v", role.Category) + } +} + +func TestCheckUserDataObjectAccess_UnresolvedNotFound(t *testing.T) { + server := newGQLServer(t, emptyDataObjectResp) + defer server.Close() + + ctx := chip.SetCollibraHost(t.Context(), "http://collibra.test") + output, err := tool.NewTool(testutil.NewClient(server)).Handler(ctx, tool.Input{ + ObjectIds: []string{"ghost"}, + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if output.Result == nil { + t.Fatal("Expected a result") + } + if len(output.Result.Results) != 0 { + t.Fatalf("Expected no resolved results, got: %d", len(output.Result.Results)) + } + if len(output.Result.Unresolved) != 1 { + t.Fatalf("Expected 1 unresolved ID, got: %+v", output.Result.Unresolved) + } + if output.Result.Unresolved[0].ID != "ghost" || output.Result.Unresolved[0].Reason != "not_found" { + t.Fatalf("Unexpected unresolved entry: %+v", output.Result.Unresolved[0]) + } + if output.Message == "" { + t.Fatal("Expected a message asking the user to correct or drop the ID") + } +} + +func TestCheckUserDataObjectAccess_RequiresIDs(t *testing.T) { + output, err := tool.NewTool(nil).Handler(t.Context(), tool.Input{ObjectIds: []string{" "}}) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if output.Error == "" { + t.Fatal("Expected an error when no object IDs are supplied") + } +} diff --git a/pkg/tools/create_data_access_request/tool.go b/pkg/tools/create_data_access_request/tool.go new file mode 100644 index 0000000..9b550ad --- /dev/null +++ b/pkg/tools/create_data_access_request/tool.go @@ -0,0 +1,132 @@ +package create_data_access_request + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// aiDescriptionSuffix is appended to every description so the access request is clearly +// attributed to an AI agent. +const aiDescriptionSuffix = "This access request was created by AI." + +// suggestedNameMaxLen caps the length of a name suggestion derived from the purpose. +const suggestedNameMaxLen = 80 + +// Status values returned in the Output. +const ( + statusNeedsNameConfirmation = "needs_name_confirmation" + statusCreated = "created" +) + +type Input struct { + Name string `json:"name,omitempty" jsonschema:"Optional. Display name of the access request. If omitted, the tool returns a suggested name derived from the purpose and asks the agent to confirm it with the user before retrying."` + Purpose string `json:"purpose" jsonschema:"Required. The user-supplied purpose / business justification for the access request. This is used verbatim as the description of the access request. The tool always appends a note indicating the request was created by AI."` + UserIDs []string `json:"userIds" jsonschema:"Required. IDs of the beneficiary users (the WHO of the request). Resolve these via the search_data_access_identities tool before calling."` + What []clients.CreateDataAccessRequestWhatInput `json:"what" jsonschema:"Required. The data objects the users are requesting access to (the WHAT of the request). Each item references a data object ID and optional requested permissions. Resolve the data object IDs via the search_data_access_objects tool before calling."` +} + +type Output struct { + Status string `json:"status,omitempty" jsonschema:"Outcome of the call: needs_name_confirmation (no name was supplied — confirm the suggestedName with the user and call again with name set), or created (the request was successfully created)."` + Message string `json:"message,omitempty" jsonschema:"Human-readable explanation of the status. When status is needs_name_confirmation, this tells the agent to confirm the suggested name with the user."` + SuggestedName string `json:"suggestedName,omitempty" jsonschema:"Name suggestion derived from the purpose. Present only when status is needs_name_confirmation."` + Request *clients.DataAccessRequestSummary `json:"request,omitempty" jsonschema:"The created access request, if successful."` + Error string `json:"error,omitempty" jsonschema:"Error message if the access request could not be created."` +} + +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "create_data_access_request", + Title: "Create Data Access Request", + Description: "Create a new Collibra Data Access request. Requires the WHO (beneficiary user IDs, obtained via search_data_access_identities), the WHAT (data objects, obtained via search_data_access_objects), and a user-supplied purpose that is used as the description. If no name is supplied, the tool returns a suggested name derived from the purpose with status needs_name_confirmation — confirm the suggestion (or get a replacement) with the user, then call again with name set. The description always ends with a note stating that the request was created by AI.", + Handler: handle(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: new(false), OpenWorldHint: new(false)}, + } +} + +func handle(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + purpose := strings.TrimSpace(input.Purpose) + if purpose == "" { + return Output{Error: "purpose is required — ask the user for the business justification for this access request"}, nil + } + if len(input.UserIDs) == 0 { + return Output{Error: "at least one beneficiary user ID is required — resolve them with search_data_access_identities"}, nil + } + if len(input.What) == 0 { + return Output{Error: "at least one data object is required — resolve them with search_data_access_objects"}, nil + } + for i, w := range input.What { + if strings.TrimSpace(w.DataObjectID) == "" { + return Output{Error: fmt.Sprintf("what[%d].dataObjectId is required", i)}, nil + } + } + + name := strings.TrimSpace(input.Name) + if name == "" { + suggested := suggestNameFromPurpose(purpose) + return Output{ + Status: statusNeedsNameConfirmation, + SuggestedName: suggested, + Message: fmt.Sprintf("No name was supplied. Suggested name based on the purpose: %q. Confirm this with the user (or ask for a different name), then call create_data_access_request again with the confirmed name in the `name` field.", suggested), + }, nil + } + + clientInput := clients.CreateDataAccessRequestInput{ + Name: &name, + Description: buildDescription(purpose), + UserIDs: input.UserIDs, + What: input.What, + } + + req, err := clients.CreateDataAccessRequest(ctx, collibraClient, clientInput) + if err != nil { + return Output{Error: fmt.Sprintf("Failed to create data access request: %s", err.Error())}, nil + } + return Output{Status: statusCreated, Request: req}, nil + } +} + +func buildDescription(purpose string) string { + if strings.Contains(purpose, aiDescriptionSuffix) { + return purpose + } + if !strings.HasSuffix(purpose, ".") { + purpose = purpose + "." + } + return purpose + " " + aiDescriptionSuffix +} + +// suggestNameFromPurpose derives a short, human-readable name from the purpose text. +// It takes the first sentence/line, strips the AI-attribution suffix, collapses whitespace, +// truncates to suggestedNameMaxLen characters at a word boundary, and prefixes it. +func suggestNameFromPurpose(purpose string) string { + summary := strings.ReplaceAll(purpose, aiDescriptionSuffix, "") + summary = strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == '\t' { + return ' ' + } + return r + }, summary) + if idx := strings.IndexAny(summary, ".!?"); idx >= 0 { + summary = summary[:idx] + } + summary = strings.Join(strings.Fields(summary), " ") + if summary == "" { + return "Access request" + } + if len(summary) > suggestedNameMaxLen { + truncated := summary[:suggestedNameMaxLen] + if sp := strings.LastIndex(truncated, " "); sp > suggestedNameMaxLen/2 { + truncated = truncated[:sp] + } + summary = strings.TrimRight(truncated, " ,;:-") + } + return "Access request: " + summary +} diff --git a/pkg/tools/get_data_access_control_details/tool.go b/pkg/tools/get_data_access_control_details/tool.go new file mode 100644 index 0000000..516d8ec --- /dev/null +++ b/pkg/tools/get_data_access_control_details/tool.go @@ -0,0 +1,49 @@ +package get_data_access_control_details + +import ( + "context" + "fmt" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type DataAccessControlInput struct { + ID string `json:"id" jsonschema:"The id of the data access control to retrieve"` +} + +type DataAccessControlOutput struct { + AccessControl *clients.DataAccessControlDetails `json:"accessControl,omitempty" jsonschema:"The data access control details if found"` + Error string `json:"error,omitempty" jsonschema:"Error message if the access control could not be retrieved"` + Found bool `json:"found" jsonschema:"Whether the data access control was found"` +} + +func NewTool(collibraClient *http.Client) *chip.Tool[DataAccessControlInput, DataAccessControlOutput] { + return &chip.Tool[DataAccessControlInput, DataAccessControlOutput]{ + Name: "get_data_access_control_details", + Title: "Get Data Access Control Details", + Description: "Retrieve detailed information about a specific Collibra Data Access control by its id. Returns the access control's name, description, state (ACTIVE, INACTIVE, DELETED), action type (GRANT, MASK, FILTER, SHARE, GROUP, FILTERRULE), grant category, policy rule, external management status, ABAC scope parse status, and timestamps. Use this to inspect an individual access control when you know its ID.", + Handler: handleGetDataAccessControlDetails(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: new(false), IdempotentHint: true, OpenWorldHint: new(false)}, + } +} + +func handleGetDataAccessControlDetails(collibraClient *http.Client) chip.ToolHandlerFunc[DataAccessControlInput, DataAccessControlOutput] { + return func(ctx context.Context, input DataAccessControlInput) (DataAccessControlOutput, error) { + details, err := clients.GetDataAccessControl(ctx, collibraClient, input.ID) + if err != nil { + return DataAccessControlOutput{ + Error: fmt.Sprintf("Failed to retrieve data access control: %s", err.Error()), + Found: false, + }, nil + } + + return DataAccessControlOutput{ + AccessControl: details, + Found: true, + }, nil + } +} diff --git a/pkg/tools/get_data_access_data_source/tool.go b/pkg/tools/get_data_access_data_source/tool.go new file mode 100644 index 0000000..7e6c59d --- /dev/null +++ b/pkg/tools/get_data_access_data_source/tool.go @@ -0,0 +1,55 @@ +package get_data_access_data_source + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + sdktypes "github.com/collibra/data-access-go-sdk/types" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Input struct { + DataSourceID string `json:"dataSourceId" jsonschema:"Required. ID of the data source to fetch. Data source IDs are returned as dataSourceId on data objects from search_data_access_objects and check_user_data_object_access."` +} + +type Output struct { + DataSource *clients.DataAccessDataSource `json:"dataSource,omitempty" jsonschema:"The resolved data source: its id, name, type, description, parent id, and timestamps."` + Message string `json:"message,omitempty" jsonschema:"Guidance for the agent, set when the data source ID could not be resolved — ask the user to correct it."` + Error string `json:"error,omitempty" jsonschema:"Error message if the data source could not be retrieved."` +} + +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "get_data_access_data_source", + Title: "Get Data Access Data Source", + Description: "Fetches a Collibra Data Access data source by its ID, returning its name, type, description, parent, and timestamps. Use this to resolve a data source ID (the dataSourceId carried by data objects from search_data_access_objects and check_user_data_object_access) to a human-readable data source name and type. If the ID does not correspond to an existing data source, a message asking the user to correct it is returned.", + Handler: handle(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: new(false), IdempotentHint: true, OpenWorldHint: new(false)}, + } +} + +func handle(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + id := strings.TrimSpace(input.DataSourceID) + if id == "" { + return Output{Error: "a data source ID is required"}, nil + } + + dataSource, err := clients.GetDataAccessDataSource(ctx, collibraClient, id) + if err != nil { + var notFound *sdktypes.ErrNotFound + if errors.As(err, ¬Found) { + return Output{Message: fmt.Sprintf("No data source found with ID %q. Ask the user to correct it, then call again.", id)}, nil + } + return Output{Error: fmt.Sprintf("Failed to fetch data source: %s", err.Error())}, nil + } + + return Output{DataSource: dataSource}, nil + } +} diff --git a/pkg/tools/get_data_access_data_source/tool_test.go b/pkg/tools/get_data_access_data_source/tool_test.go new file mode 100644 index 0000000..f7ea9d5 --- /dev/null +++ b/pkg/tools/get_data_access_data_source/tool_test.go @@ -0,0 +1,109 @@ +package get_data_access_data_source_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/chip" + tool "github.com/collibra/chip/pkg/tools/get_data_access_data_source" + "github.com/collibra/chip/pkg/tools/testutil" +) + +// The data access SDK posts all queries to the same GraphQL endpoint (/dataAccess/query). +// Tests dispatch on the operation name embedded in the query body. +const gqlPath = "/dataAccess/query" + +const dataSourceResp = `{"data":{"dataSource":{"__typename":"DataSource",` + + `"id":"ds-1","name":"Snowflake Prod","type":"snowflake","description":"Production warehouse",` + + `"createdAt":"2024-01-01T00:00:00Z","modifiedAt":"2024-02-01T00:00:00Z","parent":null,"edgeSiteInfo":null}}}` + +const notFoundResp = `{"data":{"dataSource":{"__typename":"NotFoundError","message":"no such data source"}}}` + +// newGQLServer returns a test server answering the GetDataSource operation with the supplied body. +func newGQLServer(t *testing.T, getDataSourceResp string) *httptest.Server { + t.Helper() + handler := http.NewServeMux() + handler.HandleFunc(gqlPath, func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + q := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(q, "query GetDataSource"): + _, _ = io.WriteString(w, getDataSourceResp) + default: + http.Error(w, "unexpected query: "+q, http.StatusBadRequest) + } + }) + return httptest.NewServer(handler) +} + +func TestGetDataAccessDataSource_Found(t *testing.T) { + server := newGQLServer(t, dataSourceResp) + defer server.Close() + + ctx := chip.SetCollibraHost(t.Context(), "http://collibra.test") + output, err := tool.NewTool(testutil.NewClient(server)).Handler(ctx, tool.Input{ + DataSourceID: "ds-1", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if output.Error != "" { + t.Fatalf("Expected no tool error, got: %q", output.Error) + } + if output.Message != "" { + t.Fatalf("Expected no message, got: %q", output.Message) + } + if output.DataSource == nil { + t.Fatal("Expected a data source") + } + ds := output.DataSource + if ds.ID != "ds-1" || ds.Name != "Snowflake Prod" || ds.Type != "snowflake" { + t.Fatalf("Unexpected data source: %+v", ds) + } + if ds.Description != "Production warehouse" { + t.Fatalf("Unexpected description: %q", ds.Description) + } + if ds.CreatedAt.IsZero() || ds.ModifiedAt.IsZero() { + t.Fatalf("Expected timestamps to be populated, got: %+v", ds) + } +} + +func TestGetDataAccessDataSource_NotFound(t *testing.T) { + server := newGQLServer(t, notFoundResp) + defer server.Close() + + ctx := chip.SetCollibraHost(t.Context(), "http://collibra.test") + output, err := tool.NewTool(testutil.NewClient(server)).Handler(ctx, tool.Input{ + DataSourceID: "ghost", + }) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if output.DataSource != nil { + t.Fatalf("Expected no data source, got: %+v", output.DataSource) + } + if output.Error != "" { + t.Fatalf("Expected no tool error, got: %q", output.Error) + } + if output.Message == "" { + t.Fatal("Expected a message asking the user to correct the ID") + } +} + +func TestGetDataAccessDataSource_RequiresID(t *testing.T) { + output, err := tool.NewTool(nil).Handler(t.Context(), tool.Input{DataSourceID: " "}) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if output.Error == "" { + t.Fatal("Expected an error when no data source ID is supplied") + } +} diff --git a/pkg/tools/register.go b/pkg/tools/register.go index 862275b..2a355d8 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -8,7 +8,9 @@ import ( "github.com/collibra/chip/pkg/skills" "github.com/collibra/chip/pkg/tools/add_data_classification_match" "github.com/collibra/chip/pkg/tools/create_assessment" + "github.com/collibra/chip/pkg/tools/check_user_data_object_access" "github.com/collibra/chip/pkg/tools/create_asset" + "github.com/collibra/chip/pkg/tools/create_data_access_request" "github.com/collibra/chip/pkg/tools/create_dq_job" "github.com/collibra/chip/pkg/tools/create_dq_rule" "github.com/collibra/chip/pkg/tools/deploy_dq_rule_template" @@ -23,6 +25,8 @@ import ( "github.com/collibra/chip/pkg/tools/get_business_term_data" "github.com/collibra/chip/pkg/tools/get_column_semantics" "github.com/collibra/chip/pkg/tools/get_context_specification" + "github.com/collibra/chip/pkg/tools/get_data_access_control_details" + "github.com/collibra/chip/pkg/tools/get_data_access_data_source" "github.com/collibra/chip/pkg/tools/get_debug_mcp_init_request" "github.com/collibra/chip/pkg/tools/get_dq_rule" "github.com/collibra/chip/pkg/tools/get_dq_rule_results" @@ -44,6 +48,8 @@ import ( "github.com/collibra/chip/pkg/tools/remove_data_classification_match" "github.com/collibra/chip/pkg/tools/search_asset_keyword" "github.com/collibra/chip/pkg/tools/search_catalog_columns" + "github.com/collibra/chip/pkg/tools/search_data_access_identities" + "github.com/collibra/chip/pkg/tools/search_data_access_objects" "github.com/collibra/chip/pkg/tools/search_data_classes" "github.com/collibra/chip/pkg/tools/search_data_classification_matches" "github.com/collibra/chip/pkg/tools/search_lineage_entities" @@ -96,6 +102,12 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv toolRegister(server, toolConfig, prepare_create_asset.NewTool(client)) toolRegister(server, toolConfig, create_asset.NewTool(client)) toolRegister(server, toolConfig, edit_asset.NewTool(client)) + toolRegister(server, toolConfig, search_data_access_identities.NewTool(client)) + toolRegister(server, toolConfig, search_data_access_objects.NewTool(client)) + toolRegister(server, toolConfig, create_data_access_request.NewTool(client)) + toolRegister(server, toolConfig, check_user_data_object_access.NewTool(client)) + toolRegister(server, toolConfig, get_data_access_data_source.NewTool(client)) + toolRegister(server, toolConfig, get_data_access_control_details.NewTool(client)) toolRegister(server, toolConfig, get_assessment.NewTool(client)) toolRegister(server, toolConfig, create_assessment.NewTool(client)) toolRegister(server, toolConfig, edit_assessment.NewTool(client)) diff --git a/pkg/tools/search_data_access_identities/tool.go b/pkg/tools/search_data_access_identities/tool.go new file mode 100644 index 0000000..9051a11 --- /dev/null +++ b/pkg/tools/search_data_access_identities/tool.go @@ -0,0 +1,48 @@ +package search_data_access_identities + +import ( + "context" + "fmt" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type SearchDataAccessIdentitiesInput struct { + Email string `json:"email,omitempty" jsonschema:"Optional. Exact email address to look up the user by."` + Name string `json:"name,omitempty" jsonschema:"Optional. Search string for a case-insensitive contains match on the user's display name. When used without email, ListUsers is called server-side. When used with email, it is applied as a client-side filter on the result."` + PageSize int `json:"pageSize,omitempty" jsonschema:"Optional. Maximum number of results to return (default: 25, max: 25). Only applicable for name-based searches."` +} + +type SearchDataAccessIdentitiesOutput struct { + Results []*clients.DataAccessIdentity `json:"results" jsonschema:"The matching Data Access users."` + Error string `json:"error,omitempty" jsonschema:"Error message if the search could not be completed."` +} + +func NewTool(collibraClient *http.Client) *chip.Tool[SearchDataAccessIdentitiesInput, SearchDataAccessIdentitiesOutput] { + return &chip.Tool[SearchDataAccessIdentitiesInput, SearchDataAccessIdentitiesOutput]{ + Name: "search_data_access_identities", + Title: "Search Data Access Identities", + Description: "Search for Data Access users (identities) by name and/or email. Providing email performs an exact lookup; providing name performs a case-insensitive contains search via ListUsers. Both can be combined: email resolves the user, name filters the result client-side.", + Handler: handleSearchDataAccessIdentities(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: new(false), IdempotentHint: true, OpenWorldHint: new(false)}, + } +} + +func handleSearchDataAccessIdentities(collibraClient *http.Client) chip.ToolHandlerFunc[SearchDataAccessIdentitiesInput, SearchDataAccessIdentitiesOutput] { + return func(ctx context.Context, input SearchDataAccessIdentitiesInput) (SearchDataAccessIdentitiesOutput, error) { + result, err := clients.SearchDataAccessIdentities(ctx, collibraClient, input.Name, input.Email, input.PageSize) + if err != nil { + return SearchDataAccessIdentitiesOutput{ + Error: fmt.Sprintf("Failed to search Data Access identities: %s", err.Error()), + }, nil + } + + return SearchDataAccessIdentitiesOutput{ + Results: result.Items, + }, nil + } +} diff --git a/pkg/tools/search_data_access_objects/tool.go b/pkg/tools/search_data_access_objects/tool.go new file mode 100644 index 0000000..ed781fd --- /dev/null +++ b/pkg/tools/search_data_access_objects/tool.go @@ -0,0 +1,52 @@ +package search_data_access_objects + +import ( + "context" + "fmt" + "net/http" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type SearchDataAccessObjectsInput struct { + Name string `json:"name,omitempty" jsonschema:"Optional. Filter by name (case-insensitive contains match on data object name)."` + DataSources []string `json:"dataSources,omitempty" jsonschema:"Optional. Restrict to data objects belonging to one or more data sources (data source IDs)."` + Types []string `json:"types,omitempty" jsonschema:"Optional. Restrict to data objects of one or more types (e.g. table, column, schema, view)."` + Parents []string `json:"parents,omitempty" jsonschema:"Optional. Restrict to data objects whose direct parent matches one of the given data object IDs."` + Ancestors []string `json:"ancestors,omitempty" jsonschema:"Optional. Restrict to data objects whose ancestors include one of the given data object IDs."` + IncludeDeleted bool `json:"includeDeleted,omitempty" jsonschema:"Optional. If true, also includes data objects that no longer exist in the source. Defaults to false."` + PageSize int `json:"pageSize,omitempty" jsonschema:"Optional. Maximum number of results to return (default: 25, max: 25)."` +} + +type SearchDataAccessObjectsOutput struct { + Results []*clients.DataAccessObject `json:"results" jsonschema:"The matching data objects."` + Error string `json:"error,omitempty" jsonschema:"Error message if the search could not be completed."` +} + +func NewTool(collibraClient *http.Client) *chip.Tool[SearchDataAccessObjectsInput, SearchDataAccessObjectsOutput] { + return &chip.Tool[SearchDataAccessObjectsInput, SearchDataAccessObjectsOutput]{ + Name: "search_data_access_objects", + Title: "Search Data Access Objects", + Description: "Search for data objects in Collibra Data Access. Data objects represent tables, columns, schemas, views, and other entities tracked in registered data sources. Filters can be combined: name (case-insensitive contains), dataSources (data source IDs), types (e.g. table, column), parents/ancestors (other data object IDs), and includeDeleted. Returns up to pageSize matches (default 25, max 25). Each result also includes its applicablePermissions — the source-system permissions (with name and description) that can be requested on the object.", + Handler: handleSearchDataAccessObjects(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: new(false), IdempotentHint: true, OpenWorldHint: new(false)}, + } +} + +func handleSearchDataAccessObjects(collibraClient *http.Client) chip.ToolHandlerFunc[SearchDataAccessObjectsInput, SearchDataAccessObjectsOutput] { + return func(ctx context.Context, input SearchDataAccessObjectsInput) (SearchDataAccessObjectsOutput, error) { + result, err := clients.SearchDataAccessObjects(ctx, collibraClient, input.Name, input.DataSources, input.Types, input.Parents, input.Ancestors, input.IncludeDeleted, input.PageSize) + if err != nil { + return SearchDataAccessObjectsOutput{ + Error: fmt.Sprintf("Failed to search data access objects: %s", err.Error()), + }, nil + } + + return SearchDataAccessObjectsOutput{ + Results: result.Items, + }, nil + } +}