PostgreSQLのユーザ構成管理(IaC) -Anasible編-

# PostgreSQLのユーザ構成管理(IaC) -Anasible編-
## まえがき
PostgreSQLのユーザ管理、構成管理をしようと思い色々調べてみたのですが自分の中でこれといったものがなかなか見つからなかったのでここに書き記しておきます。
- [ユーザ認証方式検討](https://qiita.com/tp7hayct/items/6889927c62ba0735411b)
- PostgreSQLのユーザ構成管理(IaC) -Anasible編- (本記事)
- PostgreSQLのユーザ構成管理(IaC) -Terraform編- (記事がかけたらリンク張る)
- MySQLのユーザ構成管理(IaC) -Anasible編- (記事がかけたらリンク張る)
- MySQLのユーザ構成管理(IaC) -Terraform編- (記事がかけたらリンク張る)

今回はAnsibleで以下を実装しました。また、今回は `組み込みユーザ認証方式` でユーザを作成しています。 
- ユーザロールの作成
- グループロールの作成
- グループロールへの権限付与
- ユーザロールのグループロールの継承
- パスワードの送付

## 構成
### ディレクトリ構成
```
ansible
├── staging.yml
├── roles
│   └── send-mail
│       └── tasks
│           └── main.yaml      # credentialsに書き込まれたファイルを送信するロールです
│   └── db-account
│       └── tasks
│           └── test_db.yaml   # ユーザロール、グループロール、各種権限付与を行うロールです。今回はDB毎にyamlファイルを作成する方式にしました
├── group_vars
│   ├── staging.yaml           # DB(instance)に作成するデータベース情報やユーザロール、グループロールを記述します
│   ├── staging.sops.yaml      # DBの認証情報を記述します
│   ├── production.yaml
│   └── production.sops.yaml
├── hosts_staging              # db-account roleの実行先環境を記述します
├── hosts_production
├── hosts_sendmail             # send-mail role の実行先環境を記述します
├── credentials                # こちらのディレクトリにパスワードが書き込まれたファイルが生成されます
├── ansible.cfg                # ansibleでsopsを使用するために必要です
└── README.md
```

### コード
- staging.yaml  
```
test_db:
  database:
    - database1
    - database2
  group_role:
    - read_only
    - read_write
  group_role_priv:
    - type: table
      role: read_only
      priv: SELECT
    - type: table
      role: read_write
      priv: SELECT,INSERT,UPDATE,DELETE
    - type: sequence
      role: read_only
      priv: USAGE,SELECT
    - type: sequence
      role: read_write
      priv: USAGE,SELECT,UPDATE

test_db_user:
  - name: taro.yamada
    email: taro.yama@example.com
    group: [read_write]
    state: present
    group_state: present
    no_password_changes: yes
  - name: hanako.yamada
    email: hanako.yamada@example.com
    group: [read_write]
    state: present
    group_state: present
    no_password_changes: yes
```

- staging.sops.yaml  
```
test_db_info:
    login_host: xxx.xxx.xxx.xxx
    login_user: postgres
    login_password: postgres
```

- test_db.yaml
```
- name: Create group roles
  community.postgresql.postgresql_user:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    name: "{{ item }}"
    no_password_changes: yes
    role_attr_flags: NOLOGIN
    fail_on_user: no
    state: present
  loop: "{{ test_db.group_role }}"

- name: Attach priv to group roles on public schema
  community.postgresql.postgresql_privs:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    database: "{{ item.0 }}"
    privs: "{{ item.1.priv }}"
    type:  "{{ item.1.type }}"
    objs: ALL_IN_SCHEMA
    schema: public
    roles: "{{ item.1.role }}"
    grant_option: no
    fail_on_role: no
    state: present
  loop: "{{ test_db.database | product(test_db.group_role_priv) }}"

- name: Collect all roles
  community.postgresql.postgresql_info:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    filter: roles
  register: roles

- name: Create user roles
  community.postgresql.postgresql_user:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    name: "{{ item.name }}"
    no_password_changes: yes
    encrypted: yes
    password: >-
      {%- if item.state == 'present' -%}
        {%- if ansible_check_mode == false -%}
          {%- if item.name in roles.roles | list -%}
          {%- else -%}
            {{ lookup('password', 'credentials/' + item.email + '.txt length=32 chars=ascii_uppercase,ascii_lowercase,digits') }}
          {%- endif -%}
        {%- endif -%}
      {%- endif -%}
    state: "{{ item.state }}"
  loop: "{{ test_db_user }}"

- name: Collect all roles after Create user roles
  community.postgresql.postgresql_info:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    filter: roles
  register: roles

- name: Reset priv to user roles
  community.postgresql.postgresql_membership:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    groups: >-
      {%- if not roles.roles[item.name].member_of -%}
        {{ item.group }}
      {%- else -%}
        {%- if roles.roles[item.name].member_of == item.group -%}
          {{ roles.roles[item.name].member_of }}
        {%- else -%}
          {{ roles.roles[item.name].member_of }}
        {%- endif -%}
      {%- endif -%}
    target_role: "{{ item.name }}"
    fail_on_role: no
    state: >-
      {%- if not roles.roles[item.name].member_of -%}
        present
      {%- else -%}
        {%- if roles.roles[item.name].member_of == item.group -%}
          present
        {%- else -%}
          absent
        {%- endif -%}
      {%- endif -%}
  with_items: "{{ test_db_user }}"
  when: item.name in roles.roles | list

- name: Attach priv to user roles
  community.postgresql.postgresql_membership:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    groups: "{{ item.group }}"
    target_role: "{{ item.name }}"
    fail_on_role: no
    state: "{{ item.group_state }}"
  loop: "{{ test_db_user }}"
  when: item.name in roles.roles | list
```

- main.yaml(ansible/send-mail/tasks/main.yaml)
```
- name: Copy password file from local to remote
  copy:
    src: credentials/
    dest: /tmp/credentials
    remote_src: no
  check_mode: no

- name: get files
  find:
    paths: /tmp/credentials
    file_type: "file"
  register: find_result
  check_mode: no

- name: Send an email to a single recipient that the deployment was successful
  community.general.sendgrid:
    api_key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    attachments:  /tmp/credentials/{{ item.path | basename }}
    from_address: "test@example.com"
    to_addresses: "{{ item.path | basename | splitext | first }}"
    subject: "Database Password"
    body: "test"
  #delegate_to: localhost
  with_items: "{{ find_result.files }}"

- file: path=/tmp/credentials/
        state=absent
```

- ansible.cfg
```
[defaults]
vars_plugins_enabled = host_group_vars,community.sops.sops
```

## 解説
### Collect all roles が何故必要なのか
`community.postgresql.postgresql_user` moduleではユーザが存在する場合はスキップしてくれますし、 `no_password_changes` を設定すればパスワードも変更されないので一見 `Collect all roles` タスクは不要に感じます。しかしながら、↓のパスワード作成部分がやっかいでこのチェック機構を挟まないと存在するユーザ分もパスワードが作成されてしまうので、それを回避するため敢えて入れています。
パスワードをファイルに出力せずその場でランダムに作成してしまうと　`DBを複数作成した場合「DBごとに初期パスワードが異なってしまい面倒であること` 、 `メールでパスワードを送信したいこと` を理由にこういった形でパスワードを生成するようにしています。なお、 `lookup関数` を使うとファイルが存在しない場合はファイルを作成し、存在する場合はそのファイルからパスワードを読み取ってくれます。
```
{{ lookup('password', 'credentials/' + item.email + '.txt length=32 chars=ascii_uppercase,ascii_lowercase,digits') }}
```
当該箇所
```
- name: Collect all roles
  community.postgresql.postgresql_info:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    filter: roles
  register: roles

- name: Create user roles
  community.postgresql.postgresql_user:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    name: "{{ item.name }}"
    no_password_changes: yes
    encrypted: yes
    password: >-
      {%- if item.state == 'present' -%}
        {%- if ansible_check_mode == false -%}
          {%- if item.name in roles.roles | list -%}
          {%- else -%}
            {{ lookup('password', 'credentials/' + item.email + '.txt length=32 chars=ascii_uppercase,ascii_lowercase,digits') }}
          {%- endif -%}
        {%- endif -%}
      {%- endif -%}
    state: "{{ item.state }}"
  loop: "{{ test_db_user }}"
```

### {%- if not roles.roles[item.name].member_of -%}
こちらは解説というほどのものではないのですが結構ハマったところなので書いています。前述のパスワード作成部分で `item.email` のような変数を `+` でくっつける方法の記事はいくつかでてくるのですが、その方法ではうまく取り出せなかったので備忘録を兼ねて記載しておきました。
```
{{ lookup('password', 'credentials/' + item.email + '.txt length=32 chars=ascii_uppercase,ascii_lowercase,digits') }}
```
ちなみにregisterに登録したrolesにはこういった構造で格納されます。
```
TASK [debug] ***************************************************************************************************************************************************************
ok: [xxx.xxx.xxx.xxx => {
    "msg": [
        {
            "changed": false,
            "databases": {},
            "failed": false,
            "in_recovery": null,
            "pending_restart_settings": [],
            "repl_slots": {},
            "replications": {},
            "roles": {
                "read_only": {
                    "canlogin": false,
                    "member_of": [],
                    "superuser": false,
                    "valid_until": "9999-12-31T23:59:59.999999+00:00"
                },
                "read_write": {
                    "canlogin": false,
                    "member_of": [],
                    "superuser": false,
                    "valid_until": "9999-12-31T23:59:59.999999+00:00"
                },
                "hanako.yamada": {
                    "canlogin": true,
                    "member_of": [
                        "read_only"
                    ],
                    "superuser": false,
                    "valid_until": ""
                },
                "taro.yamada": {
                    "canlogin": true,
                    "member_of": [
                        "read_write"
                    ],
                    "superuser": false,
                    "valid_until": "9999-12-31T23:59:59.999999+00:00"
                },
            },
            "settings": {},
            "tablespaces": {},
            "version": {}
        }
    ]
}

PLAY RECAP *****************************************************************************************************************************************************************
xxx.xxx.xxx.xxx              : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
```
当該コード
```
{%- if not roles.roles[item.name].member_of -%}
```

### Reset priv to user roles が何故存在するのか
`community.postgresql.postgresql_membership` はユーザロールをグループロールを継承させるmoduleです。一見、継承させるだけなので 最下部の`Attach priv to user roles` のタスクだけでも良さそうですが、こちらの `community.postgresql.postgresql_membership` はAnsibleの仕様で `state: present` で付与、 `state: absent` で削除しかできません。つまり仮に `taro.yamada` が `read_write` ロールを現状継承しているが、それを `read_only` に付け替えたいというケースで単純に　`Attach priv to user roles` を実行しただけでは、 `read_only` に所属はしますが、 `state: absent` は実行されていないのでもともと継承していた `read_write` ロールにも所属したままになります。それを回避するために一度こちらのタスクで不要なロールをリセットしてあげる機構を取り入れています。(正直このmoduleを使っている他の方がどうやって運用を回しているのかとても気になります...毎回今の権限を確認して `absent` してから `present` しているのでしょうか...)また、 `roles.roles[item.name].member_of` が空の状態で `state: absent` を実行するとエラーでAnsibleは実行をやめてしまいます。それを回避するために以下の条件分岐を入れています。
```
{%- if not roles.roles[item.name].member_of -%}
```
その後は、item.groupが `roles.roles[item.name].member_of` と一致するかしないかで `absent` させるか `present` させるかを分岐させています。つまりタスク名上はResetとしていますが、厳密にはリセットだけではなく登録もすることになります。
```
{%- if roles.roles[item.name].member_of == item.group -%}
```

当該コード
```

- name: Reset priv to user roles
  community.postgresql.postgresql_membership:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    groups: >-
      {%- if not roles.roles[item.name].member_of -%}
        {{ item.group }}
      {%- else -%}
        {%- if roles.roles[item.name].member_of == item.group -%}
          {{ roles.roles[item.name].member_of }}
        {%- else -%}
          {{ roles.roles[item.name].member_of }}
        {%- endif -%}
      {%- endif -%}
    target_role: "{{ item.name }}"
    fail_on_role: no
    state: >-
      {%- if not roles.roles[item.name].member_of -%}
        present
      {%- else -%}
        {%- if roles.roles[item.name].member_of == item.group -%}
          present
        {%- else -%}
          absent
        {%- endif -%}
      {%- endif -%}
  with_items: "{{ test_db_user }}"
  when: item.name in roles.roles | list

- name: Attach priv to user roles
  community.postgresql.postgresql_membership:
    login_host: "{{ test_db_info.login_host }}"
    login_user: "{{ test_db_info.login_user }}"
    login_password: "{{ test_db_info.login_password }}"
    groups: "{{ item.group }}"
    target_role: "{{ item.name }}"
    fail_on_role: no
    state: "{{ item.group_state }}"
  loop: "{{ test_db_user }}"
  when: item.name in roles.roles | list
```

### sendmail
こちらはオマケだと思っていただければと思います。sendgridでcredentialsに格納されたファイルを当該ユーザに送信します。ファイル名をemailアドレスにしておいたのはこちらが理由です。ファイルはローカルにしか作成されないので、sendgridが動作するリモートサーバにデータをコピーし最後に削除しています。
```
- name: Copy password file from local to remote
  copy:
    src: credentials/
    dest: /tmp/credentials
    remote_src: no
  check_mode: no

- name: get files
  find:
    paths: /tmp/credentials
    file_type: "file"
  register: find_result
  check_mode: no

- name: Send an email to a single recipient that the deployment was successful
  community.general.sendgrid:
    api_key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    attachments:  /tmp/credentials/{{ item.path | basename }}
    from_address: "test@example.com"
    to_addresses: "{{ item.path | basename | splitext | first }}"
    subject: "Database Password"
    body: "The most recent Ansible deployment was successful."
  #delegate_to: localhost
  with_items: "{{ find_result.files }}"

- file: path=/tmp/credentials/
        state=absent
```

## オマケ
今回はわかりやすさ、取り回しやすさを重視して書きましたが、以下のような書き方でファイル数を削減したり環境をTaskで吸収したりもできますね。より深いネストになります。
2重までのネストはよく記事で出てくるのですが3重のネストはあまり出てこなかったのでご紹介です。
- /ansible/group_vars/staging.sops.yaml
```
env: "staging"
staging:
  - db_name: test-db
    db_info:
      login_host: xxx.xxx.xxx.xxx
      login_user: postgres
      login_password: xxxxxxxxxxxxxxxxxxxxxxxx
    db_list:
      - database1
      - database2
    user_list:
      - name: yaro.yamada
        email: taro.yamada@example.com
        group: [read_only]
        state: present
        group_state: absent
```

- ansible/roles/db-account/tasks/test_db.yaml
`loop` のmainelement `lookup('vars', env)` がポイントです。
```
- name: Create read_only role
  community.postgresql.postgresql_user:
    login_host: "{{ item.0.db_info.login_host }}"
    login_user: "{{ item.0.db_info.login_user }}"
    login_password: "{{ item.0.db_info.login_password }}"
    db: "{{ item.1 }}"
    name: read_only
    no_password_changes: yes
    role_attr_flags: NOLOGIN
    fail_on_user: no
    state: present
  loop: "{{ lookup('vars', env) | subelements('db_list') }}"
```