Skip to main content

Connect a Snowflake directory of your people

This page is for an administrator who has a Snowflake view of their own people — a worker directory, an HR extract, whatever their organization calls it — and wants Prism to answer questions per person against it.

For any other table or view in Snowflake — client accounts, incidents, changes, a gold-layer view of anything that is not a list of people — start from Connect a Snowflake table instead. It is the generic recipe; this page is the one with the structure a directory of people needs on top of it.

You will author a short YAML document called a source manifest that tells Prism which view to read, what each column means, and what Prism is allowed to say about it. There is a worked example below to start from. Nothing here needs Tetrate: you enter the manifest and the credential in Prism's own admin surface, and the connection is between your appliance and your warehouse.

Budget an hour, most of it spent with whoever owns the view.

Read Take a source live first if you have not connected any source yet. It covers what a probe does; this page is the layer above it, and assumes the appliance is already running.

Before you start: one chart value​

This page opens by sending you to Admin → Context streams, and there is one switch that has to be on before what it describes is there to do:

mcps:
semanticMcp:
enabled: true # the only component that executes a query-through statement

A Snowflake source is query-through — nothing is copied into Prism, and every question against it becomes one statement run against your warehouse — so without the semantic query MCP the source registers, probes and shows as connected while nothing on the agent can read it. The self test says so on its designated route line, by name.

sources.registry.enabled, which renders the page itself, defaults to true from 0.10.0; set it only if your values file pins it to false. What the chart needs explains what each renders and why the credential is not a chart value.

Per-person answers do not resolve from a Snowflake query-through directory yet, in this release. The join that turns a GitHub login, a Jira account and an employee id into one person — the identity crosswalk — is compiled by the ingest job. For an Oracle query-through directory it now pulls the two identity columns out of the warehouse once a day and compiles from them (0.12, issue 1112). For a Snowflake one that pull is not enabled, so the compile refuses, in as many words:

