> For the complete documentation index, see [llms.txt](https://v2.dataos.info/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://v2.dataos.info/build/productize/governance.md).

# Governance

Different users should see different slices of the same data. An executive might see every row; an analyst might see only active users, with sensitive columns like email hidden. Governance is how you make Vulcan enforce that, as part of the product itself rather than as a separate access layer someone bolts on later.

Governance in Vulcan works through physical-model policies, in two steps:

1. After DataOS authorizes a user, a small project plugin you write decides which **policy group** that user belongs to, such as `analyst` or `executive`.
2. A policy attached to the backing physical model uses that group to decide which rows can be queried and which columns are masked. Semantic and metric models inherit those rules; they never author policy directly.

{% hint style="warning" %}
Policies attach to physical models only. `depends_on` must resolve to a physical/base model: pointing a policy at a semantic or metric model fails with `attach policy to a physical model only`. If a project still has inline `policies:` on a semantic model, that block is ignored with a warning, and any per-dimension `mask_expression` is stripped at load time. Move that logic into `policies/access/*.yml` and the physical model's `column_mask_expressions` instead.
{% endhint %}

For the full schema reference, see [Vulcan policies](https://v2.dataos.info/references/resources/vulcan/policies).

***

## How plugin governance works

Plugin governance has two parts:

1. The project registers an authorization plugin in `config.yaml`.
2. Policy files under `policies/access/` match the group returned by that plugin.

At query time, Vulcan authorizes the user with DataOS's authorization service and then calls your configured plugin. The plugin receives an `AuthExtensionContext` (`user_id`, `user_tags`, and `request_headers`) and returns a `SecurityContext`: an object that names the user's policy group and can include extra claims. Vulcan applies the physical model policy that matches that group before running the query.

{% hint style="info" %}
If `after_authorize` isn't configured, or the hook raises or fails to import, Vulcan does not attach a `SecurityContext`: every policy rule that matches on `group` silently fails to match. Any model with an attached policy needs a working plugin.
{% endhint %}

***

## Register an auth plugin

Add an `after_authorize` hook in `config.yaml`. The value is a Python import path in the form `module:function`.

```yaml
after_authorize: "plugins.auth_ext:resolve_user_groups"
```

This points to a function named `resolve_user_groups` in `plugins/auth_ext.py`.

The `b2b_saas` example keeps the plugin small. It reads DataOS role tags, converts each role tag into a group label, and returns the first group:

```python
from schema.auth import AuthExtensionContext, SecurityContext

ROLE_ID_TAG_PREFIX = "roles:id:"


async def resolve_user_groups(ctx: AuthExtensionContext) -> SecurityContext:
    """Derive the primary group label from DataOS tags for ``X-User-Group``."""

    groups = [
        tag.replace(ROLE_ID_TAG_PREFIX, "role:", 1)
        for tag in ctx.user_tags
        if tag.startswith(ROLE_ID_TAG_PREFIX)
    ]

    group = groups[0] if groups else ""
    return SecurityContext(group=group)
```

The `orders-analytics-with-auth` example maps DataOS role tags to policy groups and chooses the highest-priority group:

```python
from schema.auth import AuthExtensionContext, SecurityContext

ROLE_ID_TAG_PREFIX = "roles:id:"
GROUP_DELIMITER = ","
POLICY_GROUP_PRIORITY = (
    "vulcan_ap_user1",
    "vulcan_ap_user2",
    "vulcan_ap_user3",
    "vulcan_ap_user4",
)


async def resolve_user_groups(ctx: AuthExtensionContext) -> SecurityContext:
    groups = [
        tag.replace(ROLE_ID_TAG_PREFIX, "", 1).replace("-", "_")
        for tag in ctx.user_tags
        if tag.startswith(ROLE_ID_TAG_PREFIX)
    ]

    group = next(
        (policy_group for policy_group in POLICY_GROUP_PRIORITY if policy_group in groups),
        groups[0] if groups else "",
    )
    return SecurityContext(group=group, groups=GROUP_DELIMITER.join(groups))
```

Write a plugin like this when your policies need a project-specific group name that doesn't match DataOS's raw tags. For example, DataOS might return a role tag like `roles:id:vulcan-ap-user2`, while your policy file expects `vulcan_ap_user2`. The plugin does that translation.

***

## Apply policies to physical models

Policies live in YAML files under `policies/access/`. Each file targets exactly one physical model through `depends_on`. Each rule starts with a `group`; if the plugin returns that group in the `SecurityContext`, Vulcan applies the rule before running the query.

A policy can:

* Leave a group unrestricted, by declaring only `group`.
* Mask sensitive dimensions with `mask`.
* Restrict rows with `filter`.
* Combine masking and row filters in the same policy.

{% hint style="warning" %}
Access is deny-by-default. If a caller's group matches no rule, the query is denied: author a rule for every group that should be able to query the model. `group` must be lowercase snake\_case, matching `^[a-z][a-z0-9_]{0,63}$`. `mask` takes only bare column names; wildcards and inline mask-expression objects are rejected: the actual masking SQL lives in the physical model's `column_mask_expressions`.
{% endhint %}

### Mask a column

The physical model defines its masking expression:

```sql
MODEL (
  name silver.dim_customer_profile,
  column_mask_expressions (
    email = CAST(NULL AS TEXT)
  )
);
```

The policy decides which groups receive the mask:

```yaml
type: policy
name: customer_profile_access
depends_on:
  - silver.dim_customer_profile

rules:
  - group: vulcan_ap_user2
    mask:
      - email
```

When `vulcan_ap_user2` queries a semantic or metric model backed by `silver.dim_customer_profile`, Vulcan returns `email` using the configured `column_mask_expressions` expression instead of the real value. Every column named in a policy's `mask` must have a matching `column_mask_expressions` entry, or `vulcan plan` fails.

A separate `column_classifications` block on the same `MODEL` (for example, `plan_type = restricted`) is catalog metadata only: it documents sensitivity for discovery, but it doesn't mask anything at query time.

### Filter rows

Use `filter` to limit the values a group can query:

```yaml
type: policy
name: customer_profile_access
depends_on:
  - silver.dim_customer_profile

rules:
  - group: vulcan_ap_user2
    filter:
      - member: region_name
        operator: in
        values:
          - North
          - South
```

The `member` must be a column on the protected physical model. The examples use these operators:

* `equals` - allow rows where the member equals one of the listed values.
* `notEquals` - exclude rows where the member equals one of the listed values.
* `in` - allow rows where the member is in the listed values.
* `notIn` - exclude rows where the member is in the listed values.

A `values` entry can be a literal or a `{securityContext.<key>}` template, resolved from the claims your plugin returns. A missing key at query time returns `403`.

Filters can also nest with `and` / `or` for multi-condition logic:

```yaml
rules:
  - group: regional_manager
    filter:
      - or:
          - member: region_name
            operator: equals
            values: [North]
          - member: region_name
            operator: equals
            values: [South]
```

### Combine mask and filter

This policy uses one unrestricted group and several restricted groups:

```yaml
type: policy
name: customer_profile_access
depends_on:
  - silver.dim_customer_profile

rules:
  - group: vulcan_ap_user1
  - group: vulcan_ap_user2
    mask:
      - email
    filter:
      - member: customer_segment
        operator: notEquals
        values:
          - Churned
      - member: region_name
        operator: in
        values:
          - North
          - South
  - group: vulcan_ap_user3
    mask:
      - email
      - customer_name
    filter:
      - member: customer_segment
        operator: in
        values:
          - High Value
          - Medium Value
      - member: region_name
        operator: equals
        values:
          - East
```

In this example:

* `vulcan_ap_user1` can query the model with no masks or filters.
* `vulcan_ap_user2` can't see raw email values and can't query churned customers.
* `vulcan_ap_user3` can't see raw email or customer name values, and can only query selected segments in the East region.

A policy-governed response can include `filter_display`: a read-only summary of the row filter actually applied for the caller's group, useful for showing consumers what slice of data they're looking at.

### Masking and rollups

If the project sets `enable_rollup: true`, a rollup's `group by` dimension can only use a column whose mask is a **constant** expression, such as `CAST(NULL AS TIMESTAMP)` or a string literal. A value-referencing mask like `CONCAT(LEFT(email, 2), '***')` can't be safely re-derived from already-aggregated data, so Vulcan rejects it for rollup dimensions.

***

## End-to-end flow

1. A user sends a semantic query through SQL, REST, or GraphQL.
2. DataOS authorizes the request and returns user tags.
3. Vulcan calls your configured plugin, such as `plugins.auth_ext:resolve_user_groups`.
4. The plugin returns a `SecurityContext` with a primary `group`.
5. Vulcan finds the matching physical-model policy rule and applies its masks and filters before executing the query.

Keep policy group names consistent between the plugin and the policy YAML. If the plugin returns `vulcan_ap_user2`, the policy file must declare `group: vulcan_ap_user2` for that policy to apply.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://v2.dataos.info/build/productize/governance.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
