Repository: ariga/atlas
Pull request #2320 (2023-11-27 12:24:37)

doc/md/guides: documenting working with secrets


Patch:
diff --git a/doc/md/cloud/bot.mdx b/doc/md/cloud/bot.mdx
index d9cb8562..de697823 100644
--- a/doc/md/cloud/bot.mdx
+++ b/doc/md/cloud/bot.mdx
@@ -15,15 +15,27 @@ In this guide, we will show how to create a new bot user, and how to create an A
 To create a bot user, make sure you are logged in as an administrator to you Atlas Cloud account.
 
 Follow these steps to create a new bot user:
-1. To get to the bot management screen, click on "Settings > Bots" in the left navigation.
-  In this screen, click on the "Create Bot" button.
+1. To get to the bot management screen, click on _**Settings > Bots**_ in the left navigation.
+  In this screen, click on the _**Create Bot**_ button.
+
+  <details>
+  <summary>Screenshot Example</summary>
 
   ![](https://atlasgo.io/uploads/cloud/bots/create-bot-button.png)
+  </details>
+
+2. Give your bot a name and click _**Save**_.
 
-2. Give your bot a name and click "Save".
+  <details>
+  <summary>Screenshot Example</summary>
 
   ![](https://atlasgo.io/uploads/cloud/bots/create-bot-modal.png)
+  </details>
 
 3. Copy the token and store it in a safe place. You will not be able to see it again.
 
-  ![](https://atlasgo.io/uploads/images/bot-token.png)
\ No newline at end of file
+  <details>
+  <summary>Screenshot Example</summary>
+
+  ![](https://atlasgo.io/uploads/images/bot-token.png)
+  </details>
\ No newline at end of file
diff --git a/doc/md/cloud/directory.mdx b/doc/md/cloud/directory.mdx
index 50930d24..d6e614f5 100644
--- a/doc/md/cloud/directory.mdx
+++ b/doc/md/cloud/directory.mdx
@@ -4,34 +4,85 @@ id: directories
 slug: /cloud/directories
 ---
 
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+import MigratePushCommand from '../components/migrate-push-command.mdx'
+
 The following guide explains how to connect a migration directory to Atlas Cloud. Once connected, Atlas provides a list
 of features enabling users to seamlessly audit their migration history, run continuous migration linting on their PRs,
-monitor executed migrations and receive Slack notifications, among other capabilities.
+monitor executed migrations and receive webhook notifications, among other capabilities.
+
+To get started, you need to create the migration directory in Atlas Cloud.
+
+### Login to Atlas Cloud
+
+Firstly, you'll need to [log in to Atlas](https://auth.atlasgo.cloud/signup). If it's your first time, you'll be
+prompted to create both an account and a workspace (organization):
+
+<Tabs>
+<TabItem value="web" label="Via Web">
+
+```bash
+atlas login
+```
+
+</TabItem>
+<TabItem value="token" label="Via Token">
+
+```bash
+atlas login --token "ATLAS_TOKEN"
+```
+
+</TabItem>
+<TabItem value="env-var" label="Via Environment Variable">
 
-There are two ways for connecting migration directories to Atlas Cloud:
+```bash
+ATLAS_TOKEN="$ATLAS_TOKEN" atlas login
+```
 
-1. **Push mode**: Set up a GitHub Action, [`atlas-sync-action`](https://github.com/ariga/atlas-sync-action), to persist
-  the desired state of the migration directory in Atlas Cloud. Refer to the [Action documentation](https://github.com/ariga/atlas-sync-action#usage)
-  for installation guidance for your project.
-2. **Pull mode**: Connect your migration directory stored in GitHub with the Atlas GitHub App. To do so, follow these steps:
-   - Navigate to `/dirs` and click on **Add Directory**.
-   - Select **Continue with GitHub** and grant Atlas access to the relevant repositories.
-   - Once authorized by GitHub, select the path to the migration directory and click **Connect Directory**.
-     <details>
-     <summary>Screenshot example</summary>
+</TabItem>
+</Tabs>
 
-     ![](https://atlasgo.io/uploads/images/blog-v010/configure-dir.png)
+### Push the migration directory
 
-     </details>
-   - After the migration files are processed, you will be redirected to the migration directory page.
-     <details>
-     <summary>Screenshot example</summary>
+After logging in, we can push the migration directory to Atlas Cloud to persist its state. This allows it to be read by
+Atlas CLI later on in our CI/CD pipelines or by other developers on our team.
 
-     ![](https://atlasgo.io/uploads/images/blog-v010/view-dir-erd.png)
+<MigratePushCommand/>
 
-     </details>
+:::note
+For the purpose of this example, we have named our migration directory `app`, but you can choose a
+different name containing only alphanumeric characters, dashes, and underscores.
+:::
+
+<div style={{textAlign: 'center'}}>
+    <img src="https://atlasgo.io/uploads/versioned/migrate-push1.jpg" alt="atlas migrate push" width="100%"/>
+    <p style={{fontSize: 12}}>Migration Directory created with <code>atlas migrate push</code></p>
+</div>
+
+### Setup CI
+
+After setting up your migration directory, the next step is to add Atlas to your CI pipeline. This ensures that the
+migration files are checked and reviewed before being merged into the main branch. Below are the supported CI platforms
+Atlas currently supports:
+
+- [GitHub Actions](/cloud/setup-ci#github-action-workflow)
+- [GitLab CI](/guides/ci-platforms/gitlab)
+- Using other CI platforms? [Let us know!](https://github.com/ariga/atlas/issues/new)
+
+
+### Setup CD
+
+Atlas Cloud streamlines deploying migrations by providing a single place to manage migrations for all your services.
+After connecting your migration directory to Atlas Cloud, it is automatically synced to a central location on every
+commit to your main branch. Once this setup (which takes less than one minute) is complete, you can deploy migrations
+from Atlas Cloud to any environment with a single command (or using popular CD tools such as Kubernetes and Terraform).
+
+For more information, visit the _Introduction to schema migrations deployments_ in the [guides section](/guides/deploying/intro).
 
 ### Slack Integration
+
 You can use Slack Webhooks to receive updates in your Slack channel about schema changes, CI runs and deployment statuses of your migration directory.
 Make sure you have a Slack incoming webhook configured
 (you can create one [here](https://my.slack.com/services/new/incoming-webhook)).
diff --git a/doc/md/cloud/getting-started.mdx b/doc/md/cloud/getting-started.mdx
index a88d6736..b50d488d 100644
--- a/doc/md/cloud/getting-started.mdx
+++ b/doc/md/cloud/getting-started.mdx
@@ -26,8 +26,11 @@ teams make the most out of Atlas with as little friction and effort as possible.
 
 ### How do I get started?
 
-1. **Create an account** - in the [signup page](https://auth.atlasgo.cloud/signup) and follow the instructions
-   to create an account.
+1. **Create an account** - in the [signup page](https://auth.atlasgo.cloud/signup) or from the CLI:
+
+   ```
+   atlas login
+   ```
 2. **Connect a migration directory** - To unlock CI, deployments and visibility features you need to connect a migration
    directory to your account. [Follow this guide](/cloud/directories) to learn how.
 3. **Deploy from your connected directory** - Once you've connected a migration directory, you can deploy migrations
@@ -49,22 +52,22 @@ Atlas Cloud users can get early access to beta drivers by joining the Atlas Clou
 you first need to create an account on Atlas Cloud, enable it in your account settings, and then login to the CLI
 using your Atlas identity.
 
-1\. Sign up first on [Atlas Cloud](https://auth.atlasgo.cloud/signup).
+1. Sign up first on [Atlas Cloud](https://auth.atlasgo.cloud/signup).
 
-2\. Go to the account settings by clicking your avatar. Then, select the driver you want to get access to. For example,
+2. Go to the account settings by clicking your avatar. Then, select the driver you want to get access to. For example,
 Microsoft SQL Server.
 
-<details>
-<summary>Screenshot example</summary>
+  <details>
+  <summary>Screenshot example</summary>
 
-![](https://atlasgo.io/uploads/cloud/beta-driver.png)
+  ![](https://atlasgo.io/uploads/cloud/beta-driver.png)
 
-</details>
+  </details>
 
-3\. Last, run `atlas login` to login or refetch your account permissions:
+3. Last, run `atlas login` to login or refetch your account permissions:
 
-```text
-$ atlas login a8m
-//highlight-next-line-info
-You are now connected to "a8m" on Atlas Cloud.
-```
+  ```text
+  $ atlas login a8m
+  //highlight-next-line-info
+  You are now connected to "a8m" on Atlas Cloud.
+  ```
diff --git a/doc/md/cloud/setup-ci.mdx b/doc/md/cloud/setup-ci.mdx
index 4040aa26..d04aaa95 100644
--- a/doc/md/cloud/setup-ci.mdx
+++ b/doc/md/cloud/setup-ci.mdx
@@ -3,8 +3,7 @@ id: setup-ci
 title: Setting up CI for your Database with Atlas Cloud
 ---
 
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
+import MigratePushCommand from '../components/migrate-push-command.mdx'
 
 As your application evolves, so does your database schema. If you're not careful
 with schema changes (migrations), you can end up introducing all sorts of issues
@@ -36,28 +35,9 @@ the [GitLab CI](/guides/ci-platforms/gitlab), for example.
   ```
 
 3. Push your migration directory to Atlas Cloud. This is used by the CI process to detect
-   which migration files are new and if linear history is maintained.
+   which migration files are new and if linear history is maintained:
 
-   To push your migrations to Atlas Cloud, run `atlas migrate push` in your terminal:
-
-    <Tabs>
-    <TabItem value="postgres" label="PostgreSQL">
-
-    ```bash
-    atlas migrate push app \
-      --dev-url "docker://postgres/15/dev?search_path=public"
-    ```
-
-    </TabItem>
-    <TabItem value="mysql" label="MySQL">
-
-    ```bash
-    atlas migrate push app \
-      --dev-url "docker://mysql/8/dev"
-    ```
-
-    </TabItem>
-    </Tabs>
+   <MigratePushCommand/>
 
 
 ## GitHub Action Workflow
diff --git a/doc/md/components/migrate-push-command.mdx b/doc/md/components/migrate-push-command.mdx
new file mode 100644
index 00000000..044878a4
--- /dev/null
+++ b/doc/md/components/migrate-push-command.mdx
@@ -0,0 +1,50 @@
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+<Tabs>
+<TabItem value="postgres" label="PostgreSQL">
+
+```bash
+atlas migrate push app \
+  --dev-url "docker://postgres/15/dev?search_path=public"
+```
+
+</TabItem>
+<TabItem value="mysql" label="MySQL">
+
+```bash
+atlas migrate push app \
+  --dev-url "docker://mysql/8/dev"
+```
+
+</TabItem>
+<TabItem value="maria" label="MariaDB">
+
+```bash
+atlas migrate push app \
+  --dev-url "docker://mariadb/latest/dev"
+```
+
+</TabItem>
+<TabItem value="sqlite" label="SQLite">
+
+```bash
+atlas migrate push app \
+  --dev-url "sqlite://dev?mode=memory"
+```
+
+</TabItem>
+<TabItem value="sqlserver" label="SQL Server">
+
+Run first the following command to start a local MSSQL server using Docker:
+
+```bash
+docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=P@ssw0rd0995' -p 1433:1433 -d mcr.microsoft.com/mssql/server:2019-latest
+```
+```bash
+atlas migrate push app \
+    --dev-url "sqlserver://sa:P@ssw0rd0995@localhost:1433?database=master"
+```
+
+</TabItem>
+</Tabs>
\ No newline at end of file
diff --git a/doc/md/guides/ci-platforms/gitlab.mdx b/doc/md/guides/ci-platforms/gitlab.mdx
index bb7f15f9..1946f916 100644
--- a/doc/md/guides/ci-platforms/gitlab.mdx
+++ b/doc/md/guides/ci-platforms/gitlab.mdx
@@ -5,6 +5,7 @@ slug: /guides/ci-platforms/gitlab
 ---
 
 import InstallationInstructions from '../../components/_installation_instructions.mdx'
+import MigratePushCommand from '../../components/migrate-push-command.mdx'
 import Tabs from '@theme/Tabs';
 import TabItem from '@theme/TabItem';
 
@@ -42,32 +43,7 @@ atlas login
 
 Once you have logged in, you can push your migrations to Atlas by running the following command:
 
-<Tabs>
-<TabItem value="sqlite" label="SQLite">
-
-```bash
-atlas migrate push app \
-  --dev-url "sqlite://db?mode=memory"
-```
-
-</TabItem>
-<TabItem value="postgres" label="PostgreSQL">
-
-```bash
-atlas migrate push app \
-  --dev-url "docker://postgres/15/dev?search_path=public"
-```
-
-</TabItem>
-<TabItem value="mysql" label="MySQL">
-
-```bash
-atlas migrate push app \
-  --dev-url "docker://mysql/8/dev"
-```
-
-</TabItem>
-</Tabs>
+<MigratePushCommand/>
 
 Atlas will print a URL leading to your migrations on Atlas Cloud.  You can visit this URL to view your migrations.
 
diff --git a/doc/md/guides/deploying/secrets.mdx b/doc/md/guides/deploying/secrets.mdx
new file mode 100644
index 00000000..d410e0be
--- /dev/null
+++ b/doc/md/guides/deploying/secrets.mdx
@@ -0,0 +1,216 @@
+---
+id: secrets
+title: Working with secrets
+slug: /guides/deploying/secrets
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+Database credentials are considered sensitive information and should be treated as such. In this guide,
+we will show how to use Atlas to handle database credentials in a secure manner.  We will present two strategies
+for handling database credentials, and show how to use Atlas to implement them: IAM Authentication and Secret Stores.
+
+## Secret Stores
+
+Secret stores are systems or services that allow users to store and retrieve sensitive information. The main features
+of secret stores are encryption, access control, and auditing.  All cloud providers offer some form of secret store
+service, and there are also many open-source alternatives.  
+
+When working with secret stores, Atlas assumes that the secret store is already provisioned and configured.  Atlas
+supports the following secret stores:
+
+* [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/)
+* [GCP Secret Manager](https://cloud.google.com/secret-manager)
+
+Support for other secret stores is planned, if you have a specific request, please [open an issue](https://github.com/ariga/atlas/issues/new).
+
+## IAM Authentication
+
+IAM authentication is a mechanism that allows users to authenticate to a database using their cloud provider credentials.
+The main advantage of IAM authentication is that it allows users to avoid storing database credentials altogether. Although
+setting this up may be more cumbersome, it is considered a best practice for many cloud providers.  IAM authentication is also more secure than 
+using passwords. Even strong passwords stored in encrypted form can be leaked and used by attackers. IAM authentication
+allows users to avoid storing database credentials altogether.
+
+IAM authentication is currently supported on GCP and AWS. Support for other cloud providers is planned as well,
+if you have a specific request, please [open an issue](https://github.com/ariga/atlas/issues/new).
+
+## Retrieving Credentials from a Secret Store
+
+Atlas can retrieve information from a secret store at runtime using the `runtimevar` data source.  The `runtimevar`
+data source uses the [`runtimevar` package](https://gocloud.dev/howto/runtimevar/) from the Go [CDK](https://gocloud.dev/).
+To read more about using `runtimevar` with Atlas, view the [data source documentation](atlas-schema/projects#data-source-runtimevar).
+
+<Tabs
+defaultValue="aws-secret-manager"
+values={[
+{label: 'AWS Secret Manager', value: 'aws-secret-manager'},
+{label: 'GCP Secret Manager', value: 'gcp-secret-manager'},
+]}>
+<TabItem value="aws-secret-manager">
+
+1. Create a secret a secret to store the database password using the AWS CLI:
+
+    ```bash
+    aws secretsmanager create-secret \
+      --name db-pass-demo \
+      --secret-string "p455w0rd"
+    ```
+
+    The CLI prints out:
+
+    ```text
+    {
+        "ARN": "arn:aws:secretsmanager:us-east-1:1111111111:secret:db-pass-demo-aBiM5k",
+        "Name": "db-pass-demo",
+        "VersionId": "b702431d-174f-4a8f-aee5-b80e687b8bf1"
+    }
+    ```
+
+    Note the database secret name and the region (`us-east-1`), we will use them in the next part.
+
+2. Create a new file named `atlas.hcl` with the following contents:
+
+    ```hcl
+    data "runtimevar" "pass" {
+      url = "awssecretsmanager://db-pass-demo?region=us-east-1"
+    }
+
+    env "dev" {
+      url = "mysql://root:${data.runtimevar.pass}@host:3306/database"
+    }
+    ```
+
+    Let's breakdown the configuration:
+    * The `runtimevar` data source is used to retrieve the database password from AWS Secrets Manager.
+    * We define an `env` named `dev`.  The value retrieved by the `runtimevar` data source
+       is interpolated into the `url` attribute using the `${data.runtimevar.pass}` expression.
+
+3. Run `atlas schema inspect --env dev` to verify that Atlas is able to connect to the database.
+
+</TabItem>
+<TabItem value="gcp-secret-manager">
+
+1. Create a secret a secret to store the database password using the GCP CLI:
+
+    ```bash
+    printf "p455w0rd" | gcloud secrets create db-pass-demo --data-file=-
+    ```
+
+    The CLI prints out:
+
+    ```text
+    Created version [1] of the secret [db-pass-demo].
+    ```
+
+2. Create a new file named `atlas.hcl` with the following contents:
+
+    ```hcl
+    data "runtimevar" "pass" {
+      url = "gcpsecretmanager://projects/my-project/secrets/db-pass-demo"
+    }
+
+    env "dev" {
+      url = "mysql://root:${data.runtimevar.pass}@host:3306/database"
+    }
+    ```
+
+    Let's breakdown the configuration:
+    * The `runtimevar` data source is used to retrieve the database password from GCP Secret Manager. The URL
+      is composed of the project and secret name. If you are working locally in a multi-project environment, you
+      can find out the name of the active project by running `gcloud config get-value project`.
+    * We define an `env` named `dev`.  The value retrieved by the `runtimevar` data source
+       is interpolated into the `url` attribute using the `${data.runtimevar.pass}` expression.
+
+3. Run `atlas schema inspect --env dev` to verify that Atlas is able to connect to the database.
+
+</TabItem>
+</Tabs>
+
+## Using IAM Authentication
+
+Atlas can retrieve short-lived credentials from the cloud provider and use them to connect to the database. The
+passwords are retrieved using special data sources that are specific to each cloud provider.
+
+<Tabs
+defaultValue="aws-rds-iam"
+values={[
+{label: 'aws_rds_token', value: 'aws-rds-iam'},
+{label: 'gcp_cloudsql_token', value: 'gcp-cloud-sql-iam'},
+]}>
+<TabItem value="aws-rds-iam">
+
+1. Enable IAM Authentication for your database. For instructions on how to do this,
+   [see the AWS documentation](https://aws.github.io/aws-sdk-go-v2/docs/sdk-utilities/rds/#iam-authentication).
+
+2. Create a database user and grant it permission to authenticate using IAM, see
+   [the AWS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.DBAccounts.html)
+   for instructions.
+
+3. Create an IAM role with the "rds-db:connect" permission for the specific database and user. For instructions on how to do this,
+   [see the AWS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.IAMPolicy.html).
+
+4. Create a new file named `atlas.hcl` with the following contents:
+
+    ```hcl
+    locals {
+      user = "iamuser"
+      endpoint = "hostname-of-db.example9y7k.us-east-1.rds.amazonaws.com:5432"
+    }
+
+    data "aws_rds_token" "db" {
+        region = "us-east-1"
+        endpoint = local.endpoint
+        username = local.user
+    }
+
+    env "rds" {
+      url = "postgres://${local.user}:${urlescape(data.aws_rds_token.db)}@${local.endpoint}/postgres"
+    }
+    ```
+
+    Let's breakdown the configuration:
+    * The `aws_rds_token` data source is used to retrieve the database password from AWS Secrets Manager.
+    * We define an `env` named `rds`.  The value retrieved by the `aws_rds_token` data source
+       is interpolated into the `url` attribute using the `${data.aws_rds_token.db}` expression.
+
+</TabItem>
+<TabItem value="gcp-cloud-sql-iam">
+
+The `gcp_cloudsql_token` data source generates a short-lived token for an [GCP CloudSQL](https://cloud.google.com/sql) database
+using [IAM Authentication](https://cloud.google.com/sql/docs/mysql/authentication#manual).
+
+To use this data source:
+
+1. Enable IAM Authentication for your database. For instructions on how to do this,
+   [see the GCP documentation](https://cloud.google.com/sql/docs/mysql/create-edit-iam-instances).
+2. Create a database user and grant it permission to authenticate using IAM, see
+   [the GCP documentation](https://cloud.google.com/sql/docs/mysql/add-manage-iam-users)
+   for instructions.
+3. Create a file named `atlas.hcl` with the following contents:
+
+    ```hcl title="atlas.hcl"
+    locals {
+      user = "iamuser"
+      endpoint = "34.143.100.1:3306"
+    }
+
+    data "gcp_cloudsql_token" "db" {}
+
+    env "cloudsql" {
+      url = "mysql://${local.user}:${urlescape(data.gcp_cloudsql_token.db)}@${local.endpoint}/?allowCleartextPasswords=1&tls=skip-verify&parseTime=true"
+    }
+    ```
+
+    :::note
+    The `allowCleartextPasswords` and `tls` parameters are required for the MySQL driver to connect to CloudSQL. For PostgreSQL, use `sslmode=require` to connect to the database.
+    :::
+
+    Let's breakdown the configuration:
+    * The `gcp_cloudsql_token` data source is used to retrieve the database password from GCP CloudSQL.
+    * We define an `env` named `cloudsql`.  The value retrieved by the `gcp_cloudsql_token` data source
+       is interpolated into the `url` attribute using the `${data.gcp_cloudsql_token.db}` expression.
+
+</TabItem>
+</Tabs>
\ No newline at end of file
diff --git a/doc/md/versioned/intro.mdx b/doc/md/versioned/intro.mdx
index 497add18..dab0a4a5 100644
--- a/doc/md/versioned/intro.mdx
+++ b/doc/md/versioned/intro.mdx
@@ -6,6 +6,7 @@ title: Introduction to Atlas Migrations
 
 import Tabs from '@theme/Tabs';
 import TabItem from '@theme/TabItem';
+import MigratePushCommand from '../components/migrate-push-command.mdx';
 import Discord from '../../website/src/assets/icons/discord-white.svg'
 
 This guide offers a high-level overview of the Atlas versioned migration workflow. It walks you through the steps of
@@ -149,7 +150,7 @@ most methods covered in the [guides](/guides) section. In this example, we'll de
 </div>
 
 Firstly, you'll need to [log in to Atlas](https://auth.atlasgo.cloud/signup). If it's your first time, you'll be
-prompted to create both an account and a workspace.
+prompted to create both an account and a workspace (organization):
 
 <Tabs>
 <TabItem value="web" label="Via Web">
@@ -166,28 +167,18 @@ atlas login --token "ATLAS_TOKEN"
 ```
 
 </TabItem>
-</Tabs>
-
-After logging in, let's name our new migration project `app` and run `atlas migrate push`:
-
-<Tabs>
-<TabItem value="postgres" label="PostgreSQL">
+<TabItem value="env-var" label="Via Environment Variable">
 
 ```bash
-atlas migrate push app \
-  --dev-url "docker://postgres/15/dev?search_path=public"
+ATLAS_TOKEN="ATLAS_TOKEN" atlas login
 ```
 
 </TabItem>
-<TabItem value="mysql" label="MySQL">
+</Tabs>
 
-```bash
-atlas migrate push app \
-  --dev-url "docker://mysql/8/dev"
-```
+After logging in, let's name our new migration project `app` and run `atlas migrate push`:
 
-</TabItem>
-</Tabs>
+<MigratePushCommand />
 
 After our migration directory is pushed, Atlas prints a URL to the created directory, similar to the one shown in the
 image above.
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index c596564e..6b981d13 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -91,7 +91,7 @@ module.exports = {
             collapsed: false,
             items: [
                 {type: 'doc', id: 'cloud/getting-started', label: 'Getting Started'},
-                {type: 'doc', id: 'cloud/directories', label: 'Connected Directories'},
+                {type: 'doc', id: 'cloud/directories', label: 'Connect Directories'},
                 {type: 'doc', id: 'cloud/setup-ci', label: 'CI Setup'},
                 {type: 'doc', id: 'cloud/bots', label: 'Creating Bots'},
                 {type: 'doc', id: 'cloud/deployment', label: 'Deployments'},
@@ -105,7 +105,6 @@ module.exports = {
                 {
                     type: 'doc',
                     label: 'Kubernetes Operator',
-                    type: 'doc',
                     id: 'integrations/kubernetes/operator'
                 },
                 {type: 'doc', id: 'integrations/github-actions', label: 'GitHub Actions'},
@@ -204,6 +203,11 @@ module.exports = {
                     type: 'doc',
                     id: 'guides/deploying/cloud-sql-via-github-actions',
                     label: 'GCP CloudSQL (GH Actions)'
+                },
+                {
+                    type: 'doc',
+                    id: 'guides/deploying/secrets',
+                    label: 'Working with secrets'
                 }
             ]
         },
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index 7aeda35f..a8faa1f9 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -75,8 +75,28 @@ const (
 	typeColumn       = "column"
 	typeSchema       = "schema"
 	typeMaterialized = "materialized"
+	typeFunction     = "function"
+	typeProcedure    = "procedure"
 )
 
+// typeName returns the type name of the given object.
+func typeName(o schema.Object) string {
+	switch o := o.(type) {
+	case *schema.Table:
+		return typeTable
+	case *schema.View:
+		if o.Materialized() {
+			return typeMaterialized
+		}
+		return typeView
+	case *schema.Func:
+		return typeFunction
+	case *schema.Proc:
+		return typeProcedure
+	}
+	return "object"
+}
+
 // Scan populates the Realm from the schemas and table specs.
 func Scan(r *schema.Realm, doc *ScanDoc, funcs *ScanFuncs) error {
 	byName := make(map[string]*schema.Schema)
@@ -88,157 +108,149 @@ func Scan(r *schema.Realm, doc *ScanDoc, funcs *ScanFuncs) error {
 		r.AddSchemas(s1)
 		byName[s.Name] = s1
 	}
-	tableFKs := make(map[*schema.Table][]*sqlspec.ForeignKey)
+	var (
+		fks  = make(map[*schema.Table][]*sqlspec.ForeignKey)
+		deps = make(map[schema.Object][]*schemahcl.Ref, len(doc.Views))
+	)
 	for _, st := range doc.Tables {
 		name, err := SchemaName(st.Schema)
 		if err != nil {
-			return fmt.Errorf("specutil: cannot extract schema name for table %q: %w", st.Name, err)
+			return fmt.Errorf("cannot extract schema name for table %q: %w", st.Name, err)
 		}
 		s, ok := byName[name]
 		if !ok {
-			return fmt.Errorf("specutil: schema %q not found for table %q", name, st.Name)
+			return fmt.Errorf("schema %q not found for table %q", name, st.Name)
 		}
 		t, err := funcs.Table(st, s)
 		if err != nil {
-			return fmt.Errorf("specutil: cannot convert table %q: %w", st.Name, err)
+			return fmt.Errorf("cannot convert table %q: %w", st.Name, err)
 		}
-		tableFKs[t] = st.ForeignKeys
+		fks[t] = st.ForeignKeys
 		s.AddTables(t)
+		if d, ok := st.Attr("depends_on"); ok {
+			refs, err := d.Refs()
+			if err != nil {
+				return fmt.Errorf("expect list of references for attribute table.%s.depends_on: %w", st.Name, err)
+			}
+			deps[t] = refs
+		}
 	}
 	// Link the foreign keys.
-	for t, fks := range tableFKs {
+	for t, fks := range fks {
 		if err := linkForeignKeys(t, fks); err != nil {
 			return err
 		}
 	}
-	viewDeps := make(map[*schema.View][]*schemahcl.Ref, len(doc.Views))
 	for _, sv := range doc.Views {
 		name, err := SchemaName(sv.Schema)
 		if err != nil {
-			return fmt.Errorf("specutil: cannot extract schema name for view %q: %w", sv.Name, err)
+			return fmt.Errorf("cannot extract schema name for view %q: %w", sv.Name, err)
 		}
 		s, ok := byName[name]
 		if !ok {
-			return fmt.Errorf("specutil: schema %q not found for view %q", name, sv.Name)
+			return fmt.Errorf("schema %q not found for view %q", name, sv.Name)
 		}
 		v, err := funcs.View(sv, s)
 		if err != nil {
-			return fmt.Errorf("specutil: cannot convert view %q: %w", sv.Name, err)
+			return fmt.Errorf("cannot convert view %q: %w", sv.Name, err)
 		}
 		s.AddViews(v)
-		if deps, ok := sv.Attr("depends_on"); ok {
-			refs, err := deps.Refs()
+		if d, ok := sv.Attr("depends_on"); ok {
+			refs, err := d.Refs()
 			if err != nil {
-				return fmt.Errorf("specutil: expect list of references for attribute view.%s.depends_on: %w", sv.Name, err)
+				return fmt.Errorf("expect list of references for attribute view.%s.depends_on: %w", sv.Name, err)
 			}
-			viewDeps[v] = refs
+			deps[v] = refs
 		}
 	}
 	for _, m := range doc.Materialized {
 		name, err := SchemaName(m.Schema)
 		if err != nil {
-			return fmt.Errorf("specutil: cannot extract schema name for materialized %q: %w", m.Name, err)
+			return fmt.Errorf("cannot extract schema name for materialized %q: %w", m.Name, err)
 		}
 		s, ok := byName[name]
 		if !ok {
-			return fmt.Errorf("specutil: schema %q not found for materialized %q", name, m.Name)
+			return fmt.Errorf("schema %q not found for materialized %q", name, m.Name)
 		}
 		v, err := funcs.View(m, s)
 		if err != nil {
-			return fmt.Errorf("specutil: cannot convert materialized %q: %w", m.Name, err)
+			return fmt.Errorf("cannot convert materialized %q: %w", m.Name, err)
 		}
 		s.AddViews(v.SetMaterialized(true))
-		if deps, ok := m.Attr("depends_on"); ok {
-			refs, err := deps.Refs()
+		if d, ok := m.Attr("depends_on"); ok {
+			refs, err := d.Refs()
 			if err != nil {
-				return fmt.Errorf("specutil: expect list of references for attribute materialized.%s.depends_on: %w", m.Name, err)
-			}
-			viewDeps[v] = refs
-		}
-	}
-	// Link views' dependencies.
-	for v, refs := range viewDeps {
-		srcT := typeView
-		if v.Materialized() {
-			srcT = typeMaterialized
-		}
-		for i, r := range refs {
-			switch p, err := r.Path(); {
-			case err != nil:
-				return fmt.Errorf("specutil: extract reference for %s.%s: %w", srcT, v.Name, err)
-			case len(p) == 0:
-				return fmt.Errorf("specutil: empty reference for %s.%s", srcT, v.Name)
-			case p[0].T == typeView:
-				q, n, err := RefName(r, typeView)
-				if err != nil {
-					return fmt.Errorf("specutil: extract view name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
-				}
-				v1, err := findT(v.Schema, q, n, func(s *schema.Schema, name string) (*schema.View, bool) {
-					return s.View(name)
-				})
-				if err != nil {
-					return fmt.Errorf("specutil: find view refrence for %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
-				}
-				v.AddDeps(v1)
-			case p[0].T == typeMaterialized:
-				q, n, err := RefName(r, typeMaterialized)
-				if err != nil {
-					return fmt.Errorf("specutil: extract materialized name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
-				}
-				v1, err := findT(v.Schema, q, n, func(s *schema.Schema, name string) (*schema.View, bool) {
-					return s.Materialized(name)
-				})
-				if err != nil {
-					return fmt.Errorf("specutil: find materialized refrence for %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
-				}
-				v.AddDeps(v1)
-			case p[0].T == typeTable:
-				q, n, err := RefName(r, typeTable)
-				if err != nil {
-					return fmt.Errorf("specutil: extract table name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
-				}
-				t1, err := findT(v.Schema, q, n, func(s *schema.Schema, name string) (*schema.Table, bool) {
-					return s.Table(name)
-				})
-				if err != nil {
-					return fmt.Errorf("specutil: find table refrence for %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
-				}
-				v.AddDeps(t1)
+				return fmt.Errorf("expect list of references for attribute materialized.%s.depends_on: %w", m.Name, err)
 			}
+			deps[v] = refs
 		}
 	}
 	if funcs.Func != nil {
 		for _, sf := range doc.Funcs {
 			name, err := SchemaName(sf.Schema)
 			if err != nil {
-				return fmt.Errorf("specutil: cannot extract schema name for function %q: %w", sf.Name, err)
+				return fmt.Errorf("cannot extract schema name for function %q: %w", sf.Name, err)
 			}
 			s, ok := byName[name]
 			if !ok {
-				return fmt.Errorf("specutil: schema %q not found for function %q", name, sf.Name)
+				return fmt.Errorf("schema %q not found for function %q", name, sf.Name)
 			}
 			f, err := funcs.Func(sf)
 			if err != nil {
-				return fmt.Errorf("specutil: cannot convert function %q: %w", sf.Name, err)
+				return fmt.Errorf("cannot convert function %q: %w", sf.Name, err)
 			}
 			s.AddFuncs(f)
+			if d, ok := sf.Attr("depends_on"); ok {
+				refs, err := d.Refs()
+				if err != nil {
+					return fmt.Errorf("expect list of references for attribute function.%s.depends_on: %w", f.Name, err)
+				}
+				deps[f] = refs
+			}
 		}
 	}
 	if funcs.Proc != nil {
 		for _, sf := range doc.Procs {
 			name, err := SchemaName(sf.Schema)
 			if err != nil {
-				return fmt.Errorf("specutil: cannot extract schema name for procedure %q: %w", sf.Name, err)
+				return fmt.Errorf("cannot extract schema name for procedure %q: %w", sf.Name, err)
 			}
 			s, ok := byName[name]
 			if !ok {
-				return fmt.Errorf("specutil: schema %q not found for procedure %q", name, sf.Name)
+				return fmt.Errorf("schema %q not found for procedure %q", name, sf.Name)
 			}
 			f, err := funcs.Proc(sf)
 			if err != nil {
-				return fmt.Errorf("specutil: cannot convert procedure %q: %w", sf.Name, err)
+				return fmt.Errorf("cannot convert procedure %q: %w", sf.Name, err)
 			}
 			s.AddProcs(f)
+			if d, ok := sf.Attr("depends_on"); ok {
+				refs, err := d.Refs()
+				if err != nil {
+					return fmt.Errorf("expect list of references for attribute procedure.%s.depends_on: %w", f.Name, err)
+				}
+				deps[f] = refs
+			}
+		}
+	}
+	for o, refs := range deps {
+		var err error
+		switch o := o.(type) {
+		case *schema.Table:
+			err = fromDependsOn(fmt.Sprintf("table.%s", o.Name), o, o.Schema, refs)
+		case *schema.View:
+			t := "view"
+			if o.Materialized() {
+				t = "materialized"
+			}
+			err = fromDependsOn(fmt.Sprintf("%s.%s", t, o.Name), o, o.Schema, refs)
+		case *schema.Func:
+			err = fromDependsOn(fmt.Sprintf("function.%s", o.Name), o, o.Schema, refs)
+		case *schema.Proc:
+			err = fromDependsOn(fmt.Sprintf("procedure.%s", o.Name), o, o.Schema, refs)
+		}
+		if err != nil {
+			return err
 		}
 	}
 	return nil
@@ -291,11 +303,11 @@ func Table(spec *sqlspec.Table, parent *schema.Schema, convertColumn ConvertTabl
 func View(spec *sqlspec.View, parent *schema.Schema, convertC ConvertViewColumnFunc, convertI ConvertViewIndexFunc) (*schema.View, error) {
 	as, ok := spec.Extra.Attr("as")
 	if !ok {
-		return nil, fmt.Errorf("specutil: missing 'as' definition for view %q", spec.Name)
+		return nil, fmt.Errorf("missing 'as' definition for view %q", spec.Name)
 	}
 	def, err := as.String()
 	if err != nil {
-		return nil, fmt.Errorf("specutil: expect string definition for attribute view.%s.as: %w", spec.Name, err)
+		return nil, fmt.Errorf("expect string definition for attribute view.%s.as: %w", spec.Name, err)
 	}
 	v := schema.NewView(spec.Name, def).SetSchema(parent)
 	for _, c := range spec.Columns {
@@ -318,7 +330,7 @@ func View(spec *sqlspec.View, parent *schema.Schema, convertC ConvertViewColumnF
 	if c, ok := spec.Extra.Attr("check_option"); ok {
 		o, err := c.String()
 		if err != nil {
-			return nil, fmt.Errorf("specutil: expect string definition for attribute view.%s.check_option: %w", spec.Name, err)
+			return nil, fmt.Errorf("expect string definition for attribute view.%s.check_option: %w", spec.Name, err)
 		}
 		v.SetCheckOption(o)
 	}
@@ -601,6 +613,10 @@ func FromTable(t *schema.Table, colFn TableColumnSpecFunc, pkFn PrimaryKeySpecFu
 			spec.Checks = append(spec.Checks, ckFn(c))
 		}
 	}
+	if deps, ok := dependsOn(t.Schema.Realm, t.Deps); ok {
+		// Embedding a resource push its attributes to the end.
+		spec.Extra.Children = append(spec.Extra.Children, &schemahcl.Resource{Attrs: []*schemahcl.Attr{deps}})
+	}
 	convertCommentFromSchema(t.Attrs, &spec.Extra.Attrs)
 	return spec, nil
 }
@@ -644,51 +660,106 @@ func FromView(v *schema.View, colFn ViewColumnSpecFunc, idxFn IndexSpecFunc) (*s
 			embed.Attrs = append(embed.Attrs, schemahcl.StringAttr("check_option", c.V))
 		}
 	}
+	if deps, ok := dependsOn(v.Schema.Realm, v.Deps); ok {
+		embed.Attrs = append(embed.Attrs, deps)
+	}
+	convertCommentFromSchema(v.Attrs, &embed.Attrs)
+	spec.Extra.Children = append(spec.Extra.Children, embed)
+	return spec, nil
+}
+
+// dependsOn returns the depends_on attribute for the given objects.
+func dependsOn(realm *schema.Realm, objects []schema.Object) (*schemahcl.Attr, bool) {
 	var (
-		deps         = make([]*schemahcl.Ref, 0, len(v.Deps))
-		nameT, nameV = make(map[string]int), make(map[string]int)
+		deps  = make([]*schemahcl.Ref, 0, len(objects))
+		names = make(map[string]int)
+		name  = func(t schema.Object, n string) string { return typeName(t) + "/" + n }
 	)
-	// Qualify table/view names if there are
-	// multiple tables/views with the same name.
-	if v.Schema.Realm != nil {
-		for _, s := range v.Schema.Realm.Schemas {
+	// Qualify references if there are objects with the same name.
+	if realm != nil {
+		for _, s := range realm.Schemas {
 			for _, t := range s.Tables {
-				nameT[t.Name]++
+				names[name(t, t.Name)]++
 			}
 			for _, v := range s.Views {
-				nameV[v.Name]++
+				names[name(v, v.Name)]++
+			}
+			for _, f := range s.Funcs {
+				names[name(f, f.Name)]++
+			}
+			for _, p := range s.Procs {
+				names[name(p, p.Name)]++
 			}
 		}
 	}
-	for _, d := range v.Deps {
+	for _, o := range objects {
 		path := make([]string, 0, 2)
-		switch d := d.(type) {
+		var n, s string
+		switch d := o.(type) {
 		case *schema.Table:
-			if nameT[d.Name] > 1 {
-				path = append(path, d.Schema.Name)
-			}
-			deps = append(deps, schemahcl.BuildRef([]schemahcl.PathIndex{
-				{T: typeTable, V: append(path, d.Name)},
-			}))
+			n, s = d.Name, d.Schema.Name
 		case *schema.View:
-			vt := typeView
-			if d.Materialized() {
-				vt = typeMaterialized
-			}
-			if nameV[d.Name] > 1 {
-				path = append(path, d.Schema.Name)
-			}
-			deps = append(deps, schemahcl.BuildRef([]schemahcl.PathIndex{
-				{T: vt, V: append(path, d.Name)},
-			}))
+			n, s = d.Name, d.Schema.Name
+		case *schema.Func:
+			n, s = d.Name, d.Schema.Name
+		case *schema.Proc:
+			n, s = d.Name, d.Schema.Name
 		}
+		if names[name(o, n)] > 1 {
+			path = append(path, s)
+		}
+		deps = append(deps, schemahcl.BuildRef([]schemahcl.PathIndex{
+			{T: typeName(o), V: append(path, n)},
+		}))
 	}
 	if len(deps) > 0 {
-		embed.Attrs = append(embed.Attrs, schemahcl.RefsAttr("depends_on", deps...))
+		return schemahcl.RefsAttr("depends_on", deps...), true
 	}
-	convertCommentFromSchema(v.Attrs, &embed.Attrs)
-	spec.Extra.Children = append(spec.Extra.Children, embed)
-	return spec, nil
+	return nil, false
+}
+
+func fromDependsOn[T interface{ AddDeps(...schema.Object) T }](loc string, t T, ns *schema.Schema, refs []*schemahcl.Ref) error {
+	for i, r := range refs {
+		p, err := r.Path()
+		if err != nil {
+			return fmt.Errorf("extract %s.depends_on references: %w", loc, err)
+		}
+		if len(p) == 0 {
+			return fmt.Errorf("empty reference exists in %s.depends_on[%d]", loc, i)
+		}
+		q, n, err := RefName(r, p[0].T)
+		if err != nil {
+			return fmt.Errorf("extract %s name from %s.depends_on[%d]: %w", p[0].T, loc, i, err)
+		}
+		var o schema.Object
+		switch p[0].T {
+		case typeTable:
+			o, err = findT(ns, q, n, func(s *schema.Schema, name string) (*schema.Table, bool) {
+				return s.Table(name)
+			})
+		case typeView:
+			o, err = findT(ns, q, n, func(s *schema.Schema, name string) (*schema.View, bool) {
+				return s.View(name)
+			})
+		case typeMaterialized:
+			o, err = findT(ns, q, n, func(s *schema.Schema, name string) (*schema.View, bool) {
+				return s.Materialized(name)
+			})
+		case typeFunction:
+			o, err = findT(ns, q, n, func(s *schema.Schema, name string) (*schema.Func, bool) {
+				return s.Func(name)
+			})
+		case typeProcedure:
+			o, err = findT(ns, q, n, func(s *schema.Schema, name string) (*schema.Proc, bool) {
+				return s.Proc(name)
+			})
+		}
+		if err != nil {
+			return fmt.Errorf("find %s refrence for %s.depends_on[%d]: %w", loc, p[0].T, i, err)
+		}
+		t.AddDeps(o)
+	}
+	return nil
 }
 
 // FromPrimaryKey converts schema.Index to a sqlspec.PrimaryKey.
@@ -899,7 +970,7 @@ func SchemaName(ref *schemahcl.Ref) (string, error) {
 		return "", err
 	}
 	if len(vs) != 1 {
-		return "", fmt.Errorf("specutil: expected 1 schema ref, got %d", len(vs))
+		return "", fmt.Errorf("expected 1 schema ref, got %d", len(vs))
 	}
 	return vs[0], nil
 }
@@ -911,11 +982,11 @@ func ColumnByRef(t *schema.Table, ref *schemahcl.Ref) (*schema.Column, error) {
 		return nil, err
 	}
 	if len(vs) != 1 {
-		return nil, fmt.Errorf("specutil: expected 1 column ref, got %d", len(vs))
+		return nil, fmt.Errorf("expected 1 column ref, got %d", len(vs))
 	}
 	c, ok := t.Column(vs[0])
 	if !ok {
-		return nil, fmt.Errorf("specutil: unknown column %q in table %q", vs[0], t.Name)
+		return nil, fmt.Errorf("unknown column %q in table %q", vs[0], t.Name)
 	}
 	return c, nil
 }
@@ -941,9 +1012,9 @@ func externalRef(ref *schemahcl.Ref, sch *schema.Schema) (*schema.Table, *schema
 // findT finds the table/view referenced by ref in the provided schema. If the table/view
 // is not in the provided schema.Schema other schemas in the connected schema.Realm are
 // searched as well.
-func findT[T schema.View | schema.Table](sch *schema.Schema, qualifier, name string, findT func(*schema.Schema, string) (*T, bool)) (*T, error) {
+func findT[T schema.Object](sch *schema.Schema, qualifier, name string, findT func(*schema.Schema, string) (T, bool)) (t T, err error) {
 	var (
-		matches []*T             // Found references.
+		matches []T              // Found references.
 		schemas []*schema.Schema // Schemas to search.
 	)
 	switch {
@@ -967,10 +1038,11 @@ func findT[T schema.View | schema.Table](sch *schema.Schema, qualifier, name str
 	case 1:
 		return matches[0], nil
 	case 0:
-		return nil, fmt.Errorf("specutil: refrenced table/view %q not found", name)
+		err = fmt.Errorf("refrenced %s %q not found", typeName(t), name)
 	default:
-		return nil, fmt.Errorf("specutil: multiple refrences tables/views found for %q", name)
+		err = fmt.Errorf("multiple refrences %ss found for %q", typeName(t), name)
 	}
+	return
 }
 
 func tableName(ref *schemahcl.Ref) (string, string, error) {
diff --git a/sql/postgres/driver_oss.go b/sql/postgres/driver_oss.go
index b14917b6..4b0f439b 100644
--- a/sql/postgres/driver_oss.go
+++ b/sql/postgres/driver_oss.go
@@ -42,6 +42,10 @@ func (i *inspect) inspectTypes(context.Context, *schema.Realm, *schema.InspectOp
 	return nil // unimplemented.
 }
 
+func (i *inspect) inspectDeps(context.Context, *schema.Realm, *schema.InspectOptions) error {
+	return nil // unimplemented.
+}
+
 func (*state) addView(*schema.AddView) error {
 	return nil // unimplemented.
 }
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index f147ea35..e685f783 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -62,6 +62,9 @@ func (i *inspect) InspectRealm(ctx context.Context, opts *schema.InspectRealmOpt
 				return nil, err
 			}
 		}
+		if err := i.inspectDeps(ctx, r, nil); err != nil {
+			return nil, err
+		}
 	}
 	return sqlx.ExcludeRealm(r, opts.Exclude)
 }
@@ -159,8 +162,11 @@ func (i *inspect) tables(ctx context.Context, realm *schema.Realm, opts *schema.
 	}
 	defer rows.Close()
 	for rows.Next() {
-		var tSchema, name, comment, partattrs, partstart, partexprs sql.NullString
-		if err := rows.Scan(&tSchema, &name, &comment, &partattrs, &partstart, &partexprs); err != nil {
+		var (
+			oid                                                     sql.NullInt64
+			tSchema, name, comment, partattrs, partstart, partexprs sql.NullString
+		)
+		if err := rows.Scan(&oid, &tSchema, &name, &comment, &partattrs, &partstart, &partexprs); err != nil {
 			return fmt.Errorf("scan table information: %w", err)
 		}
 		if !sqlx.ValidString(tSchema) || !sqlx.ValidString(name) {
@@ -172,6 +178,9 @@ func (i *inspect) tables(ctx context.Context, realm *schema.Realm, opts *schema.
 		}
 		t := schema.NewTable(name.String)
 		s.AddTables(t)
+		if oid.Valid {
+			t.AddAttrs(&OID{V: oid.Int64})
+		}
 		if sqlx.ValidString(comment) {
 			t.SetComment(comment.String)
 		}
@@ -776,6 +785,12 @@ type (
 		T string // e.g., void, any, cstring, etc.
 	}
 
+	// OID is the object identifier as defined in the Postgres catalog.
+	OID struct {
+		schema.Attr
+		V int64
+	}
+
 	// enumType represents an enum type. It serves aa intermediate representation of a Postgres enum type,
 	// to temporary save TypeID and TypeName of an enum column until the enum values can be extracted.
 	enumType struct {
@@ -811,6 +826,8 @@ type (
 		Null    bool            // Nullability.
 		Default schema.Expr     // Default value.
 		Checks  []*schema.Check // Check constraints.
+		Attrs   []schema.Attr   // Extra attributes, such as OID.
+		Deps    []schema.Object // Objects this domain depends on.
 	}
 
 	// IntervalType defines an interval type.
@@ -1275,6 +1292,7 @@ ORDER BY
 	// Query to list table information.
 	tablesQuery = `
 SELECT
+	t3.oid,
 	t1.table_schema,
 	t1.table_name,
 	pg_catalog.obj_description(t3.oid, 'pg_class') AS comment,
@@ -1297,6 +1315,7 @@ ORDER BY
 `
 	tablesQueryArgs = `
 SELECT
+	t3.oid,
 	t1.table_schema,
 	t1.table_name,
 	pg_catalog.obj_description(t3.oid, 'pg_class') AS comment,
diff --git a/sql/schema/dsl.go b/sql/schema/dsl.go
index 096119da..36994922 100644
--- a/sql/schema/dsl.go
+++ b/sql/schema/dsl.go
@@ -244,6 +244,12 @@ func (t *Table) AddAttrs(attrs ...Attr) *Table {
 	return t
 }
 
+// AddDeps adds the given objects as dependencies to the view.
+func (t *Table) AddDeps(objs ...Object) *Table {
+	t.Deps = append(t.Deps, objs...)
+	return t
+}
+
 // NewView creates a new View.
 func NewView(name, def string) *View {
 	return &View{Name: name, Def: def}
@@ -834,6 +840,18 @@ func (f *ForeignKey) SetOnDelete(o ReferenceOption) *ForeignKey {
 	return f
 }
 
+// AddDeps adds the given objects as dependencies to the function.
+func (f *Func) AddDeps(objs ...Object) *Func {
+	f.Deps = append(f.Deps, objs...)
+	return f
+}
+
+// AddDeps adds the given objects as dependencies to the procedure.
+func (p *Proc) AddDeps(objs ...Object) *Proc {
+	p.Deps = append(p.Deps, objs...)
+	return p
+}
+
 // ReplaceOrAppend searches an attribute of the same type as v in
 // the list and replaces it. Otherwise, v is appended to the list.
 func ReplaceOrAppend(attrs *[]Attr, v Attr) {
diff --git a/sql/schema/schema.go b/sql/schema/schema.go
index ad96a70d..0115da06 100644
--- a/sql/schema/schema.go
+++ b/sql/schema/schema.go
@@ -39,7 +39,8 @@ type (
 		Indexes     []*Index
 		PrimaryKey  *Index
 		ForeignKeys []*ForeignKey
-		Attrs       []Attr // Attrs, constraints and options.
+		Attrs       []Attr   // Attrs, constraints and options.
+		Deps        []Object // Objects this table depends on.
 	}
 
 	// A View represents a view definition.
@@ -49,8 +50,8 @@ type (
 		Schema  *Schema
 		Columns []*Column
 		Attrs   []Attr   // Attrs and options.
-		Deps    []Object // Tables and views used in view definition.
 		Indexes []*Index // Indexes on materialized view.
+		Deps    []Object // Objects this view depends on.
 	}
 
 	// A Column represents a column definition.
@@ -114,9 +115,10 @@ type (
 		Schema *Schema
 		Args   []*FuncArg
 		Ret    Type
-		Body   string // Function body only.
-		Lang   string // Language (e.g. SQL, PL/pgSQL, etc.).
-		Attrs  []Attr // Extra driver specific attributes.
+		Body   string   // Function body only.
+		Lang   string   // Language (e.g. SQL, PL/pgSQL, etc.).
+		Attrs  []Attr   // Extra driver specific attributes.
+		Deps   []Object // Objects this function depends on.
 	}
 
 	// Proc represents a procedure definition.
@@ -124,9 +126,10 @@ type (
 		Name   string
 		Schema *Schema
 		Args   []*FuncArg
-		Body   string // Function body only.
-		Lang   string // Language (e.g. SQL, PL/pgSQL, etc.).
-		Attrs  []Attr // Extra driver specific attributes.
+		Body   string   // Function body only.
+		Lang   string   // Language (e.g. SQL, PL/pgSQL, etc.).
+		Attrs  []Attr   // Extra driver specific attributes.
+		Deps   []Object // Objects this function depends on.
 	}
 
 	// A FuncArg represents a single function argument.
@@ -531,6 +534,8 @@ const (
 // objects.
 func (*Table) obj()    {}
 func (*View) obj()     {}
+func (*Func) obj()     {}
+func (*Proc) obj()     {}
 func (*EnumType) obj() {}
 
 // expressions.
diff --git a/sql/sqlcheck/datadepend/datadepend.go b/sql/sqlcheck/datadepend/datadepend.go
index 1dab1518..39e7fb90 100644
--- a/sql/sqlcheck/datadepend/datadepend.go
+++ b/sql/sqlcheck/datadepend/datadepend.go
@@ -8,6 +8,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"strings"
 
 	"ariga.io/atlas/schemahcl"
 	"ariga.io/atlas/sql/internal/sqlx"
@@ -87,22 +88,27 @@ func (a *Analyzer) Diagnostics(_ context.Context, p *sqlcheck.Pass) (diags []sql
 			for _, c := range m.Changes {
 				switch c := c.(type) {
 				case *schema.AddIndex:
-					column := func() *schema.Column {
+					names := func() []string {
+						var names []string
 						for i := range c.I.Parts {
 							// We consider a column a non-new column if
 							// it was not added in this migration file.
 							if column := c.I.Parts[i].C; column != nil && p.File.ColumnSpan(m.T, column)&sqlcheck.SpanAdded == 0 {
-								return column
+								names = append(names, fmt.Sprintf("%q", column.Name))
 							}
 						}
-						return nil
+						return names
 					}()
-					// A unique index was added on an existing column.
-					if c.I.Unique && column != nil {
+					// A unique index was added on an existing columns.
+					if c.I.Unique && len(names) > 0 {
+						s := fmt.Sprintf("columns %s contain", strings.Join(names, ", "))
+						if len(names) == 1 {
+							s = fmt.Sprintf("column %s contains", names[0])
+						}
 						diags = append(diags, sqlcheck.Diagnostic{
 							Code: codeAddUniqueI,
 							Pos:  sc.Stmt.Pos,
-							Text: fmt.Sprintf("Adding a unique index %q on table %q might fail in case column %q contains duplicate entries", c.I.Name, m.T.Name, column.Name),
+							Text: fmt.Sprintf("Adding a unique index %q on table %q might fail in case %s duplicate entries", c.I.Name, m.T.Name, s),
 						})
 					}
 				case *schema.ModifyIndex:


Test patch:
diff --git a/internal/integration/postgres_test.go b/internal/integration/postgres_test.go
index 9f1ba4c5..0c329765 100644
--- a/internal/integration/postgres_test.go
+++ b/internal/integration/postgres_test.go
@@ -1134,8 +1134,11 @@ create table atlas_types_sanity
 		require.Len(t, realm.Schemas, 1)
 		ts, ok := realm.Schemas[0].Table(n)
 		require.True(t, ok)
+		require.Len(t, ts.Attrs, 1)
+		require.IsType(t, &postgres.OID{}, ts.Attrs[0])
 		expected := schema.Table{
 			Name:   n,
+			Attrs:  ts.Attrs,
 			Schema: realm.Schemas[0],
 			Columns: []*schema.Column{
 				{
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index 00d039ff..e0ad3672 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -370,11 +370,11 @@ func TestDriver_InspectPartitionedTable(t *testing.T) {
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(tablesQuery, "$1"))).
 		WithArgs("public").
 		WillReturnRows(sqltest.Rows(`
- table_schema | table_name  | comment | partition_attrs | partition_strategy |                  partition_exprs                   
---------------+-------------+---------+-----------------+--------------------+----------------------------------------------------
- public       | logs1       |         |                 |                    | 
- public       | logs2       |         | 1               | r                  | 
- public       | logs3       |         | 2 0 0           | l                  | (a + b), (a + (b * 2))
+ oid   | table_schema | table_name  | comment | partition_attrs | partition_strategy |                  partition_exprs
+-------+--------------+-------------+---------+-----------------+--------------------+----------------------------------------------------
+ 112  | public       | logs1       |         |                 |                     | 
+ 113  | public       | logs2       |         | 1               | r                   | 
+ 114  | public       | logs3       |         | 2 0 0           | l                   | (a + b), (a + (b * 2))
 
 `))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(columnsQuery, "$2, $3, $4"))).
@@ -402,12 +402,12 @@ logs3      | c5         | integer   | integer   | NO          |                |
 
 	t1, ok := s.Table("logs1")
 	require.True(t, ok)
-	require.Empty(t, t1.Attrs)
+	require.Equal(t, []schema.Attr{&OID{V: 112}}, t1.Attrs)
 
 	t2, ok := s.Table("logs2")
 	require.True(t, ok)
-	require.Len(t, t2.Attrs, 1)
-	key := t2.Attrs[0].(*Partition)
+	require.Len(t, t2.Attrs, 2)
+	key := t2.Attrs[1].(*Partition)
 	require.Equal(t, PartitionTypeRange, key.T)
 	require.Equal(t, []*PartitionPart{
 		{C: &schema.Column{Name: "c2", Type: &schema.ColumnType{Raw: "integer", Type: &schema.IntegerType{T: "integer"}}}},
@@ -415,8 +415,8 @@ logs3      | c5         | integer   | integer   | NO          |                |
 
 	t3, ok := s.Table("logs3")
 	require.True(t, ok)
-	require.Len(t, t3.Attrs, 1)
-	key = t3.Attrs[0].(*Partition)
+	require.Len(t, t3.Attrs, 2)
+	key = t3.Attrs[1].(*Partition)
 	require.Equal(t, PartitionTypeList, key.T)
 	require.Equal(t, []*PartitionPart{
 		{C: &schema.Column{Name: "c5", Type: &schema.ColumnType{Raw: "integer", Type: &schema.IntegerType{T: "integer"}}}},
@@ -694,9 +694,9 @@ func (m mock) version(version string) {
 }
 
 func (m mock) tableExists(schema, table string, exists bool) {
-	rows := sqlmock.NewRows([]string{"table_schema", "table_name", "table_comment", "partition_attrs", "partition_strategy", "partition_exprs"})
+	rows := sqlmock.NewRows([]string{"oid", "table_schema", "table_name", "table_comment", "partition_attrs", "partition_strategy", "partition_exprs"})
 	if exists {
-		rows.AddRow(schema, table, nil, nil, nil, nil)
+		rows.AddRow(nil, schema, table, nil, nil, nil, nil)
 	}
 	m.ExpectQuery(queryTables).
 		WithArgs(schema).
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index fa788677..b5364d80 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -739,7 +739,7 @@ table "logs" {
 				}
 			}
 		`), &schema.Schema{}, nil)
-		require.EqualError(t, err, `specutil: cannot convert table "logs": missing columns or expressions for logs.partition`)
+		require.EqualError(t, err, `cannot convert table "logs": missing columns or expressions for logs.partition`)
 
 		err = EvalHCLBytes([]byte(`
 			schema "test" {}
@@ -753,7 +753,7 @@ table "logs" {
 				}
 			}
 		`), &schema.Schema{}, nil)
-		require.EqualError(t, err, `specutil: cannot convert table "logs": multiple definitions for logs.partition, use "columns" or "by"`)
+		require.EqualError(t, err, `cannot convert table "logs": multiple definitions for logs.partition, use "columns" or "by"`)
 	})
 }
 
diff --git a/sql/sqlcheck/datadepend/datadepend_test.go b/sql/sqlcheck/datadepend/datadepend_test.go
index fefad1fb..01e46055 100644
--- a/sql/sqlcheck/datadepend/datadepend_test.go
+++ b/sql/sqlcheck/datadepend/datadepend_test.go
@@ -38,6 +38,8 @@ func TestAnalyzer_AddUniqueIndex(t *testing.T) {
 									AddColumns(
 										schema.NewColumn("a"),
 										schema.NewColumn("b"),
+										schema.NewColumn("c"),
+										schema.NewColumn("d"),
 									),
 								Changes: []schema.Change{
 									// Ignore new created columns.
@@ -48,11 +50,19 @@ func TestAnalyzer_AddUniqueIndex(t *testing.T) {
 										I: schema.NewUniqueIndex("idx_a").
 											AddColumns(schema.NewColumn("a")),
 									},
-									// Report on existing columns.
+									// Report on existing column.
 									&schema.AddIndex{
 										I: schema.NewUniqueIndex("idx_b").
 											AddColumns(schema.NewColumn("b")),
 									},
+									// Report on existing columns.
+									&schema.AddIndex{
+										I: schema.NewUniqueIndex("idx_c_d").
+											AddColumns(
+												schema.NewColumn("c"),
+												schema.NewColumn("d"),
+											),
+									},
 								},
 							},
 						},
@@ -69,8 +79,9 @@ func TestAnalyzer_AddUniqueIndex(t *testing.T) {
 	err = az.Analyze(context.Background(), pass)
 	require.NoError(t, err)
 	require.Equal(t, "data dependent changes detected", report.Text)
-	require.Len(t, report.Diagnostics, 1)
+	require.Len(t, report.Diagnostics, 2)
 	require.Equal(t, `Adding a unique index "idx_b" on table "users" might fail in case column "b" contains duplicate entries`, report.Diagnostics[0].Text)
+	require.Equal(t, `Adding a unique index "idx_c_d" on table "users" might fail in case columns "c", "d" contain duplicate entries`, report.Diagnostics[1].Text)
 }
 
 func TestAnalyzer_ModifyUniqueIndex(t *testing.T) {