> ## Documentation Index
> Fetch the complete documentation index at: https://docs.credibledata.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Fine-Grained Access Control

> Row, column, and source-level access control in your Malloy models

Fine-grained access control lives in your Malloy models and is enforced on every query. While [resource hierarchy](/platform-admin/groups-permissions) controls access at the environment and package level ("can you see this package?"), fine-grained ACLs work at the source level, in three layers:

1. **Row scope** — which rows do they see? Filter with a `where:` clause over a secure given.
2. **Source access** — can this caller query the source at all? Gate it with `#(authorize)`.
3. **Column scope** — which fields are exposed? Restrict them with `accept:`/`except:`, and gate sensitive columns with a separate `#(authorize)` source.

Row scope and source access decide access from **secure givens**. [Givens](https://docs.malloydata.dev/documentation/experiments/givens) are a Malloy language construct — named values a model declares once and receives at query time, referenced with `$`. A **secure** given is one Credible fills server-side from the caller's verified identity — their email — so a caller cannot forge it. Column scope builds on the same `#(authorize)` gate. If you need identity resolved from something other than email, [reach out](mailto:support@credibledata.com).

<Note>
  This is separate from **discovery curation** (`explores` / `queryableSources` in [`publisher.json`](/how-to/modeling/publishing#curating-discovery)), which controls *which* sources are listed and queryable by name — not *who* may query them.
</Note>

## Row scope: secure givens

Scope rows with a `where:` clause over a **secure given** — a `given:` Credible populates from the caller's verified identity. Givens are declared at the top of the model (not inside a source) and referenced with `$`. Mark a custom given `#(secure)` and declare it **set-valued** (`string[]`), then filter with a membership test (`in`) — secure givens are set-valued by design (a scalar secure given isn't enforced).

```malloy theme={null}
##! experimental.givens

given:
  #(secure)
  ALLOWED_TENANTS :: string[]

source: orders is conn.table('orders') extend {
  // Each caller sees only the tenants the platform grants them
  where: tenant_id in $ALLOWED_TENANTS

  measure:
    order_count is count()
}
```

The given's values aren't in the model — they're a **lookup table** you manage on the [Access Control page](/platform-admin/admin-portal#access-control). Each row grants a **user** (by email), a **group**, or **everyone** (a default) a list of values — and the values are the literal data values your `where:` compares against (here, `tenant_id` values). At query time Credible takes the caller's verified email, resolves their groups, and **merges every applicable row into one set**: a group grant is a floor shared by all its members, and individual users can be topped up with extra values. A caller with no applicable row resolves to an empty set, which matches nothing — access fails closed.

For the example above: grant the `support` group `["acme"]` and [alice@yourco.com](mailto:alice@yourco.com) `["globex"]`, and Alice — a member of `support` — resolves `$ALLOWED_TENANTS` to `["acme", "globex"]` and sees both tenants' rows, while her teammates see only `acme`'s.

Referencing a custom secure given in a published model is also how the platform learns it exists — the attribute appears on the Access Control page once a model gates on it — so declaring `#(secure) ALLOWED_TENANTS` is only half the setup; assigning values is the other half.

`$GROUPS` is built-in — declare `GROUPS :: string[]` in the model (no `#(secure)` needed), and Credible fills it with the **names of the groups** the caller belongs to in your organization (the same groups you manage in [Users & Groups](/platform-admin/groups-permissions)), with no admin-portal assignment. The values are literally the group names, matched against your data **exactly, including case** — a group named `West` won't match a `west` value. So filtering on `$GROUPS` means naming groups after your data values: to scope rows by region, create a group per region (`west`, `east`, …), add each user to their regions, and filter with a membership test:

```malloy theme={null}
##! experimental.givens

given:
  GROUPS :: string[]

source: sales_by_group is conn.table('sales') extend {
  // $GROUPS is the caller's group names: a user in groups 'east' and
  // 'west' sees rows where region = 'east' or region = 'west'
  where: region in $GROUPS

  measure:
    revenue is sum(amount)
}
```

## Source access: `#(authorize)`

Gate whether a caller can query a source at all with `#(authorize)`, a Malloy boolean expression over the model's givens, placed above the `source:`. A source with no `#(authorize)` is unrestricted; stack multiple and they combine as **OR** (any `true` grants).

```malloy theme={null}
##! experimental.givens

given:
  GROUPS :: string[]

// Only members of the 'support' group (the team granted the acme tenant
// above) can see ticket details — refund notes, customer conversations
#(authorize) "'support' in $GROUPS"
source: support_tickets is conn.table('support_tickets') extend {
  measure:
    open_ticket_count is count() { where: status = 'open' }
}
```

The gate can read **any given, not just `$GROUPS`** — including a custom secure given whose values you assign per user or group on the [Access Control page](/platform-admin/admin-portal#access-control). That lets you grant source access to specific individuals without hardcoding emails in the model:

```malloy theme={null}
##! experimental.givens

given:
  #(secure)
  ALLOWED_TENANTS :: string[]

// Only callers whose assigned tenant list includes 'acme' can query this
#(authorize) "'acme' in $ALLOWED_TENANTS"
source: acme_orders is conn.table('orders') extend {
  where: tenant_id = 'acme'

  measure:
    order_count is count()
}
```

Assign [dana@yourco.com](mailto:dana@yourco.com) a user-scope value of `["acme"]` and she passes the gate; change or remove the assignment and her access follows — no republish needed.

<Warning>
  **An access gate is only as trustworthy as the given it reads.**

  * **Secure** — a `#(secure)` set-valued given (`string[]`), or the built-in `$GROUPS`. Credible resolves these server-side and strips any caller-supplied copy. Filter and gate with `in`.
  * **Bypassable** — a plain or scalar given a caller can set. The caller can send the value and pass the gate. Credible flags a non-blocking advisory when an `#(authorize)` gate reads a non-secure given.
  * A `where:` over an ordinary (non-secure) given is fine for parameterization — it just isn't an access boundary.
</Warning>

## Column scope: restricting fields

Control which fields a source exposes with Malloy's `accept:` (an allowlist) or `except:` (a denylist), inside a source `extend`. These are **static** — they can't read a given, so one source can't show a column to some callers and hide it from others. (`accept:` and `except:` can't be combined.)

Say the `orders` table has five columns: `order_id`, `status`, `amount`, `customer_email`, and `credit_card_number`. Either style hides the sensitive ones:

```malloy theme={null}
// Denylist style
source: orders is conn.table('orders') extend {
  // except: removes the listed fields from the source
  except: customer_email, credit_card_number

  measure:
    order_count is count()
}

// Allowlist style — safer for sensitive tables: a column added to the
// table later stays hidden until you opt it in
source: orders_safe is conn.table('orders') extend {
  // accept: keeps only the listed fields; everything else is dropped
  accept: order_id, status, amount

  measure:
    order_count is count()
}
```

Both expose exactly `order_id`, `status`, and `amount` — querying `credit_card_number` against either is a compile error, because the field doesn't exist on the source.

To expose a column to **some** callers only, split into two sources and gate the full one with `#(authorize)`:

```malloy theme={null}
##! experimental.givens

given:
  GROUPS :: string[]

// Everyone: orders without the sensitive columns
source: orders is conn.table('orders') extend {
  except: customer_email, credit_card_number

  measure:
    order_count is count()
}

// The billing team only: the same table, all five columns
#(authorize) "'billing' in $GROUPS"
source: orders_billing is conn.table('orders') extend {
  measure:
    order_count is count()
}
```

Callers in the `billing` group query `orders_billing` and see every column, including `credit_card_number`. Everyone else queries `orders`, where the sensitive columns don't exist.

`#(authorize)` gates *querying*, not *discovery* — the gated source still appears in listings (name, fields, docs) to callers who can't query it. To hide it from listings while keeping it queryable for authorized callers, curate it out of discovery with `queryableSources: "all"` — see [discovery curation](/how-to/modeling/publishing#curating-discovery).

<Note>
  Malloy also has experimental [`public` / `private` / `internal` field modifiers](https://docs.malloydata.dev/documentation/experiments/include), which control whether a field can be used in queries and in source extensions. Like `accept:`/`except:`, they are static — not per-caller access control. Use the source-split pattern above for identity-based column access.
</Note>

***

Have custom access control requirements? [Contact us](mailto:support@credibledata.com) to discuss your use case.

## Next Steps

<CardGroup cols={2}>
  <Card title="Embedded Analytics" icon="chart-line" color="#987362" href="/how-to/integrating/embedded-analytics">
    Build data applications with user-specific access
  </Card>

  <Card title="Users & Groups" icon="users-gear" color="#988962" href="/platform-admin/groups-permissions">
    Configure environment and package-level access
  </Card>
</CardGroup>
