PostgreSQLのユーザ構成管理(IaC) -Anasible編-
まえがき
PostgreSQLのユーザ管理、構成管理をしようと思い色々調べてみたのですが自分の中でこれといったものがなかなか見つからなかったのでここに書き記しておきます。
- ユーザ認証方式検討
- 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
のmainelementlookup('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') }}"