… is kind: query-through on engine snowflake, so its rows are never copied into the datapond, and pulling its identity columns from the warehouse is enabled for oracle, postgres and not for snowflake (#938). Designate a landed stream instead — for Snowflake, a daily copy registered from the snowflake_snapshot recipe

Everything else on this page holds: the source connects, probes, answers questions about the view through the semantic query tools, and is where the connection will live when the gap closes. What does not work yet is the per-person resolution described under "Why this one source is different".

If you need per-person answers today, designate a landed stream instead — an uploaded staff list is the fifteen-minute route, and a daily copy of the same Snowflake view is the durable one. The self test's identity crosswalk line tells you which state you are in either way. Tracked as issue 938; this note goes when its Snowflake half does.

Why this one source is different​

Prism can answer "how many pull requests were merged last month" from GitHub alone. It cannot answer "how many did Ada's team merge" — because GitHub knows a login, your Jira knows an account, your warehouse knows an employee number, and nothing tells Prism those are one person.

The organizational directory is what tells it. Exactly one source carries that role, and every other source's per-person figure is resolved through it. That is why this page is longer than the others: getting it right is what makes everything per-person work, and getting it wrong is the one mistake that produces a confident answer about the wrong person.

1. What to ask your DBA for​

Five things, and the fifth is the one that surprises people.

WhatDetail
One viewNot a table and not a schema. Prism reads exactly one view, and what is in it is the whole of what Prism can ever see — which makes the view your access control rather than a setting you have to trust us with
A TYPE = SERVICE userOne service login for the appliance, never one per reader. It appears in QUERY_HISTORY as itself
A read-only roleGranted USAGE on the warehouse, the database and the schema, plus SELECT on the one view. Nothing else. The grant is the control — and the schema grant is the one people leave out, because it is the only one that is not about a thing you named
A warehouseGive this login its own if you want its cost separated — this is what its spend lands on
A key pair, not a passwordSee below. This is not a preference

Key pair, and why a password will not do​

Prism signs in with an RSA key pair: you generate one, register the public half on the service user, and give Prism the private half.

A password will be refused, and not by us. Snowflake is retiring password sign-in for TYPE = SERVICE users through the August–October 2026 window. A service user authenticating by password is on a clock that has already started, and an appliance that ingests on a schedule is exactly the thing that breaks quietly when it runs out. So Prism accepts key-pair only, deliberately, and tells you now rather than at a 400 six weeks in.

Generate an unencrypted PKCS#8 key, or one whose passphrase you have to hand:

openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -out rsa_key.p8 -nocrypt
openssl rsa -in rsa_key.p8 -pubout -out rsa_key.pub

Those two commands produce a PEM private key — the file begins -----BEGIN PRIVATE KEY----- — which is the form Prism wants. Check it:

head -1 rsa_key.p8 # -----BEGIN PRIVATE KEY-----

Then register the public half on the user, in Snowflake:

ALTER USER <service user> SET RSA_PUBLIC_KEY='<the base64 body of rsa_key.pub,
without the BEGIN/END lines>';

Paste the PEM into Prism, not a DER file. Some tooling writes .p8 in binary DER instead — if head -1 shows binary rather than a BEGIN line, that is what you have, and it needs converting first:

openssl pkcs8 -topk8 -nocrypt -inform DER -in rsa_key.p8 -out rsa_key.pem

Prism refuses a key it cannot read at the moment you enter it, not later: the credential is checked on the way in, and a source whose key could not be read never reaches enabled.

2. The five connection values​

You enter these on the source in Admin → Context streams, not in the chart. They are values about your warehouse, and the appliance keeps them beside the source rather than in configuration, so the probe that switches a source on is a probe of the connection that will answer.

ValueWhat it is
accountYour account identifier — myorg-myaccount, or a legacy locator. Prism reaches <account>.snowflakecomputing.com
userThe service login. Configuration, not a secret: it appears in QUERY_HISTORY and in every authentication failure, and somebody has to be able to read it to fix one
warehouseThe virtual warehouse the statements run on
databaseThe database holding your view
schemaOptional. It qualifies the session, not the SQL in your manifest — so if your login's default schema already finds the view, leave it empty

The role is not one of them, deliberately. The read-only role you granted is written into the manifest as a literal and activated on every session, so the ceiling on what a question can read is the grant you made, not a value somebody could forget to set.

And keep these five names in your manifest. The form you fill in is built from the recipe you opened, so it submits the recipe's parameter names — a manifest that calls one of them something else is refused at Register, with a message naming a field you never typed. The example below declares exactly these.

3. The manifest: what Prism needs your view to have​

A directory Prism can resolve people through needs four things. Everything else is optional and yours.

NeededWhy
A person identifier that your other systems also know — most often an employee number issued by your HR systemThis is what every per-person figure is keyed on. It must be issued and never reused; a name is not one
At least one identifier Prism's other sources carry — in practice a corporate email addressThis is the join. Jira discloses an address on the record, so an address here resolves your Jira accounts directly; without one there is nothing to match on and per-person answers stay unanswerable. GitHub is joined less completely: no GitHub record carries an address, so a login resolves only where the member published a profile address this view also holds — or where the view carries a github_login column outright, which is the durable answer. What each route needs
A snapshot date, if your view holds historyPrism reads the latest copy. Without a way to say which rows are current, a person who changed department is two people
A manager reference, if you want team rollupsOne column holding the manager's person identifier. Optional — everything else works without it

Declare every identifier your view carries, and understand how they are used, because it is more literal than it sounds: Prism resolves a person by reading the identifier out of your directory and matching it to the one the other source holds. A GitHub mirror knows logins; a Jira mirror knows addresses. So the more of those your view carries, the more of Prism can be resolved — and an identifier that is hidden in your manifest cannot be selected at all, which is why the example leaves the employee number and the manager reference visible and hides only the address.

There is also a crosswalk that resolves one kind of identifier to another from your identity_links. It is compiled and measured in this release, and it is not yet what answers a question — so treat the identifiers your view actually carries as the whole of what you get today.

4. The worked example, and which parts are yours​

Below is a complete manifest for a directory view. Read the annotations — some of this is structure every directory needs, and some is an example of a thing your view will call something else.

apiVersion: prism.tetrate.io/source/v0
name: org_directory # yours: lower case, and it is permanent
kind: query-through # STRUCTURAL: nothing is copied out of your warehouse

connection:
auth: {type: database, credential_ref: directory_key}
credential_mode: service
tier: warehouse
database:
engine: snowflake
auth_mode: key_pair # STRUCTURAL: see section 1
account_parameter: account
warehouse_parameter: warehouse
database_parameter: database
schema_parameter: schema
user_parameter: user
read_only_role: PRISM_READER # yours: the role you granted
parameters:
# These five NAMES are what the admin form prompts for. Rename one and the
# form asks for a value your manifest does not declare, and registration is
# refused — so keep them.
account: {type: string, required: true}
warehouse: {type: string, required: true}
database: {type: string, required: true}
schema: {type: string}
user: {type: string, required: true}

model:
datasets:
- name: people
source: WORKER_DIRECTORY # YOURS: your view, a BARE name (see below)
# YOURS, and it is compared character for character across your enabled
# sources to count what you are licensed for — so pick one spelling and
# keep it.
stream: Organizational directory
# STRUCTURAL: the REAL grain. One row per person per snapshot means
# `person_id` alone is not a key — it repeats once per `as_of`.
primary_key: [person_id, as_of]
ai_context: >-
One row per person per snapshot. Read the latest snapshot unless a
question asks about a date.
fields:
# `sql:` is the COLUMN IN YOUR VIEW. `name:` is what Prism calls it.
# Where they are the same you may omit `sql:` — but write it anyway
# while you are authoring, because a name that happens to match is
# indistinguishable from one you checked.
# WHAT IS HIDDEN AND WHAT IS NOT IS A DECISION, not a style. A hidden
# field cannot be selected or grouped by — so an identifier you want
# answers keyed on must NOT be hidden, and one that would be a
# disclosure as a grouping key must be.
- name: person_id
type: string
identity: employee_id # STRUCTURAL: the kind, not the column
sql: EMPLOYEE_NUMBER # YOURS
ai_context: The employee number. The key every per-person figure is grouped on.
- name: email
type: string
identity: email # STRUCTURAL
sql: WORK_EMAIL # YOURS
# HIDDEN deliberately: an address is something to resolve THROUGH,
# and an answer grouped by email address is a list of your staff's
# addresses.
hidden: true
ai_context: Work email address. Used to resolve identity; never shown as a grouping key.
- name: manager_person_id
type: string
identity: employee_id
sql: SUPERVISOR_NUMBER # YOURS — omit this field if you have no manager column
ai_context: >-
The employee number of this person's DIRECT manager, in the same form
as person_id. Group by it for a per-manager figure.
- name: full_name
type: string
person: true # STRUCTURAL: names a person, joins on nothing
sql: PREFERRED_NAME # YOURS
hidden: true
ai_context: Display name. Names a person, resolves to no system.
- name: as_of
type: date
time_intervals: [DAY, WEEK, MONTH]
sql: SNAPSHOT_DATE # YOURS
ai_context: The date of this snapshot.
- name: is_latest_snapshot
type: boolean
# YOURS, and note it names your view again inside the subquery.
sql: SNAPSHOT_DATE = (SELECT MAX(SNAPSHOT_DATE) FROM WORKER_DIRECTORY)
hidden: true
ai_context: Whether this row is from the most recent snapshot.
# STRUCTURAL, and the most consequential three lines on the page.
# `default_filters` — not `required_filters`. A default is APPLIED when a
# question does not mention the field, and yields to one that does; a
# `required_filter` merely REFUSES a question that does not name the
# field, and accepts `false`. Without a default here every count is a
# count of snapshots rather than of people.
default_filters:
- is_latest_snapshot: true
metrics:
people:
type: count_distinct
# STRUCTURAL: the FIELD, not your column. A metric's `sql` that names
# a field resolves to that field's `sql:`, so the column is written
# once, on the field above.
sql: person_id
ai_context: How many distinct people the directory lists.

identity_links:
# STRUCTURAL. It says "a row of this dataset carries both of these, so they
# are one person" — which is what the crosswalk is compiled from and what the
# coverage figure will be measured against. It is NOT what answers a question
# today: see section 3.
- from: employee_id
to: email
via: people
from_field: person_id
to_field: email
coverage: declared

policy:
source_label: Snowflake worker directory
# YOURS, AND OPTIONAL. The per-person floor is your INSTALLATION's setting
# (`disclosure.perPersonFloor`, default 0 — off); this line RAISES it for this
# source alone. Read section 8 before you add or remove it. Delete the line
# and the directory inherits whatever your installation is set to.
min_n: 5
# STRUCTURAL where you keep a floor at all: which aggregations it withholds
# is a statement about this data, and in a directory every row is a person.
min_n_applies_to: [count, count_distinct, sum, average, median]
verification:
status: connected_unverified
# YOURS. Each needs a `prompt` AND a `query` — the query is what Prism runs,
# the prompt is what you are asked to check. See section 7.
questions:
- prompt: >-
How many people does the directory list? (Check it against the row
count from section 5's query 4.)
query:
source: org_directory
dataset: people
metrics: [people]
limit: 1

dataset.source is a bare view name​

Write WORKER_DIRECTORY, not MYDB.MYSCHEMA.WORKER_DIRECTORY. The database and schema come from the session, which Prism sets from the connection values you entered — so a fully-qualified name in the manifest would be a second place that has to agree with the first, and the first is the one an operator can change without editing YAML.

5. Check every column exists — before you register anything​

Do this. It is the single highest-value five minutes on this page.

If you declare a column that does not exist, nothing catches it early: a successful probe proves the session opened and the credential was accepted, and says so in exactly those words — it does not claim your view exists or that the role can read it. The mistake surfaces later as an empty answer or an error about something else.

Run this in your own Snowflake session, as the service user, with the role you granted:

USE ROLE PRISM_READER;
USE WAREHOUSE <your warehouse>;
USE DATABASE <your database>;
USE SCHEMA <your schema>; -- do this even if you left `schema` blank in Prism:
-- the queries below need to resolve the view too

-- 1. The view is visible to this role at all.
SELECT COUNT(*) FROM WORKER_DIRECTORY;

-- 2. Every column you named in `sql:` exists. Add one line per field.
-- Snowflake stores unquoted identifiers in UPPER CASE, so compare in upper
-- case whatever your manifest spells them as.
SELECT COLUMN_NAME, DATA_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'WORKER_DIRECTORY'
AND COLUMN_NAME IN ('EMPLOYEE_NUMBER', 'WORK_EMAIL', 'SUPERVISOR_NUMBER',
'PREFERRED_NAME', 'SNAPSHOT_DATE')
ORDER BY COLUMN_NAME;

-- 3. The latest-snapshot filter selects something.
SELECT COUNT(*) FROM WORKER_DIRECTORY
WHERE SNAPSHOT_DATE = (SELECT MAX(SNAPSHOT_DATE) FROM WORKER_DIRECTORY);

-- 4. The join column is actually populated. This number is the ceiling on
-- every per-person answer Prism will ever give you.
SELECT COUNT(*) AS people,
COUNT(WORK_EMAIL) AS with_an_address
FROM WORKER_DIRECTORY
WHERE SNAPSHOT_DATE = (SELECT MAX(SNAPSHOT_DATE) FROM WORKER_DIRECTORY);

Query 2 must return one row per column you listed, and counting them is the point — a column that is missing simply does not appear, so a result with four rows where you listed five is the finding. If query 1 fails instead, the usual cause is the schema grant rather than a column name. Query 4 is the one to write down: if half your directory has no address, half your people cannot be resolved, and Prism will report that figure rather than hide it — but you should know it before it appears on a screen.

Why the checking matters more than it should​

Correcting a registered manifest currently means deleting the source and registering it again, which destroys the stored credential — so a single wrong column name costs you a full re-entry, including going back for the private key. That is a rough edge we are removing; until then, the cheap order is: get the SQL right in your own session, then register once.

6. Register it, and what the probe does and does not prove​

Sign in as a super admin and open Admin → Context streams. Pick the Snowflake worker directory recipe from the list (snowflake_directory); open Review the manifest and replace the document with yours. Enter the five connection values and the private key, then probe.

The recipe's own document is annotated the way section 4 is, with one more marker: OURS, on the fields that exist only because the directory we test against happens to carry them — a GitHub login and whether it was revoked, how each address was matched, which system a row came from. Each one's comment lists what goes with it. Delete those if your view has no such column; a directory without them is complete.

Then, and this step is easy to miss because nothing prompts for it: approve the source, and only then enable it. A source moves registered → probed → approved → enabled, and enable is refused from probed. Approving is you saying you have read the manifest; enabling is you saying it may answer.

A passing probe means three things and no more:

  • the account was reachable;
  • the credential was not rejected;
  • and the session Prism got is the one it asked for — same role, warehouse, database and schema.

That third one is worth its own sentence, because Snowflake does not refuse a warehouse or a schema that does not exist. It accepts the request and quietly gives you a session without one, and every real question then fails. So Prism reads the session back and refuses on your behalf.

A passing probe does not mean your view exists, that the role can read it, or that any column you named is real. That is what section 5 is for.

And enabled is not verified. Enabling a source means this installation demonstrably reached it. Whether the figures are right is section 7, and it is not something the appliance can decide.

7. Verify it — with your questions, against your figures​

Prism ships no verification questions for a directory you authored, and cannot: a question worth asking names your own columns and has an answer only you know.

Write three or four in the manifest's policy.verification.questions, ask them on the verification screen, and mark each correct, wrong or skipped. Only a question you have marked correct earns the proven by you badge that a reader sees beside an answer — nothing the appliance does can produce it.

Good ones are the ones where you already know the number:

  • How many people does the directory list? — against the figure from query 4.
  • How many are in each department? — against a number your HR team publishes.
  • How many people report to each of the top-level leaders? — this is the one that catches a manager column pointing the wrong way, which is otherwise invisible and inverts every team rollup.

8. Two things the product will do that may surprise a reader​

Small teams come back withheld, not zero — if you have set a floor. The per-person floor is your installation's setting, and Prism ships with it off: disclosure.perPersonFloor in your values file, 0 by default. With no floor, an answer computed over two people is shown.

That default is deliberate. Prism runs on your infrastructure, every user is your own employee signed in through your own IdP, and the data is yours — so whether figures about your staff are withheld below a threshold is your decision, not a behaviour we ship you and bury the switch for.

When you do set one, an answer computed over fewer people than that is suppressed rather than shown, and the reader is told it was suppressed. In a directory every row is a person, so a headcount grouped by manager is a statement about one named manager's team — which is why the example above raises the floor for this source with its own min_n: 5, and opts counts in as well as averages. A floor of 1 means a question can name an individual.

Two things follow from the floor that are easy to miss:

  • It also governs how a name may be matched. With a floor set, a partial match on a person's name or address (starts_with, contains) is refused, because the same query repeated with a growing prefix reads the column back a piece at a time. With no floor there is nothing to read back that way, so those operators are available.
  • It is not a confidentiality boundary. min_n withholds statistics. It does not stop a determined reader inferring a value from what filtering on it returns, and hidden on a field controls what an answer prints rather than what can be worked out. If a value must not be knowable by anyone who can sign in, do not put it in a connected source.

A partly-populated join is measured, not refused. If a fraction of your directory carries no address — or no GitHub login — the people it does not cover are simply not resolved, and Prism answers about the ones it can rather than refusing. Nothing warns you, in this release: section 5's query 4 is where that number comes from, and running it yourself is the only way to know it today. A measured coverage figure on the source panel, and a threshold that acts on it, are both later work — so write your own number down, and re-run query 4 after your first month.

9. One directory, and what the refusal of a second means​

Registering this manifest does not make it the directory, and the recipe carries no role: line to suggest otherwise. Which source is your directory is a designation you make on the registered source — Designate on Admin → Context streams, or POST /api/admin/sources/<id>/organizational. Until you do, it runs as an ordinary source beside whatever directory you already have, and enabling it is never refused because of one.

Exactly one enabled source may hold the designation. If you designate a second, Prism refuses and names the one that already holds it. That is not a limitation to work around: two directories mean two answers to who is this person, and nothing to say which is right — which is the failure this whole page exists to avoid.

To move the designation, use Move to… on the current holder's pane (or POST /api/admin/organizational/move): one act, so your installation is never without a directory in between.

A source registered by a release before 0.16.0 stored the role: line as it stood, so it carries a designation nobody made. If enabling it is refused because another source holds the directory, press Withdraw designation on its row (the API is DELETE /api/admin/sources/<id>/organizational) and then enable it; the stored key pair is kept. To make it the directory instead, use Move to… on the current directory's row.

If something is wrong​

  • The probe fails on the account or credential — the message names which. Check the account identifier's shape, and that the public key on the user is the pair of the private key you pasted.

  • The probe passes and questions return nothing — almost always a column name. Go back to section 5's query 2.

  • Answers cover fewer people than you expect — query 4's with_an_address figure is your ceiling, and re-running it is how you check whether it moved.

  • A figure comes back withheld — that is a floor you set, working. Section 8. If you did not expect any withholding, check disclosure.perPersonFloor and this source's own policy.min_n.

  • Register is refused with a message about a field you did not fill in — the form sends the recipe's parameter names, so your manifest must declare the same five. Section 2.

  • Enable is refused on a source that probed cleanly — you have not approved it yet. Section 6.

The self test reports the source's state and what it last did, and is the first thing to send us if none of the above fits.