├── .circleci
└── config.yml
├── .editorconfig
├── .github
└── ISSUE_TEMPLATE
│ ├── i-hit-a-bug-with-ldap2pg.md
│ └── i-need-help-writing-my-ldap2pg-yml.md
├── .gitignore
├── .golangci.yaml
├── .goreleaser.yaml
├── .readthedocs.yaml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── build
└── simplechanges.py
├── cmd
└── render-doc
│ └── main.go
├── default.pgo
├── docker-compose.yml
├── docker
├── Dockerfile
├── README.md
└── docker-entrypoint.sh
├── docs
├── builtins.md
├── builtins.md.tmpl
├── changelog.md
├── cli.md
├── config.md
├── guides
│ ├── acls.md
│ └── cookbook.md
├── hacking.md
├── img
│ ├── logo-80.png
│ ├── logo-horizontal.png
│ ├── logo-phrase.png
│ ├── logo-white.png
│ ├── logo.png
│ ├── owners-readers-adp.dia
│ ├── owners-readers-adp.svg
│ └── screenshot.png
├── index.md
├── install.md
├── ldap.md
├── ldap2pg.css
├── postgres.md
├── privileges.md
├── readme
│ ├── ldap2pg.yml
│ └── reset.sql
├── requirements.txt
└── roles.md
├── go.mod
├── go.sum
├── internal
├── cmd
│ ├── exit.go
│ ├── flags.go
│ ├── ldap2pg.go
│ └── version.go
├── config
│ ├── config_test.go
│ ├── file.go
│ ├── normalizers.go
│ ├── normalizers_test.go
│ ├── privilege.go
│ ├── query.go
│ ├── role.go
│ ├── role_test.go
│ ├── yaml.go
│ └── yaml_test.go
├── errorlist
│ ├── errorlist.go
│ └── errorlist_test.go
├── inspect
│ ├── config.go
│ ├── inspect_test.go
│ ├── query.go
│ ├── query_test.go
│ ├── sql
│ │ ├── creators.sql
│ │ ├── databases.sql
│ │ ├── role-columns.sql
│ │ ├── roles.sql
│ │ ├── schemas.sql
│ │ └── session.sql
│ ├── stage0.go
│ ├── stage1.go
│ ├── stage2.go
│ └── stage3.go
├── ldap
│ ├── client.go
│ ├── command.go
│ ├── command_test.go
│ ├── config.go
│ ├── filter.go
│ ├── filter_test.go
│ ├── generate.go
│ ├── generate_test.go
│ ├── ldap_test.go
│ ├── rc.go
│ ├── scope.go
│ └── search.go
├── lists
│ ├── blacklist.go
│ ├── blacklist_test.go
│ ├── bool.go
│ ├── lists_test.go
│ ├── product.go
│ └── product_test.go
├── logging.go
├── logging_test.go
├── normalize
│ ├── alias.go
│ ├── alias_test.go
│ ├── boolean.go
│ ├── boolean_test.go
│ ├── checks.go
│ ├── doc.go
│ ├── list.go
│ └── list_test.go
├── perf
│ ├── mem.go
│ ├── mem_test.go
│ ├── perf_test.go
│ ├── watch.go
│ └── watch_test.go
├── postgres
│ ├── apply.go
│ ├── global.go
│ ├── objects.go
│ └── queries.go
├── privileges
│ ├── acl.go
│ ├── builtin.go
│ ├── grant.go
│ ├── grant_test.go
│ ├── inspect.go
│ ├── privilege.go
│ ├── profile.go
│ ├── profile_test.go
│ ├── rule.go
│ ├── sql
│ │ ├── all-functions.sql
│ │ ├── all-sequences.sql
│ │ ├── all-tables.sql
│ │ ├── database.sql
│ │ ├── global-default.sql
│ │ ├── language.sql
│ │ ├── schema-default.sql
│ │ └── schema.sql
│ └── sync.go
├── pyfmt
│ ├── format.go
│ └── format_test.go
├── role
│ ├── config.go
│ ├── diff.go
│ ├── map.go
│ ├── membership.go
│ ├── membership_test.go
│ ├── options.go
│ ├── options_test.go
│ └── role.go
├── tree
│ ├── walk.go
│ └── walk_test.go
└── wanted
│ ├── map.go
│ ├── rules.go
│ ├── step.go
│ ├── step_test.go
│ └── wanted_test.go
├── ldap2pg.yml
├── ldaprc
├── main.go
├── mkdocs.yml
└── test
├── conftest.py
├── docker-compose.yml
├── entrypoint.sh
├── extra.ldap2pg.yml
├── fixtures
├── big.sh
├── genperfldif.sh
├── postgres
│ ├── extra.sh
│ ├── nominal.sh
│ └── reset.sh
└── samba
│ ├── extra.sh
│ └── nominal.sh
├── func
└── .gitignore
├── genbigconfig.sh
├── ldap2pg.sh
├── pytest.ini
├── requirements.txt
├── test_config.py
├── test_extra.py
└── test_nominal.py
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | workflows:
4 | version: 2
5 | default:
6 | jobs:
7 | - build:
8 | name: "stage0-build"
9 | - lint:
10 | name: "stage0-lint"
11 | - e2e:
12 | name: "stage1-e2e-rockylinux9-pg17"
13 | dist: rockylinux9
14 | requires: [stage0-build]
15 | pgversion: "16"
16 | - e2e:
17 | name: "stage1-e2e-rockylinux8-pg16"
18 | dist: rockylinux8
19 | requires: [stage0-build]
20 | pgversion: "15"
21 | - e2e:
22 | name: "stage1-e2e-centos7-pg12"
23 | dist: centos7
24 | requires: [stage0-build]
25 | pgversion: "12"
26 | - e2e:
27 | name: "stage1-e2e-centos6-pg9.5"
28 | dist: centos6
29 | requires: [stage0-build]
30 | pgversion: "9.5"
31 | - release:
32 | name: stage2-release
33 | # Send secrets to this jobs from temboard CircleCI context.
34 | context: ldap2pg
35 | filters:
36 | tags:
37 | only: '/v.+/'
38 | # Skip on branches!
39 | branches:
40 | ignore: '/.*/'
41 |
42 |
43 | jobs:
44 | build:
45 | working_directory: &workspace /workspace
46 | docker:
47 | - image: goreleaser/goreleaser:v2.8.2
48 | steps:
49 | - checkout
50 | - restore_cache:
51 | keys: [go-build]
52 | - run:
53 | name: Build ldap2pg binary snapshot
54 | command: |
55 | goreleaser build --clean --snapshot --single-target
56 | - run:
57 | name: Smoke test
58 | command: dist/ldap2pg_linux_amd64_v1/ldap2pg --version
59 | - run:
60 | name: Unit Test
61 | command: |
62 | go test -v ./...
63 | - save_cache:
64 | key: go-build-{{ epoch }}
65 | paths:
66 | - /go/pkg/mod
67 | - /root/.cache/go-build
68 | - store_artifacts:
69 | path: /workspace/dist/
70 | - persist_to_workspace:
71 | root: .
72 | paths: [dist/]
73 |
74 | lint:
75 | working_directory: *workspace
76 | docker:
77 | - image: golangci/golangci-lint:v2.0.2
78 | steps:
79 | - checkout
80 | - restore_cache:
81 | keys: [ldap2pg-go-lint]
82 | - run:
83 | name: Lint
84 | command: |
85 | golangci-lint run
86 | - save_cache:
87 | key: go-lint-{{ epoch }}
88 | paths:
89 | - /root/.cache/golangci-lint
90 | - /root/.cache/go-build
91 | - /go/pkg/mod
92 |
93 | e2e:
94 | parameters:
95 | dist:
96 | description: "Distribution."
97 | type: string
98 | pgversion:
99 | description: "Major dotted version of PostgreSQL."
100 | type: string
101 | machine:
102 | image: ubuntu-2204:2024.01.2
103 | resource_class: medium
104 | working_directory: /home/circleci/workspace
105 | steps:
106 | - checkout
107 | - attach_workspace:
108 | at: /home/circleci/workspace
109 | - run:
110 | name: Run Docker Compose
111 | environment:
112 | PGVERSION: "<< parameters.pgversion >>"
113 | DIST: "<< parameters.dist >>"
114 | command: |
115 | COMPOSE_FILE=docker-compose.yml:test/docker-compose.yml docker compose up --exit-code-from=test
116 |
117 | release:
118 | # Configure secrets of this job in ldap2pg CircleCI context.
119 | docker: [{image: goreleaser/goreleaser:v2.9.0}]
120 | working_directory: *workspace
121 | steps:
122 | - checkout
123 | - restore_cache:
124 | keys: [go-build]
125 | - run:
126 | name: GoReleaser
127 | command: goreleaser release --clean
128 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = tab
6 | indent_size = 8
7 | end_of_line = lf
8 | max_line_length = 79
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.go]
13 | indent_size = 4
14 |
15 | [*.py]
16 | indent_style = space
17 | indent_size = 4
18 |
19 | [{*.md,*.yml,*.yaml}]
20 | indent_size = 2
21 | indent_style = space
22 |
23 | [*.sql]
24 | indent_style = space
25 | indent_size = 2
26 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/i-hit-a-bug-with-ldap2pg.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: I hit a bug with ldap2pg
3 | about: Report a bug with detailed information
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 |
23 |
24 |
25 | ## ldap2pg.yml
26 |
27 |
28 |
29 | ldap2pg.yml
30 | ``` yaml
31 | postgres:
32 | ...
33 |
34 | rules:
35 | ...
36 | ```
37 |
38 |
39 |
40 | ## Expectations
41 |
42 | - What you expected from ldap2pg ?
43 | - What ldap2pg did wrong ?
44 |
45 |
46 | ## Verbose output of ldap2pg execution
47 |
48 | Verbose output
49 | ``` console
50 | $ ldap2pg --verbose --real
51 | [ldap2pg.config INFO] Starting ldap2pg ...
52 | ...
53 | $
54 | ```
55 |
56 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/i-need-help-writing-my-ldap2pg-yml.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: I need help writing my ldap2pg.yml
3 | about: Ask for help on configuration.
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 |
24 |
25 | ## What do you want?
26 |
27 |
28 |
29 | ## ldap2pg.yml
30 |
31 | ldap2pg.yml
32 | ``` yml
33 | ...
34 | ```
35 |
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Delve binary.
2 | __debug_bin
3 | dist/
4 | docker-compose.override.yml
5 | # test/conftest.py creates .env files
6 | .env
7 |
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | linters:
3 | enable:
4 | - misspell
5 | - revive
6 | settings:
7 | staticcheck:
8 | checks:
9 | - -S1034
10 | - all
11 | exclusions:
12 | generated: lax
13 | presets:
14 | - comments
15 | - common-false-positives
16 | - legacy
17 | - std-error-handling
18 | paths:
19 | - third_party$
20 | - builtin$
21 | - examples$
22 | formatters:
23 | enable:
24 | - gofumpt
25 | - goimports
26 | settings:
27 | gofumpt:
28 | extra-rules: true
29 | exclusions:
30 | generated: lax
31 | paths:
32 | - third_party$
33 | - builtin$
34 | - examples$
35 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | # Required when source tree is not copied in a directory called ldap2pg
4 | project_name: ldap2pg
5 |
6 | builds:
7 | - main: .
8 | env:
9 | - CGO_ENABLED=0
10 | flags:
11 | - -pgo=default.pgo
12 | gcflags:
13 | - -trimpath -buildvcs
14 | goarch:
15 | - amd64
16 | - arm64
17 | goos:
18 | - linux
19 | - windows
20 | - darwin
21 | ignore:
22 | - goos: windows
23 | goarch: arm64
24 |
25 | changelog:
26 | disable: true
27 |
28 | nfpms:
29 | - formats:
30 | - deb
31 | - rpm
32 | - apk
33 | maintainer: "Étienne BERSAC "
34 | description: Manage PostgreSQL roles and privileges from YAML or LDAP
35 | vendor: "Dalibo"
36 | homepage: "https://labs.dalibo.com/ldap2pg"
37 | license: "PostgreSQL"
38 |
39 | release:
40 | prerelease: auto
41 |
42 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
43 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: "ubuntu-20.04"
5 | tools:
6 | python: "3.10"
7 |
8 | python:
9 | install:
10 | - requirements: docs/requirements.txt
11 |
12 | mkdocs:
13 | fail_on_warning: true
14 | configuration: mkdocs.yml
15 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | docs/changelog.md
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | docs/hacking.md
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | PostgreSQL Licence
2 |
3 | Copyright (c) 2017, DALIBO
4 |
5 | Permission to use, copy, modify, and distribute this software and its
6 | documentation for any purpose, without fee, and without a written agreement is
7 | hereby granted, provided that the above copyright notice and this paragraph and
8 | the following two paragraphs appear in all copies.
9 |
10 | IN NO EVENT SHALL DALIBO BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL,
11 | INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE
12 | USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF DALIBO HAS BEEN ADVISED OF
13 | THE POSSIBILITY OF SUCH DAMAGE.
14 |
15 | DALIBO SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE
17 | SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND DALIBO HAS NO
18 | OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR
19 | MODIFICATIONS.
20 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | VERSION=$(shell git describe --tags | grep -Po 'v\K.+')
2 | YUM_LABS?=$(wildcard ../yum-labs)
3 |
4 | default:
5 | @echo ldap2pg $(VERSION)
6 |
7 | big: reset-samba
8 | while ! bash -c "echo -n > /dev/tcp/$${LDAPURI#*//}/636" ; do sleep 1; done
9 | test/fixtures/genbigldif.sh | ldapmodify -xw $$LDAPPASSWORD
10 | $(MAKE) reset-big
11 |
12 | reset-big: reset-postgres
13 | while ! bash -c "echo -n > /dev/tcp/$${PGHOST}/5432" ; do sleep 1; done
14 | test/fixtures/big.sh
15 |
16 | reset-%:
17 | docker compose up --force-recreate --no-deps --renew-anon-volumes --detach $*
18 |
19 | readme-sample:
20 | @test/ldap2pg.sh --config docs/readme/ldap2pg.yml --real
21 | @psql -f docs/readme/reset.sql
22 | @echo '$$ cat ldap2pg.yml'
23 | @cat docs/readme/ldap2pg.yml
24 | @echo '$$ ldap2pg --real'
25 | @test/ldap2pg.sh --color --config docs/readme/ldap2pg.yml --real 2>&1 | sed s,${PWD}/docs/readme,...,g
26 | @echo '$$ '
27 | @echo -e '\n\n\n\n'
28 |
29 | %.md: %.md.tmpl cmd/render-doc/main.go Makefile
30 | echo '' > $@.tmp
31 | go run ./cmd/render-doc $< >> $@.tmp
32 | mv -f $@.tmp $@
33 |
34 | .PHONY: docs
35 | docs: docs/builtins.md
36 | mkdocs build --clean --strict
37 |
38 | build-docker:
39 | docker build --build-arg http_proxy -t dalibo/ldap2pg:local -f docker/Dockerfile .
40 |
41 | RELEASE_BRANCH=master
42 | RELEASE_REMOTE=git@github.com:dalibo/ldap2pg.git
43 | NEXT_RELEASE:=$(shell grep -m 1 -Po '^# ldap2pg \K.+' CHANGELOG.md)
44 | release:
45 | git rev-parse --abbrev-ref HEAD | grep -q '^$(RELEASE_BRANCH)$$'
46 | ! grep -iq '^# Unreleased' CHANGELOG.md
47 | git commit docs/changelog.md -m "New version $(NEXT_RELEASE)"
48 | git tag v$(NEXT_RELEASE)
49 | git push $(RELEASE_REMOTE) refs/heads/$(RELEASE_BRANCH):refs/heads/$(RELEASE_BRANCH)
50 | git push $(RELEASE_REMOTE) tag v$(NEXT_RELEASE)
51 | @echo Now wait for CI and run make publish-packages;
52 |
53 | publish-packages:
54 | $(MAKE) download-packages
55 | $(MAKE) publish-deb
56 | $(MAKE) publish-rpm
57 |
58 | CURL=curl --fail --create-dirs --location --silent --show-error
59 | GH_DOWNLOAD=https://github.com/dalibo/ldap2pg/releases/download/v$(VERSION)
60 | PKGBASE=ldap2pg_$(VERSION)_linux_amd64
61 | download-packages:
62 | $(CURL) --output-dir dist/ --remote-name $(GH_DOWNLOAD)/$(PKGBASE).deb
63 | $(CURL) --output-dir dist/ --remote-name $(GH_DOWNLOAD)/$(PKGBASE).rpm
64 |
65 | dist/$(PKGBASE)_%.changes: dist/$(PKGBASE).deb
66 | CODENAME=$* build/simplechanges.py $< > $@
67 | debsign $@
68 |
69 | publish-deb:
70 | rm -vf dist/*.changes
71 | $(MAKE) dist/$(PKGBASE)_bookworm.changes
72 | $(MAKE) dist/$(PKGBASE)_bullseye.changes
73 | $(MAKE) dist/$(PKGBASE)_buster.changes
74 | $(MAKE) dist/$(PKGBASE)_stretch.changes
75 | $(MAKE) dist/$(PKGBASE)_jammy.changes
76 | @if expr match "$(VERSION)" ".*[a-z]\+" >/dev/null; then echo 'Refusing to publish prerelease $(VERSION) in APT repository.'; false ; fi
77 | dput labs dist/*.changes
78 |
79 | publish-rpm:
80 | @make -C $(YUM_LABS) clean
81 | cp dist/$(PKGBASE).rpm $(YUM_LABS)/rpms/RHEL9-x86_64/
82 | cp dist/$(PKGBASE).rpm $(YUM_LABS)/rpms/RHEL8-x86_64/
83 | cp dist/$(PKGBASE).rpm $(YUM_LABS)/rpms/RHEL7-x86_64/
84 | cp dist/$(PKGBASE).rpm $(YUM_LABS)/rpms/RHEL6-x86_64/
85 | @if expr match "$(VERSION)" ".*[a-z]\+" >/dev/null; then echo 'Refusing to publish prerelease $(VERSION) in YUM repository.'; false ; fi
86 | @make -C $(YUM_LABS) push createrepos clean
87 |
88 | tag-latest:
89 | docker rmi --force dalibo/ldap2pg:$(VERSION)
90 | docker pull dalibo/ldap2pg:$(VERSION)
91 | docker tag dalibo/ldap2pg:$(VERSION) dalibo/ldap2pg:latest
92 | @if expr match "$(VERSION)" ".*[a-z]\+" >/dev/null; then echo 'Refusing to tag prerelease $(VERSION) as latest in Docker Hub repository.'; false ; fi
93 | docker push dalibo/ldap2pg:latest
94 |
--------------------------------------------------------------------------------
/cmd/render-doc/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 | "path/filepath"
7 | "strings"
8 | "text/template"
9 |
10 | "github.com/dalibo/ldap2pg/v6/internal/privileges"
11 | "github.com/gosimple/slug"
12 | )
13 |
14 | func main() {
15 | if len(os.Args) != 2 {
16 | slog.Error("missing template path")
17 | os.Exit(1)
18 | }
19 | filename := os.Args[1]
20 | slog.Info("Loading template.", "filename", filename)
21 | t := template.New(filepath.Base(filename)).Funcs(template.FuncMap{
22 | "slugify": func(s string) string {
23 | // Avoid _ which has a meaning in Markdown.
24 | return strings.ReplaceAll(slug.Make(s), "_", "-")
25 | },
26 | "markdown_escape": func(s string) string {
27 | // Escape _ as HTML entity because RTD bugs on this. See #440
28 | return strings.ReplaceAll(s, "_", "_")
29 | },
30 | })
31 | t, err := t.ParseFiles(filename)
32 | if err != nil {
33 | slog.Error("parse error", "err", err)
34 | os.Exit(1)
35 | }
36 | if t == nil {
37 | slog.Error("nil")
38 | os.Exit(1)
39 | }
40 |
41 | data := struct {
42 | Groups map[string][]any
43 | Privileges map[string]map[string]any
44 | Defaults map[string]map[string]any
45 | }{
46 | Groups: make(map[string][]any),
47 | Privileges: make(map[string]map[string]any),
48 | Defaults: make(map[string]map[string]any),
49 | }
50 |
51 | for key, items := range privileges.BuiltinsProfiles {
52 | l := items.([]any)
53 | item := l[0]
54 | switch item.(type) {
55 | case string:
56 | data.Groups[key] = l
57 | default:
58 | if strings.HasPrefix(key, "__default") {
59 | data.Defaults[key] = item.(map[string]any)
60 | } else {
61 | data.Privileges[key] = item.(map[string]any)
62 | }
63 | }
64 | }
65 |
66 | err = t.Execute(os.Stdout, data)
67 | if err != nil {
68 | slog.Error("execution error", "err", err)
69 | os.Exit(1)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/default.pgo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalibo/ldap2pg/07ed954869c1012dfb1fc81472324ab27489b546/default.pgo
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | samba:
3 | image: dalibo/samba:4.19.6@sha256:bbcc439041e8741885924b5e999c036ef4bb22dd0bda715e71c64f2f59547b68
4 | environment:
5 | REALM: bridoulou.fr
6 | ADMIN_PASS: 1Ntegral
7 | DNS_BACKEND: "NONE"
8 | volumes:
9 | - ./test/fixtures/samba/nominal.sh:/docker-entrypoint-init.d/95-nominal.sh
10 | - ./test/fixtures/samba/extra.sh:/docker-entrypoint-init.d/96-extra.sh
11 | hostname: samba
12 | domainname: ldap2pg.docker
13 | labels:
14 | com.dnsdock.alias: samba.ldap2pg.docker
15 | command: [-d=1]
16 |
17 | postgres:
18 | image: postgres:${PGVERSION-17}-alpine
19 | hostname: postgres
20 | domainname: ldap2pg.docker
21 | environment:
22 | POSTGRES_USER: postgres
23 | POSTGRES_HOST_AUTH_METHOD: trust
24 | volumes:
25 | - ./test/fixtures/postgres/reset.sh:/docker-entrypoint-initdb.d/00-reset.sh
26 | - ./test/fixtures/postgres/nominal.sh:/docker-entrypoint-initdb.d/10-nominal.sh
27 | - ./test/fixtures/postgres/extra.sh:/docker-entrypoint-initdb.d/20-extra.sh
28 | labels:
29 | com.dnsdock.alias: postgres.ldap2pg.docker
30 | command: [
31 | postgres,
32 | -c, log_statement=all,
33 | -c, log_connections=on,
34 | -c, "log_line_prefix=%m [%p]: [%l-1] app=%a,db=%d,client=%h,user=%u ",
35 | -c, cluster_name=ldap2pg-dev,
36 | ]
37 |
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM goreleaser/goreleaser:v2.9.0 AS builder
2 |
3 | WORKDIR /workspace
4 | COPY . .
5 | # Clean directory of untracked files, even those in developer ~/.config/git/ignore
6 | RUN git clean -df
7 | RUN goreleaser build --clean --single-target --auto-snapshot ;
8 |
9 | FROM alpine:3.18
10 |
11 | # Set LANG for execution order of entrypoint.d run parts.
12 | ENV LANG en_US.utf8
13 | WORKDIR /workspace
14 |
15 | COPY --from=builder /workspace/dist/ldap2pg_linux_amd64_v1/ldap2pg /usr/bin/ldap2pg
16 |
17 | RUN apk add --no-cache bash openldap-clients
18 | RUN mkdir /docker-entrypoint.d
19 | COPY docker/docker-entrypoint.sh /usr/local/bin
20 | ENTRYPOINT ["docker-entrypoint.sh"]
21 |
22 | # Smoketest
23 | RUN /usr/local/bin/docker-entrypoint.sh --version
24 |
--------------------------------------------------------------------------------
/docker/README.md:
--------------------------------------------------------------------------------
1 | [](https://labs.dalibo.com/ldap2pg)
2 |
3 | Swiss-army knife to synchronize Postgres roles and privileges from YAML
4 | or LDAP.
5 |
6 |
7 | ## Features
8 |
9 | - Creates, alters and drops PostgreSQL roles from LDAP searches.
10 | - Creates static roles from YAML to complete LDAP entries.
11 | - Manages role members (alias *groups*).
12 | - Grants or revokes privileges statically or from LDAP entries.
13 | - Dry run.
14 | - Logs LDAP searches as `ldapsearch` commands.
15 | - Logs **every** SQL query.
16 | - Reads settings from an expressive YAML config file.
17 |
18 |
19 | ## How to use this image
20 |
21 | `ldap2pg` runs in `/workspace` directory in the container. Thus you can mount
22 | configuration files in.
23 |
24 | ``` console
25 | $ docker run --rm --volume ${PWD}:/workspace dalibo/ldap2pg:latest --verbose --dry
26 | ```
27 |
28 | Or use `LDAP2PG_CONFIG` environment variable or `--config` CLI switch to point
29 | to another path.
30 |
31 |
32 | ## Environment variables
33 |
34 | `ldap2pg` accepts any `PG*`
35 | [envvars from libpq](https://www.postgresql.org/docs/current/libpq-envars.html),
36 | all `LDAP*` envvars from libldap2. More details can be found in
37 | [documentation](https://ldap2pg.readthedocs.io/en/latest/).
38 |
39 |
40 | ## Docker Secrets
41 |
42 | As an alternative to passing sensitive information via environment variables,
43 | `_FILE` may be appended to some environment variables, causing the
44 | initialization script to load the values for those variables from files present
45 | in the container. In particular, this can be used to load passwords from Docker
46 | secrets stored in `/run/secrets/` files. For example:
47 |
48 | ``` console
49 | $ docker run --rm -e PGPASSWORD_FILE=/run/secrets/postgres-passwd dalibo/ldap2pg:latest --verbose --dry
50 | ```
51 |
52 | Currently, this is only supported for `PGPASSWORD`and `LDAPPASSWORD`.
53 |
54 |
55 | ## Initialization Scripts
56 |
57 | If you would like to do additional initialization in an image derived from this
58 | one, add one or more `*.sh` scripts under /docker-entrypoint.d (creating the
59 | directory if necessary). Before the entrypoint calls ldap2pg, it will run any
60 | executable *.sh scripts, and source any non-executable *.sh scripts found in
61 | that directory.
62 |
63 | These initialization files will be executed in sorted name order as defined by
64 | the current locale, which defaults to en_US.utf8.
65 |
66 |
67 | ## Support
68 |
69 | If you need support and you didn\'t found it in
70 | [documentation](https://ldap2pg.readthedocs.io/en/latest/), just drop a question
71 | in a [GitHub issue](https://github.com/dalibo/ldap2pg/issues/new)! Don\'t miss
72 | the [cookbook](https://ldap2pg.readthedocs.io/en/latest/cookbook/). You\'re
73 | welcome!
74 |
75 | `ldap2pg` is licensed under
76 | [PostgreSQL license](https://opensource.org/licenses/postgresql). `ldap2pg` is
77 | available with the help of wonderful people, jump to
78 | [contributors](https://github.com/dalibo/ldap2pg/blob/master/CONTRIBUTING.md#contributors)
79 | list to see them.
80 |
--------------------------------------------------------------------------------
/docker/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Bootstrap script for running ldap2pg in container.
4 | #
5 | # This script is mostly stolen from Docker official Postgres image.
6 | #
7 |
8 | set -eu
9 | shopt -s nullglob
10 |
11 | main() {
12 | file_env PGPASSWORD
13 | file_env LDAPPASSWORD
14 |
15 | # check dir permissions to reduce likelihood of half-initialized database
16 | ls /docker-entrypoint.d/ > /dev/null
17 | docker_process_init_files /docker-entrypoint.d/*
18 |
19 | echo
20 | echo "$0: ldap2pg init process complete; ready to start up."
21 | echo
22 |
23 | exec ldap2pg "$@"
24 | }
25 |
26 | # usage: docker_process_init_files [file [file [...]]]
27 | # process initializer files, based on file extensions and permissions
28 | docker_process_init_files() {
29 | local f
30 | for f ; do
31 | case "$f" in
32 | *.sh)
33 | if [ -x "$f" ] ; then
34 | echo "$0: running $f"
35 | "$f"
36 | else
37 | echo "$0: sourcing $f"
38 | # shellcheck source=/dev/null
39 | . "$f"
40 | fi
41 | ;;
42 | *)
43 | echo "$0: ignoring $f"
44 | ;;
45 | esac
46 | done
47 | }
48 |
49 | # usage: file_env VAR [DEFAULT]
50 | # ie: file_env 'XYZ_DB_PASSWORD' 'example'
51 | # (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of
52 | # "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature)
53 | file_env() {
54 | local var="$1"
55 | local fileVar="${var}_FILE"
56 | local def="${2:-}"
57 | if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
58 | echo >&2 "error: both $var and $fileVar are set (but are exclusive)"
59 | exit 1
60 | fi
61 | local val="$def"
62 | if [ "${!var:-}" ]; then
63 | val="${!var}"
64 | elif [ "${!fileVar:-}" ]; then
65 | val="$(< "${!fileVar}")"
66 | fi
67 | export "$var"="$val"
68 | unset "$fileVar"
69 | }
70 |
71 | main "$@"
72 |
--------------------------------------------------------------------------------
/docs/cli.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Command Line Interface
4 |
5 | ldap2pg tries to be friendly regarding configuration and consistent with psql,
6 | OpenLDAP utils and [12 factors apps](https://12factor.net/). ldap2pg reads its
7 | configuration from several sources, in the following order, first prevail:
8 |
9 | 1. command line arguments.
10 | 2. environment variables.
11 | 3. configuration file.
12 | 4. ldaprc, ldap.conf, etc.
13 |
14 | The `--help` switch shows regular online documentation for CLI arguments. As of
15 | version 5.7, this looks like:
16 |
17 | ``` console
18 | $ ldap2pg --help
19 | usage: ldap2pg [OPTIONS] [dbname]
20 |
21 | --check Check mode: exits with 1 if Postgres instance is unsynchronized.
22 | --color Force color output.
23 | -c, --config string Path to YAML configuration file. Use - for stdin.
24 | -C, --directory string Path to directory containing configuration files.
25 | -?, --help Show this help message and exit. (default true)
26 | -y, --ldappassword-file string Path to LDAP password file.
27 | -q, --quiet count Decrease log verbosity.
28 | -R, --real Real mode. Apply changes to Postgres instance.
29 | -P, --skip-privileges Turn off privilege synchronisation.
30 | -v, --verbose count Increase log verbosity.
31 | -V, --version Show version and exit. (default true)
32 |
33 | Optional argument dbname is alternatively the database name or a conninfo string or an URI.
34 | See man psql(1) for more information.
35 |
36 | By default, ldap2pg runs in dry mode.
37 | ldap2pg requires a configuration file to describe LDAP searches and mappings.
38 | See https://ldap2pg.readthedocs.io/en/latest/ for further details.
39 | ```
40 |
41 | Arguments can be defined multiple times. On conflict, the last argument is used.
42 |
43 |
44 | ## Environment variables
45 |
46 | ldap2pg has no CLI switch to configure Postgres connection.
47 | However, ldap2pg supports `libpq` [PG* env vars](https://www.postgresql.org/docs/current/libpq-envars.html).
48 |
49 | See [psql(1)] for details on libpq env vars.
50 |
51 | [psql(1)]: https://www.postgresql.org/docs/current/app-psql.html#APP-PSQL-ENVIRONMENT
52 |
53 | The same goes for LDAP, ldap2pg supports standard `LDAP*` env vars and `ldaprc` files.
54 | See `ldap.conf(5)` for further details on how to configure.
55 | ldap2pg accepts two extra variables: `LDAPPASSWORD` and `LDAPPASSWORD_FILE`.
56 |
57 | ldap2pg loads `.env` file in the lda2pg.yml's parent directory if exists.
58 |
59 | Use `true` or `false` for boolean values in environment. e.g. `LDAP2PG_SKIPPRIVILEGES=true`.
60 |
61 | !!! tip
62 |
63 | Test Postgres connexion using `psql(1)` and LDAP using `ldapwhoami(1)`,
64 | ldap2pg will be okay
65 | and it will be easier to debug the setup and the configuration later.
66 |
67 |
68 | ## Logging setup
69 |
70 | ldap2pg have several levels of logging:
71 |
72 | - `ERROR`: error details. When this happend, ldap2pg will crash.
73 | - `WARNING`: ldap2pg warns about choices you should be aware of.
74 | - `CHANGE`: only changes applied to Postgres cluster. (aka Magnus Hagander level).
75 | - `INFO` (default): tells what ldap2pg is doing, especially before long task.
76 | - `DEBUG`: everything, including raw SQL queries and LDAP searches and
77 | introspection details.
78 |
79 | The `--quiet` and `--verbose` switches respectively decrease and increase
80 | verbosity.
81 |
82 | You can select the highest level of verbosity with `LDAP2PG_VERBOSITY` envvar. For example:
83 |
84 |
85 | ``` console
86 | $ LDAP2PG_VERBOSITY=DEBUG ldap2pg
87 | 12:23:45 INFO Starting ldap2pg version=v6.0-alpha5 runtime=go1.21.0 commit=
88 | 12:23:45 WARN Running a prerelease! Use at your own risks!
89 | 12:23:45 DEBUG Searching configuration file in standard locations.
90 | 12:23:45 DEBUG Found configuration file. path=./ldap2pg.yml
91 | $
92 | ```
93 |
94 | ldap2pg output varies whether it's running with a TTY or not.
95 | If standard error is a TTY, logging is colored and tweaked for human reading.
96 | Otherwise, logging format is pure logfmt, for machine processing.
97 | You can force human-readable output by using `--color` CLI switch.
98 |
--------------------------------------------------------------------------------
/docs/img/logo-80.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalibo/ldap2pg/07ed954869c1012dfb1fc81472324ab27489b546/docs/img/logo-80.png
--------------------------------------------------------------------------------
/docs/img/logo-horizontal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalibo/ldap2pg/07ed954869c1012dfb1fc81472324ab27489b546/docs/img/logo-horizontal.png
--------------------------------------------------------------------------------
/docs/img/logo-phrase.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalibo/ldap2pg/07ed954869c1012dfb1fc81472324ab27489b546/docs/img/logo-phrase.png
--------------------------------------------------------------------------------
/docs/img/logo-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalibo/ldap2pg/07ed954869c1012dfb1fc81472324ab27489b546/docs/img/logo-white.png
--------------------------------------------------------------------------------
/docs/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalibo/ldap2pg/07ed954869c1012dfb1fc81472324ab27489b546/docs/img/logo.png
--------------------------------------------------------------------------------
/docs/img/owners-readers-adp.dia:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalibo/ldap2pg/07ed954869c1012dfb1fc81472324ab27489b546/docs/img/owners-readers-adp.dia
--------------------------------------------------------------------------------
/docs/img/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dalibo/ldap2pg/07ed954869c1012dfb1fc81472324ab27489b546/docs/img/screenshot.png
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | hide:
3 | - navigation
4 | ---
5 |
6 |
7 |
8 | 
9 |
10 | Out of the box, PostgreSQL is able to check password of an existing role using the LDAP protocol.
11 | ldap2pg automates the creation, update and removal of PostgreSQL roles and users from an entreprise directory.
12 |
13 | Managing roles is close to managing privileges as you expect roles to have proper default privileges.
14 | ldap2pg can grant and revoke privileges too.
15 |
16 | 
17 |
18 |
19 | # Features
20 |
21 | - Reads settings from an expressive YAML config file.
22 | - Creates, alters and drops PostgreSQL roles from LDAP searches.
23 | - Creates static roles from YAML to complete LDAP entries.
24 | - Manages role parents (alias *groups*).
25 | - Grants or revokes privileges statically or from LDAP entries.
26 | - Dry run, check mode.
27 | - Logs LDAP searches as `ldapsearch(1)` commands.
28 | - Logs **every** SQL statements.
29 |
30 | `ldap2pg` is licensed under [PostgreSQL license](https://opensource.org/licenses/postgresql).
31 |
32 | ldap2pg **requires** a configuration file called `ldap2pg.yaml`.
33 | Project ships a [tested ldap2pg.yml](https://github.com/dalibo/ldap2pg/blob/master/ldap2pg.yml) as a starting point.
34 |
35 | `ldap2pg` is reported to work with
36 | [Samba DC](https://www.samba.org/),
37 | [OpenLDAP](https://www.openldap.org/),
38 | [FreeIPA](https://www.freeipa.org/),
39 | Oracle Internet Directory and
40 | Microsoft Active Directory.
41 |
42 |
43 | # Support
44 |
45 | If you need support
46 | and you didn't found it in [documentation](https://ldap2pg.readthedocs.io/),
47 | just drop a question in a [GitHub discussions](https://github.com/dalibo/ldap2pg/discussions)!
48 | 🇫🇷 Possible en français.
49 | Don't miss the [cookbook](https://ldap2pg.readthedocs.io/en/latest/cookbook/) for advanced use cases.
50 |
51 |
52 | # Authors
53 |
54 | ldap2pg is a [Dalibo Labs](https://labs.dalibo.com/) project.
55 |
56 | - [Étienne BERSAC](https://github.com/bersace) and [Pierre-Louis GONON](https://github.com/pirlgon) develop.
57 | - [Damien Cazeils](https://www.damiencazeils.com) designed the logo.
58 | - [Harold le CLÉMENT de SAINT-MARCQ](https://github.com/hlecleme) implemented LDAP sub searches.
59 | - [Randolph Voorhies](https://github.com/randvoorhies) implemented role configuration synchronization.
60 |
--------------------------------------------------------------------------------
/docs/install.md:
--------------------------------------------------------------------------------
1 | Installation
2 |
3 |
4 | ## Requirements
5 |
6 | ldap2pg is released as a single binary with no dependencies.
7 |
8 | On runtime, ldap2pg requires an unprivileged role with `CREATEDB` and `CREATEROLE` options or a superuser access.
9 | ldap2pg does not require to run on the same host as the synchronized PostgreSQL cluster.
10 |
11 | With 2MiB of RAM and one vCPU, ldap2pg can synchronize several thousands of roles in seconds,
12 | depending on PostgreSQL instance and LDAP directory response time.
13 |
14 |
15 | ## Installing
16 |
17 | /// tab | Debian/Alpine
18 |
19 | Download package for your target system and architecture from [ldap2pg release page].
20 |
21 | ///
22 |
23 | /// tab | YUM/DNF
24 |
25 | On RHEL and compatible clone, [Dalibo Labs YUM repository](https://yum.dalibo.org/labs/) offer RPM package for ldap2pg.
26 |
27 | For using Dalibo Labs packaging:
28 |
29 | - [Enable Dalibo Labs YUM repository](https://yum.dalibo.org/labs/).
30 | - Install `ldap2pg` package with yum:
31 | ```
32 | yum install ldap2pg
33 | ```
34 |
35 | ///
36 |
37 | /// tab | Manual
38 |
39 | - Download binary for your target system and architecture from [ldap2pg release page].
40 | - Move the binary to `/usr/local/bin`.
41 | - Ensure it's executable with `chmod 0755 /usr/local/bin/ldap2pg`.
42 | - Test installation with `ldap2pg --version`.
43 |
44 | ///
45 |
46 | [ldap2pg release page]: https://github.com/dalibo/ldap2pg/releases
47 |
--------------------------------------------------------------------------------
/docs/ldap.md:
--------------------------------------------------------------------------------
1 | Querying Directory with LDAP
2 |
3 | ldap2pg reads LDAP searches in `rules` steps in the `ldapsearch` entry.
4 |
5 | A LDAP search is **not** mandatory.
6 | ldap2pg can create roles defined statically from YAML.
7 | Each LDAP search is executed once and only once.
8 | There is neither loop nor deduplication of LDAP searches.
9 |
10 | !!! tip
11 |
12 | ldap2pg logs LDAP searches as `ldapsearch` commands.
13 | Enable verbose messages to see them.
14 |
15 | You can debug a failing search by copy-pasting the command in your shell and update parameters.
16 | Once you are okay, translate back the right parameters in the YAML.
17 |
18 |
19 | ## Configuring Directory Access
20 |
21 | ldap2pg reads directory configuration from ldaprc file and LDAP* environment variables.
22 | Known LDAP options are:
23 |
24 | - BASE
25 | - BINDDN
26 | - PASSWORD
27 | - REFERRALS
28 | - SASL_AUTHCID
29 | - SASL_AUTHZID
30 | - SASL_MECH
31 | - TIMEOUT
32 | - TLS_REQCERT
33 | - NETWORK_TIMEOUT
34 | - URI
35 |
36 | See ldap.conf(5) for the meaning and format of each options.
37 |
38 |
39 | ## Injecting LDAP attributes
40 |
41 | Several parameters accepts LDAP attribute injection using curly braces.
42 | To do this, wraps attribute name with curly braces like `{cn}` or `{sAMAccountName}`.
43 | ldap2pg expands to each value of the attribute for each entries of the search.
44 |
45 | If the parameter has multiple LDAP attributes,
46 | ldap2pg expands to all combination of attributes for each entries.
47 |
48 | Given the following LDAP entries:
49 |
50 | ``` ldif
51 | dn: uid=dimitri,cn=Users,dc=bridoulou,dc=fr
52 | objectClass: inetOrgPerson
53 | uid: dimitri
54 | sn: Dimitri
55 | cn: dimitri
56 | mail: dimitri@bridoulou.fr
57 | company: external
58 |
59 | dn: cn=domitille,cn=Users,dc=bridoulou,dc=fr
60 | objectClass: inetOrgPerson
61 | objectClass: organizationalPerson
62 | objectClass: person
63 | objectClass: top
64 | cn: domitille
65 | sn: Domitille
66 | company: acme
67 | company: external
68 | ```
69 |
70 | The format `{company}_{cn}` with the above LDAP entries generates the following strings:
71 |
72 | - `acme_domitille`
73 | - `external_domitille`
74 | - `external_dimitri`
75 |
76 | The pseudo attribute `dn` is always available and references the Distinguished Name of the original LDAP entry.
77 |
78 |
79 | ### Accessing RDN and sub-search
80 |
81 | If an attribute type is Distinguished Name (DN),
82 | you can refer to a Relative Distinguished Name (RDN) with a dot, like this: `.`.
83 | If an RDN has multiple values, only the first value is returned.
84 | There is no way to access other value.
85 |
86 | For example,
87 | if a LDAP entry has `member` attribute with value `cn=toto,cn=Users,dc=bridoulou,dc=fr`,
88 | the `{member.cn}` format will generate `toto`.
89 | The `{member.dc}` format will generate `ldap`.
90 | There is no way to access `acme` and `fr`.
91 |
92 | Known RDN are `cn`, `l`, `st`, `o`, `ou`, `c`, `street`, `dc`, and `uid`.
93 | Other attributes triggers a sub-search.
94 | The format `{member.sAMAccountName}` will issue a sub-search for all `member` value as LDAP search base narrowed to `sAMAccountName` attribute.
95 |
96 |
97 | ### LDAP Attribute Case
98 |
99 | When injecting an LDAP attribute with curly braces,
100 | you can control the case of the value using `.lower()` or `.upper()` methods.
101 |
102 | ``` yaml
103 | - ldapsearch: ...
104 | role: "{cn.lower()}"
105 | ```
106 |
--------------------------------------------------------------------------------
/docs/ldap2pg.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --md-primary-fg-color: #a22045;
3 | --md-accent-fg-color: #a22045;
4 | }
5 |
--------------------------------------------------------------------------------
/docs/postgres.md:
--------------------------------------------------------------------------------
1 | Inspecting Postgres cluster
2 |
3 | ldap2pg follows the explicit create / implicit drop and explicit grant / implicit revoke pattern.
4 | Thus properly inspecting cluster for what you want to drop/revoke is very crucial to succeed in synchronization.
5 |
6 | ldap2pg inspects databases, schemas, roles, owners and grants with SQL queries.
7 | You can customize all these queries in the `postgres` YAML section
8 | with parameters ending with `_query`.
9 | See [ldap2pg.yaml reference] for details.
10 |
11 | [ldap2pg.yaml reference]: config.md#postgres
12 |
13 |
14 | ## What databases to synchronize ?
15 |
16 | `databases_query` returns the flat list of databases to manage.
17 | The `databases_query` must return the default database as defined in `PGDATABASE`.
18 | When dropping roles, ldap2pg loops the databases list to reassign objects and clean GRANTs of to be dropped role.
19 | This databases list also narrows the scope of GRANTs inspection.
20 | ldap2pg will revoke GRANTs only on these databases.
21 | See [ldap2pg.yaml reference] for details.
22 |
23 | ``` yaml
24 | postgres:
25 | databases_query: |
26 | SELECT datname
27 | FROM pg_catalog.pg_database
28 | WHERE datallowconn IS TRUE;
29 | ```
30 |
31 |
32 | ## Synchronize a subset of roles
33 |
34 | By default, ldap2pg manages all roles from Postgres it has powers on, minus the default blacklist.
35 | If you want ldap2pg to synchronsize only a subset of roles,
36 | you need to customize inspection query in `postgres:managed_roles_query`.
37 | The following query excludes superusers from synchronization.
38 |
39 | ``` yaml
40 | postgres:
41 | managed_roles_query: |
42 | SELECT 'public'
43 | UNION
44 | SELECT rolname
45 | FROM pg_catalog.pg_roles
46 | WHERE rolsuper IS FALSE
47 | ORDER BY 1;
48 | ```
49 |
50 | ldap2pg will only drop, revoke, grant on roles returned by this query.
51 |
52 | A common case for this query is to return only members of a group like `ldap_roles`.
53 | This way, ldap2pg is scoped to a subset of roles in the cluster.
54 |
55 | The `public` role does not exists in the system catalog.
56 | Thus if you want ldap2pg to manage `public` privileges,
57 | you must include explicitly `public` in the set of managed roles.
58 | This is the default.
59 | Of course, even if `public` is managed, ldap2pg won't drop or alter it if it's not in the directory.
60 |
61 | A safety net to completely ignore some roles is [roles_blacklist_query].
62 |
63 | ``` yaml
64 | postgres:
65 | roles_blacklist_query: [postgres, pg_*] # This is the default.
66 | ```
67 |
68 | [roles_blacklist_query]: config.md#postgres-roles-blacklist-query
69 |
70 | !!! note
71 |
72 | A pattern starting with a `*` **must** be quoted.
73 | Else you'll end up with a YAML error like `found undefined alias`.
74 |
75 |
76 | ## Inspecting Schemas
77 |
78 | For schema-wide privileges, ldap2pg needs to known managed schemas for each database.
79 | This is the purpose of `schemas_query`.
80 |
81 | [schemas_query]: config.md#postgres-schemas-query
82 |
83 |
84 | ## Configuring owners default privileges
85 |
86 | To configure default privileges, use the `default` keyword when referencing a privilege:
87 |
88 | ``` yaml
89 | privileges:
90 | reading:
91 | - default: global
92 | type: SELECT
93 | on: TABLES
94 | ```
95 |
96 | Then grant it using `grant` rule:
97 |
98 | ``` yaml
99 | rules:
100 | - grant:
101 | - privilege: reading
102 | role: readers
103 | schema: public
104 | owner: ownerrole
105 | ```
106 |
107 | You can use `__auto__` as owner.
108 | For each schema, ldap2pg will configure every managed role having `CREATE` privilege on schema.
109 |
110 | ``` yaml
111 | rules:
112 | - grant:
113 | - privilege: reading
114 | role: readers
115 | schema: public
116 | owner __auto__
117 | ```
118 |
119 | ldap2pg configures default privileges last, after all effective privileges.
120 | Thus `CREATE` on schema is granted before ldap2pg inspects creators on schemas.
121 |
122 |
123 | ## Static Queries
124 |
125 | You can replace all queries with a **static list** in YAML. This list will be
126 | used as if returned by Postgres. That's very handy to freeze a value like
127 | databases or schemas.
128 |
129 | ``` yaml
130 | postgres:
131 | databases_query: [postgres]
132 | schemas_query: [public]
133 | ```
134 |
--------------------------------------------------------------------------------
/docs/readme/ldap2pg.yml:
--------------------------------------------------------------------------------
1 | version: 6
2 |
3 | rules:
4 | - role:
5 | name: nominal
6 | options: NOLOGIN
7 | comment: "Database owner"
8 | - ldapsearch:
9 | base: ou=people,dc=ldap,dc=ldap2pg,dc=docker
10 | filter: "(objectClass=organizationalPerson)"
11 | role:
12 | name: '{cn}'
13 | options:
14 | LOGIN: yes
15 | CONNECTION LIMIT: 5
16 |
--------------------------------------------------------------------------------
/docs/readme/reset.sql:
--------------------------------------------------------------------------------
1 | -- Execute this before executing readme/ldap2pg.yml to have a few changes.
2 | DROP ROLE IF EXISTS "charles";
3 | CREATE ROLE "omar";
4 | ALTER ROLE "alain" WITH NOLOGIN CONNECTION LIMIT -1;
5 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | mkdocs==1.6.1
2 | mkdocs-exclude==1.0.2
3 | mkdocs-material==9.6.1
4 | mkdocs-material-extensions==1.3.1
5 |
--------------------------------------------------------------------------------
/docs/roles.md:
--------------------------------------------------------------------------------
1 | Managing roles
2 |
3 | ldap2pg synchronizes Postgres roles in three steps:
4 |
5 | 1. Loop `rules` and generate wanted roles list from `role` rules.
6 | 2. Inspect Postgres for existing roles, their options and their membership.
7 | 3. Compare the two roles sets and apply to the Postgres cluster using `CREATE`,
8 | `DROP` and `ALTER`.
9 |
10 | Each [role] entry in `rules` is a rule to generate zero or more roles with the corresponding parameters.
11 | A `role` rule is like a template.
12 | `role` rules allows to deduplicate membership and options by setting a list of names.
13 |
14 | You can mix static rules and dynamic rules in the same file.
15 |
16 | [role]: config.md#rules-role
17 |
18 |
19 | ## Running unprivileged
20 |
21 | ldap2pg is designed to run unprivileged.
22 | Synchronization user needs `CREATEROLE` option to manage other unprivileged roles.
23 | `CREATEDB` options allows synchronization user to managed database owners.
24 |
25 | ldap2pg user must have `createrole_self_grant` set to `inherit,set` to properly handle groups.
26 |
27 | ``` sql
28 | CREATE ROLE ldap2pg LOGIN CREATEDB CREATEROLE;
29 | ALTER ROLE ldap2pg SET createrole_self_grant TO 'inherit,set;
30 | ```
31 |
32 | Running unprivileged before Postgres 16 is actually flawed.
33 | You'd better just run ldap2pg with superuser privileges, you wont feel falsly secured.
34 |
35 |
36 | ## Ignoring roles
37 |
38 | ldap2pg totally ignores roles matching one of the glob pattern defined in [roles_blacklist_query]:
39 |
40 | ``` yaml
41 | postgres:
42 | # This is the default value.
43 | roles_blacklist_query: [postgres, pg_*]
44 | ```
45 |
46 | The role blacklist is also applied to grants.
47 | ldap2pg will never apply `GRANT` or `REVOKE` on a role matching one of the blacklist patterns.
48 |
49 | [roles_blacklist_query]: config.md#postgres-roles-blacklist-query
50 |
51 | ldap2pg blacklists its running user.
52 |
53 |
54 | ## Membership
55 |
56 | ldap2pg manages parents of roles.
57 | ldap2pg applies [roles_blacklist_query] to parents.
58 | However, ldap2pg grants unmanaged parents.
59 | This way, you can create a group manually and manages its members using ldap2pg.
60 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/dalibo/ldap2pg/v6
2 |
3 | go 1.24
4 |
5 | // https://pkg.go.dev/crypto/x509#ParseCertificate
6 | godebug x509negativeserial=1
7 |
8 | require (
9 | github.com/avast/retry-go/v4 v4.6.1
10 | github.com/deckarep/golang-set/v2 v2.8.0
11 | github.com/go-ldap/ldap/v3 v3.4.11
12 | github.com/gosimple/slug v1.15.0
13 | github.com/jackc/pgx/v5 v5.7.5
14 | github.com/joho/godotenv v1.5.1
15 | github.com/knadh/koanf/maps v0.1.2
16 | github.com/knadh/koanf/providers/confmap v1.0.0
17 | github.com/knadh/koanf/providers/env v1.1.0
18 | github.com/knadh/koanf/providers/posflag v1.0.0
19 | github.com/knadh/koanf/v2 v2.2.0
20 | github.com/lithammer/dedent v1.1.0
21 | github.com/lmittmann/tint v1.1.1
22 | github.com/mattn/go-isatty v0.0.20
23 | github.com/mitchellh/mapstructure v1.5.0
24 | github.com/spf13/pflag v1.0.6
25 | github.com/stretchr/testify v1.10.0
26 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
27 | gopkg.in/yaml.v3 v3.0.1
28 | )
29 |
30 | require (
31 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
33 | github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
34 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
35 | github.com/google/uuid v1.6.0 // indirect
36 | github.com/gosimple/unidecode v1.0.1 // indirect
37 | github.com/jackc/pgpassfile v1.0.0 // indirect
38 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
39 | github.com/kr/pretty v0.3.1 // indirect
40 | github.com/mitchellh/copystructure v1.2.0 // indirect
41 | github.com/mitchellh/reflectwalk v1.0.2 // indirect
42 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
43 | golang.org/x/crypto v0.38.0 // indirect
44 | golang.org/x/sys v0.33.0 // indirect
45 | golang.org/x/text v0.25.0 // indirect
46 | )
47 |
--------------------------------------------------------------------------------
/internal/cmd/exit.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import "os"
4 |
5 | // Custom error handling exit code.
6 | //
7 | // os.Exit() bypasses deferred functions.
8 | // This error allows passing exit code as an error
9 | // to execute deferred functions before exiting in caller.
10 | type errorCode struct {
11 | code int
12 | message string
13 | }
14 |
15 | func (err errorCode) Error() string {
16 | return err.message
17 | }
18 |
19 | func (err errorCode) Exit() {
20 | os.Exit(err.code)
21 | }
22 |
--------------------------------------------------------------------------------
/internal/cmd/version.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "runtime"
6 | "runtime/debug"
7 | "slices"
8 | )
9 |
10 | var (
11 | commit string
12 | Version string // set by main
13 | versions = make(map[string]string)
14 | mainDeps = []string{
15 | "github.com/jackc/pgx/v5",
16 | "github.com/go-ldap/ldap/v3",
17 | "gopkg.in/yaml.v3",
18 | }
19 | )
20 |
21 | func version() string {
22 | if Version == "" {
23 | return versions["github.com/dalibo/ldap2pg/v6"]
24 | }
25 | return Version
26 | }
27 |
28 | func init() {
29 | bi, ok := debug.ReadBuildInfo()
30 | if !ok {
31 | panic("Failed to read build information.")
32 | }
33 | for _, mod := range bi.Deps {
34 | if slices.Contains(mainDeps, mod.Path) {
35 | versions[mod.Path] = mod.Version
36 | }
37 | if len(versions) >= len(mainDeps) {
38 | break
39 | }
40 | }
41 |
42 | versions[bi.Main.Path] = bi.Main.Version
43 |
44 | for i := range bi.Settings {
45 | if bi.Settings[i].Key == "vcs.revision" {
46 | commit = bi.Settings[i].Value[:8]
47 | break
48 | }
49 | }
50 | }
51 |
52 | func showVersion() {
53 | fmt.Printf("ldap2pg %s\n", version())
54 |
55 | for _, path := range mainDeps {
56 | fmt.Printf("%s %s\n", path, versions[path])
57 | }
58 |
59 | fmt.Printf("%s %s %s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
60 | }
61 |
--------------------------------------------------------------------------------
/internal/config/config_test.go:
--------------------------------------------------------------------------------
1 | package config_test
2 |
3 | import (
4 | "flag"
5 | "log/slog"
6 | "os"
7 | "testing"
8 |
9 | "github.com/dalibo/ldap2pg/v6/internal"
10 | )
11 |
12 | func TestMain(m *testing.M) {
13 | flag.Parse()
14 | if testing.Verbose() {
15 | internal.SetLoggingHandler(slog.LevelDebug, false)
16 | } else {
17 | internal.SetLoggingHandler(slog.LevelWarn, false)
18 | }
19 | os.Exit(m.Run())
20 | }
21 |
--------------------------------------------------------------------------------
/internal/config/file.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 | "os"
8 | "path"
9 |
10 | "github.com/dalibo/ldap2pg/v6/internal/ldap"
11 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
12 | "github.com/dalibo/ldap2pg/v6/internal/privileges"
13 | "github.com/dalibo/ldap2pg/v6/internal/wanted"
14 | "github.com/jackc/pgx/v5"
15 | )
16 |
17 | func FindDotEnvFile(configpath string) string {
18 | var envpath string
19 | if configpath == "-" {
20 | cwd, err := os.Getwd()
21 | if err != nil {
22 | slog.Warn("Cannot get current working directory.", "err", err)
23 | return ""
24 | }
25 | envpath = path.Join(cwd, ".env")
26 | } else {
27 | envpath = path.Join(path.Dir(configpath), "/.env")
28 | }
29 | _, err := os.Stat(envpath)
30 | if err != nil {
31 | return ""
32 | }
33 | return envpath
34 | }
35 |
36 | func FindConfigFile(userValue string) string {
37 | home, _ := os.UserHomeDir()
38 | candidates := []string{
39 | "./ldap2pg.yml",
40 | "./ldap2pg.yaml",
41 | path.Join(home, "/.config/ldap2pg.yml"),
42 | path.Join(home, "/.config/ldap2pg.yaml"),
43 | "/etc/ldap2pg.yml",
44 | "/etc/ldap2pg.yaml",
45 | "/etc/ldap2pg/ldap2pg.yml",
46 | "/etc/ldap2pg/ldap2pg.yaml",
47 | }
48 | return FindFile(userValue, candidates)
49 | }
50 |
51 | func FindFile(userValue string, candidates []string) (configpath string) {
52 | if userValue == "-" {
53 | return ""
54 | }
55 |
56 | if userValue != "" {
57 | return userValue
58 | }
59 |
60 | slog.Debug("Searching configuration file in standard locations.")
61 |
62 | for _, candidate := range candidates {
63 | _, err := os.Stat(candidate)
64 | if err == nil {
65 | slog.Debug("Found configuration file.",
66 | "path", candidate)
67 |
68 | return candidate
69 | }
70 | slog.Debug("Ignoring configuration file.",
71 | "path", candidate,
72 | "error", err)
73 | }
74 |
75 | return ""
76 | }
77 |
78 | // Config holds the YAML configuration. Not the flags.
79 | type Config struct {
80 | Version int
81 | Ldap ldap.Config
82 | Postgres PostgresConfig
83 | ACLs map[string]privileges.ACL `mapstructure:"acls"`
84 | Privileges map[string]privileges.Profile
85 | Rules wanted.Rules `mapstructure:"rules"`
86 | }
87 |
88 | // New initiate a config structure with defaults.
89 | func New() Config {
90 | return Config{
91 | Postgres: PostgresConfig{
92 | DatabasesQuery: NewSQLQuery[string](`
93 | SELECT datname FROM pg_catalog.pg_database
94 | WHERE datallowconn IS TRUE
95 | ORDER BY 1;`, pgx.RowTo[string]),
96 | ManagedRolesQuery: NewSQLQuery[string](`
97 | SELECT 'public'
98 | UNION
99 | SELECT role.rolname
100 | FROM pg_roles AS role
101 | ORDER BY 1;`, pgx.RowTo[string]),
102 | RolesBlacklistQuery: NewYAMLQuery[string](
103 | "pg_*",
104 | "postgres",
105 | ),
106 | SchemasQuery: NewSQLQuery[postgres.Schema](`
107 | SELECT nspname, rolname
108 | FROM pg_catalog.pg_namespace
109 | JOIN pg_catalog.pg_roles ON pg_catalog.pg_roles.oid = nspowner
110 | -- Ensure ldap2pg can use.
111 | WHERE has_schema_privilege(CURRENT_USER, nspname, 'USAGE')
112 | AND nspname NOT LIKE 'pg_%'
113 | AND nspname <> 'information_schema'
114 | ORDER BY 1;`, postgres.RowToSchema),
115 | },
116 | }
117 | }
118 |
119 | func Load(path string) (Config, error) {
120 | c := New()
121 | err := c.Load(path)
122 | return c, err
123 | }
124 |
125 | func (c *Config) Load(path string) (err error) {
126 | slog.Debug("Loading YAML configuration.")
127 |
128 | yamlData, err := ReadYaml(path)
129 | if err != nil {
130 | return
131 | }
132 | err = c.checkVersion(yamlData)
133 | if err != nil {
134 | return
135 | }
136 | root, err := NormalizeConfigRoot(yamlData)
137 | if err != nil {
138 | return fmt.Errorf("bad configuration: %w", err)
139 | }
140 | if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
141 | Dump(root)
142 | }
143 | err = c.LoadYaml(root)
144 | if err != nil {
145 | return
146 | }
147 |
148 | c.Rules = c.Rules.SplitStaticRules()
149 | return
150 | }
151 |
--------------------------------------------------------------------------------
/internal/config/normalizers_test.go:
--------------------------------------------------------------------------------
1 | package config_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/config"
7 | "github.com/lithammer/dedent"
8 | "github.com/stretchr/testify/require"
9 | "gopkg.in/yaml.v3"
10 | )
11 |
12 | func TestNormalizeWantRule(t *testing.T) {
13 | r := require.New(t)
14 |
15 | rawYaml := dedent.Dedent(`
16 | description: Desc
17 | role: alice
18 | `)
19 | var raw any
20 | yaml.Unmarshal([]byte(rawYaml), &raw) //nolint:errcheck
21 |
22 | value, err := config.NormalizeWantRule(raw)
23 | r.Nil(err)
24 |
25 | _, exists := value["role"]
26 | r.False(exists, "role key must be renamed to roles")
27 |
28 | untypedRoles, exists := value["roles"]
29 | r.True(exists, "role key must be renamed to roles")
30 |
31 | roles := untypedRoles.([]any)
32 | r.Len(roles, 1)
33 | }
34 |
35 | func TestNormalizeRules(t *testing.T) {
36 | r := require.New(t)
37 |
38 | rawYaml := dedent.Dedent(`
39 | - description: Desc0
40 | role: alice
41 | - description: Desc1
42 | roles:
43 | - bob
44 | `)
45 | var raw any
46 | yaml.Unmarshal([]byte(rawYaml), &raw) //nolint:errcheck
47 |
48 | value, err := config.NormalizeRules(raw)
49 | r.Nil(err)
50 | r.Len(value, 2)
51 | }
52 |
53 | func TestNormalizeConfig(t *testing.T) {
54 | r := require.New(t)
55 |
56 | rawYaml := dedent.Dedent(`
57 | rules:
58 | - description: Desc0
59 | role: alice
60 | - description: Desc1
61 | roles:
62 | - bob
63 | `)
64 | var raw any
65 | yaml.Unmarshal([]byte(rawYaml), &raw) //nolint:errcheck
66 |
67 | config, err := config.NormalizeConfigRoot(raw)
68 | r.Nil(err)
69 | syncMap := config["rules"].([]any)
70 | r.Len(syncMap, 2)
71 | }
72 |
--------------------------------------------------------------------------------
/internal/config/privilege.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 |
7 | "golang.org/x/exp/maps"
8 | )
9 |
10 | func (c Config) RegisterPrivileges() error {
11 | for name, acl := range c.ACLs {
12 | acl.Name = name
13 | slog.Debug("Registering ACL.", "name", acl.Name)
14 | err := acl.Register()
15 | if err != nil {
16 | return fmt.Errorf("ACL: %s: %w", acl.Name, err)
17 | }
18 | }
19 | for name, profile := range c.Privileges {
20 | err := profile.Register(name)
21 | if err != nil {
22 | return fmt.Errorf("privileges: %s: %w", name, err)
23 | }
24 | }
25 | return nil
26 | }
27 |
28 | func (c *Config) DropPrivileges() {
29 | slog.Debug("Dropping privilege configuration.")
30 | maps.Clear(c.Privileges)
31 | c.Rules = c.Rules.DropGrants()
32 | }
33 |
34 | func (c Config) ArePrivilegesManaged() bool {
35 | return 0 < len(c.Privileges)
36 | }
37 |
--------------------------------------------------------------------------------
/internal/config/query.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal/inspect"
8 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
9 | "github.com/jackc/pgx/v5"
10 | "github.com/lithammer/dedent"
11 | )
12 |
13 | // PostgresConfig holds the configuration of an inspect.Config.
14 | //
15 | // This structure let mapstructure decode each query individually. The actually
16 | // Querier object is instanciated early. Use Build() method to produce the
17 | // final inspect.Config object.
18 | type PostgresConfig struct {
19 | FallbackOwner string `mapstructure:"fallback_owner"`
20 | DatabasesQuery QueryConfig[string] `mapstructure:"databases_query"`
21 | ManagedRolesQuery QueryConfig[string] `mapstructure:"managed_roles_query"`
22 | RolesBlacklistQuery QueryConfig[string] `mapstructure:"roles_blacklist_query"`
23 | SchemasQuery QueryConfig[postgres.Schema] `mapstructure:"schemas_query"`
24 | }
25 |
26 | func (c PostgresConfig) Build() inspect.Config {
27 | ic := inspect.Config{
28 | FallbackOwner: c.FallbackOwner,
29 | DatabasesQuery: c.DatabasesQuery.Querier,
30 | ManagedRolesQuery: c.ManagedRolesQuery.Querier,
31 | RolesBlacklistQuery: c.RolesBlacklistQuery.Querier,
32 | SchemasQuery: c.SchemasQuery.Querier,
33 | }
34 | return ic
35 | }
36 |
37 | type QueryConfig[T any] struct {
38 | Value any
39 | Querier inspect.Querier[T]
40 | }
41 |
42 | func NewSQLQuery[T any](sql string, rowto pgx.RowToFunc[T]) QueryConfig[T] {
43 | return QueryConfig[T]{
44 | Querier: &inspect.SQLQuery[T]{
45 | SQL: strings.TrimSpace(dedent.Dedent(sql)),
46 | RowTo: rowto,
47 | },
48 | }
49 | }
50 |
51 | func NewYAMLQuery[T any](rows ...T) QueryConfig[T] {
52 | return QueryConfig[T]{
53 | Querier: &inspect.YAMLQuery[T]{
54 | Rows: rows,
55 | },
56 | }
57 | }
58 |
59 | func (qc *QueryConfig[T]) Instantiate(rowTo pgx.RowToFunc[T], yamlTo YamlToFunc[T]) error {
60 | switch qc.Value.(type) {
61 | case string: // Plain SQL query case.
62 | qc.Querier = &inspect.SQLQuery[T]{
63 | SQL: strings.TrimSpace(qc.Value.(string)),
64 | RowTo: rowTo,
65 | }
66 | case []any: // YAML values case.
67 | rawList := qc.Value.([]any)
68 | rows := make([]T, 0)
69 | for _, rawRow := range rawList {
70 | row, err := yamlTo(rawRow)
71 | if err != nil {
72 | return fmt.Errorf("bad value: %w", err)
73 | }
74 | rows = append(rows, row)
75 | }
76 | qc.Querier = &inspect.YAMLQuery[T]{
77 | Rows: rows,
78 | }
79 | default:
80 | return fmt.Errorf("bad query")
81 | }
82 | return nil
83 | }
84 |
85 | type YamlToFunc[T any] func(row any) (T, error)
86 |
87 | func YamlTo[T any](raw any) (T, error) {
88 | return raw.(T), nil
89 | }
90 |
--------------------------------------------------------------------------------
/internal/config/role.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/normalize"
9 | "golang.org/x/exp/maps"
10 | )
11 |
12 | func NormalizeRoleRule(yaml any) (rule map[string]any, err error) {
13 | rule = map[string]any{
14 | "comment": "Managed by ldap2pg",
15 | "options": map[string]any{},
16 | "parents": []string{},
17 | }
18 |
19 | switch yaml := yaml.(type) {
20 | case string:
21 | rule["names"] = []string{yaml}
22 | case map[string]any:
23 | err = normalize.Alias(yaml, "names", "name")
24 | if err != nil {
25 | return
26 | }
27 | err = normalize.Alias(yaml, "parents", "parent")
28 | if err != nil {
29 | return
30 | }
31 |
32 | maps.Copy(rule, yaml)
33 |
34 | names, ok := rule["names"]
35 | if ok {
36 | rule["names"], err = normalize.StringList(names)
37 | if err != nil {
38 | return
39 | }
40 | } else {
41 | return nil, errors.New("missing name")
42 | }
43 | rule["parents"], err = NormalizeMemberships(rule["parents"])
44 | if err != nil {
45 | return
46 | }
47 | rule["options"], err = NormalizeRoleOptions(rule["options"])
48 | if err != nil {
49 | return nil, fmt.Errorf("options: %w", err)
50 | }
51 | default:
52 | return nil, fmt.Errorf("bad type: %T", yaml)
53 | }
54 |
55 | err = normalize.SpuriousKeys(rule, "names", "comment", "parents", "options", "config", "before_create", "after_create")
56 | return
57 | }
58 |
59 | // Normalize one rule with a list of names to a list of rules with a single
60 | // name.
61 | func DuplicateRoleRules(yaml map[string]any) (rules []map[string]any) {
62 | for _, name := range yaml["names"].([]string) {
63 | rule := make(map[string]any)
64 | rule["name"] = name
65 | for key, value := range yaml {
66 | if key == "names" {
67 | continue
68 | }
69 | rule[key] = value
70 | }
71 | rules = append(rules, rule)
72 | }
73 | return
74 | }
75 |
76 | func NormalizeRoleOptions(yaml any) (value map[string]any, err error) {
77 | // Normal form of role options is a map with SQL token as key and
78 | // boolean or int value.
79 | value = map[string]any{
80 | "SUPERUSER": false,
81 | "INHERIT": true,
82 | "CREATEROLE": false,
83 | "CREATEDB": false,
84 | "LOGIN": false,
85 | "REPLICATION": false,
86 | "BYPASSRLS": false,
87 | "CONNECTION LIMIT": -1,
88 | }
89 | knownKeys := maps.Keys(value)
90 |
91 | switch yaml := yaml.(type) {
92 | case string:
93 | tokens := strings.Split(yaml, " ")
94 | for _, token := range tokens {
95 | if token == "" {
96 | continue
97 | }
98 | value[strings.TrimPrefix(token, "NO")] = !strings.HasPrefix(token, "NO")
99 | }
100 | case map[string]any:
101 | for k, v := range yaml {
102 | yaml[k] = normalize.Boolean(v)
103 | }
104 | maps.Copy(value, yaml)
105 | case nil:
106 | return
107 | default:
108 | return nil, fmt.Errorf("bad type: %T", yaml)
109 | }
110 |
111 | err = normalize.SpuriousKeys(value, knownKeys...)
112 | return
113 | }
114 |
115 | func NormalizeMemberships(raw any) (memberships []map[string]any, err error) {
116 | list := normalize.List(raw)
117 | memberships = make([]map[string]any, 0, len(list))
118 | for i, raw := range list {
119 | membership, err := NormalizeMembership(raw)
120 | if err != nil {
121 | return nil, fmt.Errorf("parents[%d]: %w", i, err)
122 | }
123 | memberships = append(memberships, membership)
124 | }
125 | return
126 | }
127 |
128 | func NormalizeMembership(raw any) (value map[string]any, err error) {
129 | value = make(map[string]any)
130 | // We could add admin, inherit and set to the map
131 |
132 | switch raw := raw.(type) {
133 | case string:
134 | value["name"] = raw
135 | case map[string]any:
136 | for k, v := range raw {
137 | value[k] = normalize.Boolean(v)
138 | }
139 | default:
140 | return nil, fmt.Errorf("bad type: %T", raw)
141 | }
142 |
143 | if _, ok := value["name"]; !ok {
144 | return nil, errors.New("missing name")
145 | }
146 |
147 | err = normalize.SpuriousKeys(value, "name", "inherit", "set", "admin")
148 | return
149 | }
150 |
--------------------------------------------------------------------------------
/internal/config/role_test.go:
--------------------------------------------------------------------------------
1 | package config_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/config"
7 | "github.com/lithammer/dedent"
8 | "github.com/stretchr/testify/require"
9 | "gopkg.in/yaml.v3"
10 | )
11 |
12 | func TestRoleRulesString(t *testing.T) {
13 | r := require.New(t)
14 |
15 | value, err := config.NormalizeRoleRule("alice")
16 | r.Nil(err)
17 |
18 | names, ok := value["names"].([]string)
19 | r.True(ok)
20 | r.Equal(1, len(names))
21 | r.Equal("alice", names[0])
22 | }
23 |
24 | func TestRoleRulesSingle(t *testing.T) {
25 | r := require.New(t)
26 |
27 | rawYaml := dedent.Dedent(`
28 | name: alice
29 | `)
30 | var raw any
31 | yaml.Unmarshal([]byte(rawYaml), &raw) //nolint:errcheck
32 |
33 | value, err := config.NormalizeRoleRule(raw)
34 | r.Nil(err)
35 |
36 | rawNames, ok := value["names"]
37 | r.True(ok)
38 | names := rawNames.([]string)
39 | r.Equal(1, len(names))
40 | r.Equal("alice", names[0])
41 | r.Equal("Managed by ldap2pg", value["comment"])
42 | }
43 |
44 | func TestRolesComment(t *testing.T) {
45 | r := require.New(t)
46 |
47 | rawYaml := dedent.Dedent(`
48 | name: alice
49 | comment: au pays des merveilles.
50 | `)
51 | var raw any
52 | yaml.Unmarshal([]byte(rawYaml), &raw) //nolint:errcheck
53 |
54 | value, err := config.NormalizeRoleRule(raw)
55 | r.Nil(err)
56 | r.Equal([]string{"alice"}, value["names"])
57 | r.Equal("au pays des merveilles.", value["comment"])
58 | }
59 |
60 | func TestRoleOptionsString(t *testing.T) {
61 | r := require.New(t)
62 |
63 | raw := any("SUPERUSER LOGIN")
64 |
65 | value, err := config.NormalizeRoleOptions(raw)
66 | r.Nil(err)
67 | r.True(value["SUPERUSER"].(bool))
68 | r.True(value["LOGIN"].(bool))
69 | }
70 |
71 | func TestRoleParents(t *testing.T) {
72 | r := require.New(t)
73 |
74 | rawYaml := dedent.Dedent(`
75 | name: toto
76 | parents: groupe
77 | `)
78 | var raw any
79 | yaml.Unmarshal([]byte(rawYaml), &raw) //nolint:errcheck
80 |
81 | value, err := config.NormalizeRoleRule(raw)
82 | r.Nil(err)
83 | r.Len(value["parents"], 1)
84 | }
85 |
86 | func TestMembership(t *testing.T) {
87 | r := require.New(t)
88 |
89 | membership, err := config.NormalizeMembership("owners")
90 | r.Nil(err)
91 | r.Equal("owners", membership["name"])
92 |
93 | rawYaml := dedent.Dedent(`
94 | name: owners
95 | `)
96 | var raw any
97 | yaml.Unmarshal([]byte(rawYaml), &raw) //nolint:errcheck
98 |
99 | membership, err = config.NormalizeMembership(raw)
100 | r.Nil(err)
101 | r.Equal("owners", membership["name"])
102 | }
103 |
--------------------------------------------------------------------------------
/internal/config/yaml.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "log/slog"
9 | "os"
10 | "reflect"
11 |
12 | "github.com/dalibo/ldap2pg/v6/internal/ldap"
13 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
14 | "github.com/dalibo/ldap2pg/v6/internal/pyfmt"
15 | "github.com/jackc/pgx/v5"
16 | "github.com/mattn/go-isatty"
17 | "github.com/mitchellh/mapstructure"
18 | "gopkg.in/yaml.v3"
19 | )
20 |
21 | // Marshall YAML from file path or stdin if path is -.
22 | func ReadYaml(path string) (values any, err error) {
23 | var fo io.ReadCloser
24 | if path == "" {
25 | slog.Info("Reading configuration from standard input.")
26 | fo = os.Stdin
27 | } else {
28 | fo, err = os.Open(path)
29 | if err != nil {
30 | return
31 | }
32 | }
33 | defer fo.Close()
34 | dec := yaml.NewDecoder(fo)
35 | err = dec.Decode(&values)
36 | return
37 | }
38 |
39 | // Fill configuration from YAML data.
40 | func (c *Config) LoadYaml(root map[string]any) (err error) {
41 | err = c.DecodeYaml(root)
42 | if err != nil {
43 | return
44 | }
45 |
46 | for i := range c.Rules {
47 | item := &c.Rules[i]
48 | item.InferAttributes()
49 | // states.ComputeWanted is simplified base on the assumption
50 | // there is no more than one sub-search. Fail otherwise.
51 | if 1 < len(item.LdapSearch.Subsearches) {
52 | err = fmt.Errorf("multiple sub-search unsupported")
53 | return
54 | }
55 | item.ReplaceAttributeAsSubentryField()
56 | }
57 |
58 | slog.Debug("Loaded configuration file.", "version", c.Version)
59 | return
60 | }
61 |
62 | func Dump(root any) {
63 | var buf bytes.Buffer
64 | encoder := yaml.NewEncoder(&buf)
65 | encoder.SetIndent(2)
66 | _ = encoder.Encode(root)
67 | encoder.Close()
68 | color := isatty.IsTerminal(os.Stderr.Fd())
69 | slog.Debug("Dumping normalized YAML to stderr.")
70 | if color {
71 | os.Stderr.WriteString("\033[0;2m")
72 | }
73 | os.Stderr.WriteString(buf.String())
74 | if color {
75 | os.Stderr.WriteString("\033[0m")
76 | }
77 | }
78 |
79 | // Wrap mapstructure for config object
80 | func (c *Config) DecodeYaml(yaml any) (err error) {
81 | d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
82 | DecodeHook: decodeMapHook,
83 | Metadata: &mapstructure.Metadata{},
84 | Result: c,
85 | WeaklyTypedInput: true,
86 | })
87 | if err != nil {
88 | return
89 | }
90 | err = d.Decode(yaml)
91 | return
92 | }
93 |
94 | // Decode custom types for mapstructure. Implements mapstructure.DecodeHookFuncValue.
95 | func decodeMapHook(from, to reflect.Value) (any, error) {
96 | switch to.Type() {
97 | case reflect.TypeOf(pyfmt.Format{}):
98 | f := to.Interface().(pyfmt.Format)
99 | err := f.Parse(from.String())
100 | if err != nil {
101 | return nil, err
102 | }
103 | return f, nil
104 | case reflect.TypeOf(QueryConfig[string]{}):
105 | v := to.Interface().(QueryConfig[string])
106 | v.Value = from.Interface()
107 | err := v.Instantiate(pgx.RowTo[string], YamlTo[string])
108 | if err != nil {
109 | return nil, err
110 | }
111 | return v, nil
112 | case reflect.TypeOf(QueryConfig[postgres.Schema]{}):
113 | v := to.Interface().(QueryConfig[postgres.Schema])
114 | v.Value = from.Interface()
115 | err := v.Instantiate(postgres.RowToSchema, postgres.YamlToSchema)
116 | if err != nil {
117 | return nil, err
118 | }
119 | return v, nil
120 | case reflect.TypeOf(ldap.Scope(1)):
121 | s, err := ldap.ParseScope(from.String())
122 | if err != nil {
123 | return from.Interface(), err
124 | }
125 | return s, nil
126 | }
127 | return from.Interface(), nil
128 | }
129 |
130 | func (c *Config) checkVersion(yaml any) (err error) {
131 | yamlMap, ok := yaml.(map[string]any)
132 | if !ok {
133 | return errors.New("YAML is not a map")
134 | }
135 | version, ok := yamlMap["version"]
136 | if !ok {
137 | slog.Debug("Fallback to version 5.")
138 | version = 5
139 | }
140 | c.Version, ok = version.(int)
141 | if !ok {
142 | return errors.New("configuration version must be integer")
143 | }
144 | if c.Version != 6 {
145 | slog.Debug("Unsupported configuration version. Minimum version is 6.", "version", c.Version)
146 | return errors.New("configuration version must be 6")
147 | }
148 | return
149 | }
150 |
--------------------------------------------------------------------------------
/internal/config/yaml_test.go:
--------------------------------------------------------------------------------
1 | package config_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/config"
7 | "github.com/lithammer/dedent"
8 | "github.com/stretchr/testify/require"
9 | "gopkg.in/yaml.v3"
10 | )
11 |
12 | func TestLoadPrivilege(t *testing.T) {
13 | r := require.New(t)
14 |
15 | rawYaml := dedent.Dedent(`
16 | privileges:
17 | ro:
18 | - type: CONNECT
19 | on: DATABASE
20 | `)
21 | var value map[string]any
22 | yaml.Unmarshal([]byte(rawYaml), &value) //nolint:errcheck
23 |
24 | c := config.New()
25 | err := c.LoadYaml(value)
26 | r.Nil(err)
27 | r.Len(c.Privileges, 1)
28 | r.Contains(c.Privileges, "ro")
29 | p := c.Privileges["ro"]
30 | r.Len(p, 1)
31 | r.Equal("CONNECT", p[0].Type)
32 | r.Equal("DATABASE", p[0].On)
33 | }
34 |
--------------------------------------------------------------------------------
/internal/errorlist/errorlist.go:
--------------------------------------------------------------------------------
1 | package errorlist
2 |
3 | var maxErrors = 8
4 |
5 | type List struct {
6 | errors []error
7 | message string
8 | }
9 |
10 | type joinedErrors interface {
11 | Unwrap() []error
12 | }
13 |
14 | func New(message string) *List {
15 | return &List{message: message}
16 | }
17 |
18 | func (list List) Error() string {
19 | return list.message
20 | }
21 |
22 | func (list List) Unwrap() []error {
23 | return list.errors
24 | }
25 |
26 | // Append a single error to the list
27 | //
28 | // use Append to continue after an error up to a number of continuable errors.
29 | //
30 | // Return false when list is full.
31 | // Panics if error wraps multiple errors.
32 | // Use Extend() to append joined errors.
33 | func (list *List) Append(err error) bool {
34 | if _, ok := err.(joinedErrors); ok {
35 | panic("errorlist: cannot append agreggated error")
36 | }
37 | if err != nil {
38 | list.errors = append(list.errors, err)
39 | }
40 | return list.Len() < maxErrors
41 | }
42 |
43 | // Extend list with wrapped errors.
44 | //
45 | // Use Extend to aggregate skippable joined errors or fail fast on single error.
46 | //
47 | // Return nil if list has free slots.
48 | // Return err as is if it's a single error.
49 | // Return self if list is full.
50 | func (list *List) Extend(err error) error {
51 | if errs, ok := err.(joinedErrors); ok {
52 | list.errors = append(list.errors, errs.Unwrap()...)
53 | } else {
54 | return err
55 | }
56 |
57 | if list.Len() >= maxErrors {
58 | return list
59 | }
60 | return nil
61 | }
62 |
63 | func (list List) Len() int {
64 | return len(list.errors)
65 | }
66 |
--------------------------------------------------------------------------------
/internal/errorlist/errorlist_test.go:
--------------------------------------------------------------------------------
1 | package errorlist_test
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "testing"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/errorlist"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | var numError = 0
13 |
14 | func TestErrorListExtend(t *testing.T) {
15 | r := require.New(t)
16 | list := errorlist.New("test extend")
17 |
18 | r.Nil(list.Extend(nil))
19 |
20 | errs := errors.Join(buildErrors(2)...)
21 | r.Nil(list.Extend(errs))
22 | r.Equal(2, list.Len())
23 |
24 | errs2 := errors.Join(buildErrors(6)...)
25 | r.NotNil(list.Extend(errs2))
26 | r.Equal(8, list.Len())
27 |
28 | unaggregateError := errors.New("unaggregate error")
29 | r.NotNil(list.Extend(unaggregateError))
30 | }
31 |
32 | func TestErrorListAppend(t *testing.T) {
33 | r := require.New(t)
34 | list := errorlist.New("test append")
35 |
36 | r.True(list.Append(nil))
37 |
38 | errs := errors.Join(buildErrors(7)...)
39 | r.Panics(func() { list.Append(errs) })
40 | for _, err := range errs.(interface{ Unwrap() []error }).Unwrap() {
41 | r.True(list.Append(err))
42 | }
43 | r.Equal(7, list.Len())
44 |
45 | r.False(list.Append(errors.New("error 8")))
46 | r.Equal(8, list.Len())
47 | }
48 |
49 | func buildErrors(n int) []error {
50 | var errors []error
51 | for i := 1; i <= n; i++ {
52 | err := fmt.Errorf("error %d", numError+i)
53 | errors = append(errors, err)
54 | }
55 | return errors
56 | }
57 |
--------------------------------------------------------------------------------
/internal/inspect/config.go:
--------------------------------------------------------------------------------
1 | package inspect
2 |
3 | import (
4 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
5 | )
6 |
7 | type Config struct {
8 | FallbackOwner string
9 | DatabasesQuery Querier[string]
10 | ManagedRolesQuery Querier[string]
11 | RolesBlacklistQuery Querier[string]
12 | SchemasQuery Querier[postgres.Schema]
13 | }
14 |
--------------------------------------------------------------------------------
/internal/inspect/inspect_test.go:
--------------------------------------------------------------------------------
1 | package inspect_test
2 |
3 | import (
4 | "log/slog"
5 | "testing"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal"
8 | "github.com/stretchr/testify/suite"
9 | )
10 |
11 | type Suite struct {
12 | suite.Suite
13 | }
14 |
15 | func TestConfig(t *testing.T) {
16 | if testing.Verbose() {
17 | internal.SetLoggingHandler(slog.LevelDebug, false)
18 | } else {
19 | internal.SetLoggingHandler(slog.LevelWarn, false)
20 | }
21 | suite.Run(t, new(Suite))
22 | }
23 |
--------------------------------------------------------------------------------
/internal/inspect/query.go:
--------------------------------------------------------------------------------
1 | package inspect
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/perf"
9 | "github.com/jackc/pgx/v5"
10 | )
11 |
12 | var Watch perf.StopWatch
13 |
14 | // Querier abstracts the execution of a SQL query or the copy of static rows
15 | // from YAML.
16 | type Querier[T any] interface {
17 | Query(context.Context, Conn)
18 | Next() bool
19 | Err() error
20 | Row() T
21 | }
22 |
23 | // Conn allows to inject a mock.
24 | type Conn interface {
25 | Query(context.Context, string, ...any) (pgx.Rows, error)
26 | }
27 |
28 | // YAMLQuery holds a static rowset from config file
29 | type YAMLQuery[T any] struct {
30 | Rows []T
31 | currentIndex int
32 | }
33 |
34 | func (q *YAMLQuery[_]) Query(_ context.Context, _ Conn) {
35 | q.currentIndex = -1
36 | slog.Debug("Reading values from YAML.")
37 | }
38 |
39 | func (q *YAMLQuery[_]) Next() bool {
40 | q.currentIndex++
41 | return q.currentIndex < len(q.Rows)
42 | }
43 |
44 | func (q *YAMLQuery[_]) Err() error {
45 | return nil
46 | }
47 |
48 | func (q *YAMLQuery[T]) Row() T {
49 | return q.Rows[q.currentIndex]
50 | }
51 |
52 | // SQLQuery holds a configurable SQL query en handle fetching rows from
53 | // Postgres.
54 | // *SQLQuery implements Querier.
55 | type SQLQuery[T any] struct {
56 | SQL string
57 | RowTo pgx.RowToFunc[T]
58 |
59 | rows pgx.Rows
60 | err error
61 | row T
62 | }
63 |
64 | func (q *SQLQuery[_]) Query(ctx context.Context, pgconn Conn) {
65 | slog.Debug("Executing SQL query:\n" + q.SQL)
66 | var rows pgx.Rows
67 | var err error
68 | Watch.TimeIt(func() {
69 | rows, err = pgconn.Query(ctx, q.SQL)
70 | })
71 | if err != nil {
72 | q.err = fmt.Errorf("bad query: %w", err)
73 | return
74 | }
75 | q.rows = rows
76 | }
77 |
78 | func (q *SQLQuery[_]) Next() bool {
79 | if q.err != nil {
80 | return false
81 | }
82 | next := q.rows.Next()
83 | if !next {
84 | q.err = q.rows.Err()
85 | return false
86 | }
87 | q.row, q.err = q.RowTo(q.rows)
88 | if q.err != nil {
89 | return false
90 | }
91 | return next
92 | }
93 |
94 | func (q *SQLQuery[_]) Err() error {
95 | return q.err
96 | }
97 |
98 | func (q *SQLQuery[T]) Row() T {
99 | return q.row
100 | }
101 |
--------------------------------------------------------------------------------
/internal/inspect/query_test.go:
--------------------------------------------------------------------------------
1 | package inspect_test
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/inspect"
7 | "github.com/jackc/pgx/v5"
8 | "github.com/jackc/pgx/v5/pgconn"
9 | )
10 |
11 | func (suite *Suite) TestQuerierYAML() {
12 | r := suite.Require()
13 | var q inspect.Querier[string] = &inspect.YAMLQuery[string]{
14 | Rows: []string{"adam", "eve"},
15 | }
16 |
17 | names := make([]string, 0)
18 | for q.Query(context.TODO(), nil); q.Next(); {
19 | names = append(names, q.Row())
20 | }
21 | r.Nil(q.Err())
22 | r.Equal(2, len(names))
23 | r.Equal("adam", names[0])
24 | r.Equal("eve", names[1])
25 | }
26 |
27 | func (suite *Suite) TestQuerierSQL() {
28 | r := suite.Require()
29 | // Check implementation by using interface as variable type.
30 | var q inspect.Querier[string] = &inspect.SQLQuery[string]{
31 | SQL: "SELECT",
32 | RowTo: pgx.RowTo[string],
33 | }
34 |
35 | c := &MockConn{Rows: []string{"adam", "eve"}}
36 | names := make([]string, 0)
37 | for q.Query(context.TODO(), c); q.Next(); {
38 | names = append(names, q.Row())
39 | }
40 | r.Nil(q.Err())
41 | r.Equal(2, len(names))
42 | r.Equal("adam", names[0])
43 | r.Equal("eve", names[1])
44 | }
45 |
46 | // MockConn implements inspect.Conn and pgx.Rows to be usable by inspect.SQLQuery.
47 | type MockConn struct {
48 | Rows []string
49 |
50 | currentIndex int
51 | }
52 |
53 | func (c *MockConn) Query(_ context.Context, _ string, _ ...any) (pgx.Rows, error) {
54 | c.currentIndex = -1
55 | return pgx.Rows(c), nil
56 | }
57 |
58 | func (c *MockConn) Next() bool {
59 | c.currentIndex++
60 | return c.currentIndex < len(c.Rows)
61 | }
62 |
63 | func (c *MockConn) Scan(dest ...any) error {
64 | dest0 := dest[0].(*string)
65 | *dest0 = c.Rows[c.currentIndex]
66 | return nil
67 | }
68 |
69 | // Unused API.
70 | func (c *MockConn) Values() ([]any, error) {
71 | return nil, nil
72 | }
73 |
74 | func (c *MockConn) Close() {
75 | }
76 |
77 | func (c *MockConn) Err() error {
78 | return nil
79 | }
80 |
81 | func (c *MockConn) CommandTag() pgconn.CommandTag {
82 | return pgconn.NewCommandTag("mock")
83 | }
84 |
85 | func (c *MockConn) FieldDescriptions() []pgconn.FieldDescription {
86 | return nil
87 | }
88 |
89 | func (c *MockConn) RawValues() [][]byte {
90 | return nil
91 | }
92 |
93 | func (c *MockConn) Conn() *pgx.Conn {
94 | return nil
95 | }
96 |
--------------------------------------------------------------------------------
/internal/inspect/sql/creators.sql:
--------------------------------------------------------------------------------
1 | SELECT nspname, array_agg(rolname ORDER BY rolname) AS creators
2 | FROM pg_catalog.pg_namespace AS nsp
3 | CROSS JOIN pg_catalog.pg_roles AS creator
4 | WHERE has_schema_privilege(creator.oid, nsp.oid, 'CREATE')
5 | AND rolcanlogin
6 | GROUP BY nspname;
7 |
--------------------------------------------------------------------------------
/internal/inspect/sql/databases.sql:
--------------------------------------------------------------------------------
1 | SELECT datname, rolname
2 | FROM pg_catalog.pg_database
3 | JOIN pg_catalog.pg_roles
4 | ON pg_catalog.pg_roles.oid = datdba
5 | -- Ensure ldap2pg can reassign to owner.
6 | WHERE pg_has_role(CURRENT_USER, datdba, 'USAGE')
7 | ORDER BY 1;
8 |
--------------------------------------------------------------------------------
/internal/inspect/sql/role-columns.sql:
--------------------------------------------------------------------------------
1 | SELECT attrs.attname
2 | FROM pg_catalog.pg_namespace AS nsp
3 | JOIN pg_catalog.pg_class AS tables
4 | ON tables.relnamespace = nsp.oid AND tables.relname = 'pg_roles'
5 | JOIN pg_catalog.pg_attribute AS attrs
6 | ON attrs.attrelid = tables.oid AND attrs.attname LIKE 'rol%'
7 | WHERE nsp.nspname = 'pg_catalog'
8 | AND attrs.attname NOT IN ('rolconfig', 'rolname', 'rolpassword', 'rolvaliduntil')
9 | ORDER BY 1
10 |
--------------------------------------------------------------------------------
/internal/inspect/sql/roles.sql:
--------------------------------------------------------------------------------
1 | WITH me AS (
2 | SELECT * FROM pg_catalog.pg_roles
3 | WHERE rolname = CURRENT_USER
4 | ), memberships AS (
5 | SELECT ms.member AS member,
6 | p.rolname AS "name",
7 | g.rolname AS "grantor"
8 | FROM pg_auth_members AS ms
9 | JOIN pg_roles AS p ON p.oid = ms.roleid
10 | JOIN pg_roles AS g ON g.oid = ms.grantor
11 | )
12 | SELECT rol.rolname,
13 | -- Encapsulate columns variation in a sub-row.
14 | row(rol.*) AS opt,
15 | COALESCE(pg_catalog.shobj_description(rol.oid, 'pg_authid'), '') AS comment,
16 | -- Postgres 16 allows: json_arrayagg(memberships.* ORDER BY 2 ABSENT ON NULL)::jsonb AS parents,
17 | -- may return {NULL}, array_remove can't compare json object.
18 | array_agg(to_json(memberships.*)) AS parents,
19 | rol.rolconfig AS config
20 | FROM me
21 | CROSS JOIN pg_catalog.pg_roles AS rol
22 | LEFT OUTER JOIN memberships ON memberships.member = rol.oid
23 | WHERE NOT (rol.rolsuper AND NOT me.rolsuper)
24 | GROUP BY 1, 2, 3, 5
25 | ORDER BY 1
26 |
--------------------------------------------------------------------------------
/internal/inspect/sql/schemas.sql:
--------------------------------------------------------------------------------
1 | SELECT nspname, rolname
2 | FROM pg_catalog.pg_namespace
3 | JOIN pg_catalog.pg_roles ON pg_catalog.pg_roles.oid = nspowner
4 | -- Ensure ldap2pg can use.
5 | WHERE has_schema_privilege(CURRENT_USER, nspname, 'USAGE')
6 | ORDER BY 1;
7 |
--------------------------------------------------------------------------------
/internal/inspect/sql/session.sql:
--------------------------------------------------------------------------------
1 | WITH me AS (
2 | SELECT
3 | rolname AS "current_user",
4 | rolsuper AS "issuper"
5 | FROM pg_catalog.pg_roles
6 | WHERE rolname = CURRENT_USER
7 | ),
8 | postgres AS (
9 | SELECT
10 | substring(version() from '^[^ ]+ [^ ]+') AS server_version,
11 | current_setting('server_version_num')::BIGINT AS server_version_num,
12 | current_setting('cluster_name') AS cluster_name,
13 | current_database() AS current_database
14 | )
15 | SELECT
16 | postgres.*,
17 | me.*
18 | FROM postgres, me;
19 |
--------------------------------------------------------------------------------
/internal/inspect/stage0.go:
--------------------------------------------------------------------------------
1 | package inspect
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/lists"
9 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
10 | "github.com/dalibo/ldap2pg/v6/internal/role"
11 | mapset "github.com/deckarep/golang-set/v2"
12 | "github.com/jackc/pgx/v5"
13 | "golang.org/x/exp/slices"
14 | )
15 |
16 | // Fourzitou struct holding everything need to synchronize Instance.
17 | type Instance struct {
18 | AllRoles role.Map
19 | DefaultDatabase string
20 | FallbackOwner string
21 | ManagedDatabases mapset.Set[string]
22 | ManagedRoles role.Map
23 | Me role.Role
24 | RolesBlacklist lists.Blacklist
25 | }
26 |
27 | func Stage0(ctx context.Context, pc Config) (instance Instance, err error) {
28 | slog.Debug("Stage 0: role blacklist.")
29 | instance = Instance{}
30 |
31 | err = instance.InspectSession(ctx, pc.FallbackOwner)
32 | if err != nil {
33 | return instance, fmt.Errorf("session: %w", err)
34 | }
35 |
36 | slog.Debug("Inspecting roles blacklist.", "config", "roles_blacklist_query")
37 | conn, err := postgres.GetConn(ctx, "")
38 | if err != nil {
39 | return instance, err
40 | }
41 |
42 | for pc.RolesBlacklistQuery.Query(ctx, conn); pc.RolesBlacklistQuery.Next(); {
43 | instance.RolesBlacklist = append(instance.RolesBlacklist, pc.RolesBlacklistQuery.Row())
44 | }
45 | if err := pc.RolesBlacklistQuery.Err(); err != nil {
46 | return instance, fmt.Errorf("roles_blacklist_query: %w", err)
47 | }
48 | if !slices.Contains(instance.RolesBlacklist, instance.Me.Name) {
49 | slog.Debug("Blacklisting self.")
50 | instance.RolesBlacklist = append(instance.RolesBlacklist, instance.Me.Name)
51 | }
52 | err = instance.RolesBlacklist.Check()
53 | if err != nil {
54 | return instance, fmt.Errorf("roles_blacklist_query: %w", err)
55 | }
56 | slog.Debug("Roles blacklist loaded.", "patterns", instance.RolesBlacklist)
57 |
58 | return
59 | }
60 |
61 | func (instance *Instance) InspectSession(ctx context.Context, fallbackOwner string) error {
62 | pgconn, err := postgres.GetConn(ctx, "")
63 | if err != nil {
64 | return err
65 | }
66 |
67 | slog.Debug("Inspecting PostgreSQL server and session.")
68 | slog.Debug("Executing SQL query:\n" + sessionQuery)
69 | var rows pgx.Rows
70 | Watch.TimeIt(func() {
71 | rows, err = pgconn.Query(ctx, sessionQuery)
72 | })
73 | if err != nil {
74 | return err
75 | }
76 | if !rows.Next() {
77 | panic("No data returned.")
78 | }
79 | var clusterName, serverVersion string
80 | var serverVersionNum int
81 | err = rows.Scan(
82 | &serverVersion, &serverVersionNum,
83 | &clusterName, &instance.DefaultDatabase,
84 | &instance.Me.Name, &instance.Me.Options.Super,
85 | )
86 | if err != nil {
87 | return err
88 | }
89 |
90 | var msg string
91 | if instance.Me.Options.Super {
92 | msg = "Running as superuser."
93 | } else if serverVersionNum < 160000 {
94 | slog.Warn("Running as unprivileged user on Postgres 15 and lower.", "version", serverVersion)
95 | slog.Warn("Unprivileged user is flawed before Postgres 16.")
96 | slog.Warn("Upgrade to Postgres 16 or later, switch to superuser or stick to ldap2pg 6.0.")
97 | return fmt.Errorf("unprivileged user on pre-16 Postgres")
98 | } else {
99 | msg = "Running as unprivileged user."
100 | }
101 | slog.Info(
102 | msg,
103 | "user", instance.Me.Name,
104 | "super", instance.Me.Options.Super,
105 | "server", serverVersion,
106 | "cluster", clusterName,
107 | "database", instance.DefaultDatabase,
108 | )
109 | if rows.Next() {
110 | panic("Multiple row returned.")
111 | }
112 | if fallbackOwner == "" {
113 | instance.FallbackOwner = instance.Me.Name
114 | } else {
115 | instance.FallbackOwner = fallbackOwner
116 | }
117 | slog.Debug("Fallback owner configured.", "role", instance.FallbackOwner)
118 |
119 | return nil
120 | }
121 |
--------------------------------------------------------------------------------
/internal/inspect/stage1.go:
--------------------------------------------------------------------------------
1 | package inspect
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 | "strings"
8 |
9 | _ "embed"
10 |
11 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
12 | "github.com/dalibo/ldap2pg/v6/internal/role"
13 | mapset "github.com/deckarep/golang-set/v2"
14 | "github.com/jackc/pgx/v5"
15 | )
16 |
17 | var (
18 | //go:embed sql/databases.sql
19 | databasesQuery string
20 | //go:embed sql/role-columns.sql
21 | roleColumnsQuery string
22 | //go:embed sql/roles.sql
23 | rolesQuery string
24 | //go:embed sql/session.sql
25 | sessionQuery string
26 | )
27 |
28 | func (instance *Instance) InspectStage1(ctx context.Context, pc Config) (err error) {
29 | slog.Debug("Stage 1: roles.")
30 | instance.ManagedDatabases = mapset.NewSet[string]()
31 |
32 | pgconn, err := postgres.GetConn(ctx, "")
33 | if err != nil {
34 | return
35 | }
36 |
37 | err = instance.InspectManagedDatabases(ctx, pgconn, pc.DatabasesQuery)
38 | if err != nil {
39 | return fmt.Errorf("databases: %w", err)
40 | }
41 |
42 | err = instance.InspectRoles(ctx, pgconn, pc.ManagedRolesQuery)
43 | if err != nil {
44 | return fmt.Errorf("roles: %w", err)
45 | }
46 | return
47 | }
48 |
49 | func (instance *Instance) InspectManagedDatabases(ctx context.Context, pgconn *pgx.Conn, q Querier[string]) error {
50 | slog.Debug("Inspecting managed databases.", "config", "databases_query")
51 | for q.Query(ctx, pgconn); q.Next(); {
52 | instance.ManagedDatabases.Add(q.Row())
53 | }
54 | if err := q.Err(); err != nil {
55 | return err
56 | }
57 |
58 | slog.Debug("Inspecting database owners.")
59 | postgres.Databases = make(postgres.DBMap)
60 | dbq := &SQLQuery[postgres.Database]{SQL: databasesQuery, RowTo: postgres.RowToDatabase}
61 | for dbq.Query(ctx, pgconn); dbq.Next(); {
62 | db := dbq.Row()
63 | if instance.ManagedDatabases.Contains(db.Name) {
64 | slog.Debug("Found database.", "name", db.Name, "owner", db.Owner)
65 | postgres.Databases[db.Name] = db
66 | } else {
67 | slog.Debug("Ignoring unmanaged database.", "name", db.Name)
68 | }
69 | }
70 | if err := dbq.Err(); err != nil {
71 | return err
72 | }
73 |
74 | _, ok := postgres.Databases[instance.DefaultDatabase]
75 | if !ok {
76 | return fmt.Errorf("default database not listed")
77 | }
78 | return nil
79 | }
80 |
81 | func (instance *Instance) InspectRoles(ctx context.Context, pgconn *pgx.Conn, managedRolesQ Querier[string]) error {
82 | slog.Debug("Inspecting roles options.")
83 | var columns []string
84 | q := &SQLQuery[string]{SQL: roleColumnsQuery, RowTo: pgx.RowTo[string]}
85 | for q.Query(ctx, pgconn); q.Next(); {
86 | columns = append(columns, q.Row())
87 | }
88 | if err := q.Err(); err != nil {
89 | return fmt.Errorf("columns: %w", err)
90 | }
91 | // Setup global var to configure RoleOptions.String()
92 | columns = role.ProcessColumns(columns, instance.Me.Options.Super)
93 | slog.Debug("Inspected PostgreSQL instance role options.", "columns", columns)
94 |
95 | slog.Debug("Inspecting all roles.")
96 | instance.AllRoles = make(role.Map)
97 | sql := "rol." + strings.Join(columns, ", rol.")
98 | sql = strings.Replace(rolesQuery, "rol.*", sql, 1)
99 | rq := &SQLQuery[role.Role]{SQL: sql, RowTo: role.RowTo}
100 | for rq.Query(ctx, pgconn); rq.Next(); {
101 | role := rq.Row()
102 | match := instance.RolesBlacklist.Match(&role)
103 | if match == "" {
104 | instance.AllRoles[role.Name] = role
105 | slog.Debug("Found role in Postgres instance.", "name", role.Name, "options", role.Options, "parents", role.Parents)
106 | } else {
107 | slog.Debug("Ignoring blacklisted role name.", "name", role.Name, "pattern", match)
108 | }
109 | }
110 | if err := rq.Err(); err != nil {
111 | return fmt.Errorf("all: %w", err)
112 | }
113 |
114 | if nil == managedRolesQ {
115 | slog.Debug("Managing all roles found.")
116 | instance.ManagedRoles = instance.AllRoles
117 | return nil
118 | }
119 |
120 | slog.Debug("Inspecting managed roles.", "config", "managed_roles_query")
121 | instance.ManagedRoles = make(role.Map)
122 | for managedRolesQ.Query(ctx, pgconn); managedRolesQ.Next(); {
123 | name := managedRolesQ.Row()
124 | match := instance.RolesBlacklist.MatchString(name)
125 | if match != "" {
126 | slog.Debug("Ignoring blacklisted role name.", "role", name, "pattern", match)
127 | continue
128 | }
129 | instance.ManagedRoles[name] = instance.AllRoles[name]
130 | slog.Debug("Managing role.", "role", name)
131 |
132 | }
133 | if err := managedRolesQ.Err(); err != nil {
134 | return fmt.Errorf("managed_roles_query: %w", err)
135 | }
136 |
137 | return nil
138 | }
139 |
--------------------------------------------------------------------------------
/internal/inspect/stage2.go:
--------------------------------------------------------------------------------
1 | package inspect
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "fmt"
7 | "log/slog"
8 |
9 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
10 | "golang.org/x/exp/slices"
11 | )
12 |
13 | //go:embed sql/schemas.sql
14 | var schemasQuery string
15 |
16 | func (instance *Instance) InspectStage2(ctx context.Context, dbname string, query Querier[postgres.Schema]) error {
17 | err := instance.InspectSchemas(ctx, dbname, query)
18 | if err != nil {
19 | return fmt.Errorf("schemas: %w", err)
20 | }
21 | return nil
22 | }
23 |
24 | func (instance *Instance) InspectSchemas(ctx context.Context, dbname string, managedQuery Querier[postgres.Schema]) error {
25 | conn, err := postgres.GetConn(ctx, dbname)
26 | if err != nil {
27 | return err
28 | }
29 |
30 | var managedSchemas []string
31 | slog.Debug("Inspecting managed schemas.", "config", "schemas_query", "database", dbname)
32 | for managedQuery.Query(ctx, conn); managedQuery.Next(); {
33 | s := managedQuery.Row()
34 | managedSchemas = append(managedSchemas, s.Name)
35 | }
36 | err = managedQuery.Err()
37 | if err != nil {
38 | return err
39 | }
40 |
41 | database := postgres.Databases[dbname]
42 | sq := &SQLQuery[postgres.Schema]{SQL: schemasQuery, RowTo: postgres.RowToSchema}
43 | for sq.Query(ctx, conn); sq.Next(); {
44 | s := sq.Row()
45 | if !slices.Contains(managedSchemas, s.Name) {
46 | continue
47 | }
48 | database.Schemas[s.Name] = s
49 | slog.Debug("Found schema.", "database", dbname, "schema", s.Name, "owner", s.Owner)
50 | }
51 | err = sq.Err()
52 | if err != nil {
53 | return err
54 | }
55 |
56 | postgres.Databases[dbname] = database
57 |
58 | return nil
59 | }
60 |
--------------------------------------------------------------------------------
/internal/inspect/stage3.go:
--------------------------------------------------------------------------------
1 | package inspect
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "fmt"
7 | "log/slog"
8 |
9 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
10 | mapset "github.com/deckarep/golang-set/v2"
11 | "github.com/jackc/pgx/v5"
12 | )
13 |
14 | //go:embed sql/creators.sql
15 | var creatorsQuery string
16 |
17 | func (instance *Instance) InspectStage3(ctx context.Context, dbname string, roles mapset.Set[string]) error {
18 | err := instance.InspectCreators(ctx, dbname, roles)
19 | if err != nil {
20 | return fmt.Errorf("creators: %w", err)
21 | }
22 |
23 | return nil
24 | }
25 |
26 | type Creators struct {
27 | Schema string
28 | Creators []string
29 | }
30 |
31 | func RowToCreators(rows pgx.CollectableRow) (c Creators, err error) {
32 | err = rows.Scan(&c.Schema, &c.Creators)
33 | return
34 | }
35 |
36 | func (instance *Instance) InspectCreators(ctx context.Context, dbname string, managedRoles mapset.Set[string]) error {
37 | cq := &SQLQuery[Creators]{SQL: creatorsQuery, RowTo: RowToCreators}
38 |
39 | database := postgres.Databases[dbname]
40 | slog.Debug("Inspecting objects creators.", "database", dbname)
41 | conn, err := postgres.GetConn(ctx, dbname)
42 | if err != nil {
43 | return err
44 | }
45 |
46 | for cq.Query(ctx, conn); cq.Next(); {
47 | c := cq.Row()
48 | s, ok := database.Schemas[c.Schema]
49 | if !ok {
50 | continue
51 | }
52 |
53 | for _, name := range c.Creators {
54 | if !managedRoles.Contains(name) {
55 | continue
56 | }
57 | s.Creators = append(s.Creators, name)
58 | }
59 | slog.Debug("Found schema creators.", "database", database.Name, "schema", s.Name, "owner", s.Owner, "creators", s.Creators)
60 | database.Schemas[c.Schema] = s
61 | }
62 | err = cq.Err()
63 | if err != nil {
64 | return err
65 | }
66 |
67 | postgres.Databases[dbname] = database
68 |
69 | return nil
70 | }
71 |
--------------------------------------------------------------------------------
/internal/ldap/client.go:
--------------------------------------------------------------------------------
1 | package ldap
2 |
3 | import (
4 | "crypto/tls"
5 | "fmt"
6 | "log/slog"
7 | "net"
8 | "net/url"
9 | "strings"
10 | "time"
11 |
12 | "github.com/avast/retry-go/v4"
13 | "github.com/dalibo/ldap2pg/v6/internal/perf"
14 | ldap3 "github.com/go-ldap/ldap/v3"
15 | )
16 |
17 | type Client struct {
18 | URI string
19 | BindDN string
20 | SaslMech string
21 | SaslAuthCID string
22 | Timeout time.Duration
23 | Password string
24 | Conn *ldap3.Conn
25 | }
26 |
27 | var Watch perf.StopWatch
28 |
29 | func Connect() (client Client, err error) {
30 | uri := k.String("URI")
31 | uris := strings.Split(uri, " ")
32 | if len(uris) == 0 {
33 | err = fmt.Errorf("missing URI")
34 | return
35 | }
36 |
37 | t := tls.Config{
38 | InsecureSkipVerify: k.String("TLS_REQCERT") != "try",
39 | }
40 | d := net.Dialer{
41 | Timeout: k.Duration("NETWORK_TIMEOUT") * time.Second,
42 | }
43 | try := 0
44 | err = retry.Do(
45 | func() error {
46 | // Round-robin URIs
47 | i := try % len(uris)
48 | try++
49 | client.URI = uris[i]
50 | slog.Debug("LDAP dial.", "uri", client.URI, "try", try)
51 | client.Conn, err = ldap3.DialURL(
52 | client.URI,
53 | ldap3.DialWithTLSConfig(&t),
54 | ldap3.DialWithDialer(&d),
55 | )
56 | return err
57 | },
58 | retry.RetryIf(IsErrorRecoverable),
59 | retry.OnRetry(LogRetryError),
60 | retry.MaxDelay(30*time.Second),
61 | retry.LastErrorOnly(true),
62 | )
63 | if err != nil {
64 | return
65 | }
66 |
67 | client.Timeout = k.Duration("TIMEOUT") * time.Second
68 | slog.Debug("LDAP set timeout.", "timeout", client.Timeout)
69 | client.Conn.SetTimeout(client.Timeout)
70 |
71 | client.SaslMech = k.String("SASL_MECH")
72 | switch client.SaslMech {
73 | case "":
74 | client.BindDN = k.String("BINDDN")
75 | if client.BindDN == "" {
76 | err = fmt.Errorf("missing BINDDN")
77 | return
78 | }
79 | password := k.String("PASSWORD")
80 | client.Password = "*******"
81 | slog.Debug("LDAP simple bind.", "binddn", client.BindDN)
82 | err = client.Conn.Bind(client.BindDN, password)
83 | case "DIGEST-MD5":
84 | client.SaslAuthCID = k.String("SASL_AUTHCID")
85 | password := k.String("PASSWORD")
86 | var parsedURI *url.URL
87 | parsedURI, err = url.Parse(client.URI)
88 | if err != nil {
89 | return client, err
90 | }
91 | slog.Debug("LDAP SASL/DIGEST-MD5 bind.", "authcid", client.SaslAuthCID, "host", parsedURI.Host)
92 | err = client.Conn.MD5Bind(parsedURI.Host, client.SaslAuthCID, password)
93 | default:
94 | err = fmt.Errorf("unhandled SASL_MECH")
95 | }
96 | if err != nil {
97 | return
98 | }
99 |
100 | slog.Info("Connected to LDAP directory.", "uri", client.URI)
101 | return
102 | }
103 |
104 | func (c *Client) Search(base string, scope Scope, filter string, attributes []string) (*ldap3.SearchResult, error) {
105 | search := ldap3.SearchRequest{
106 | BaseDN: base,
107 | Scope: int(scope),
108 | Filter: filter,
109 | Attributes: attributes,
110 | }
111 | args := []string{"-b", search.BaseDN, "-s", scope.String(), search.Filter}
112 | args = append(args, search.Attributes...)
113 | slog.Debug("Searching LDAP directory.", "cmd", c.Command("ldapsearch", args...))
114 | var err error
115 | var res *ldap3.SearchResult
116 | duration := Watch.TimeIt(func() {
117 | res, err = c.Conn.Search(&search)
118 | })
119 | if err != nil {
120 | slog.Debug("LDAP search failed.", "duration", duration, "err", err)
121 | return nil, err
122 | }
123 | slog.Debug("LDAP search done.", "duration", duration, "entries", len(res.Entries))
124 | return res, nil
125 | }
126 |
127 | // Implements retry.RetryIfFunc
128 | func IsErrorRecoverable(err error) bool {
129 | ldapErr, ok := err.(*ldap3.Error)
130 | if !ok {
131 | return true
132 | }
133 | _, ok = ldapErr.Err.(*tls.CertificateVerificationError)
134 | // Retrying don't fix bad certificate
135 | return !ok
136 | }
137 |
138 | // Implements retry.OnRetryFunc
139 | func LogRetryError(n uint, err error) {
140 | slog.Debug("Retrying.", "err", err.Error(), "attempt", n)
141 | }
142 |
--------------------------------------------------------------------------------
/internal/ldap/command.go:
--------------------------------------------------------------------------------
1 | package ldap
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | )
7 |
8 | func (c Client) Command(name string, args ...string) string {
9 | cmd := []string{name}
10 | if c.URI != "" {
11 | cmd = append(cmd, "-H", c.URI)
12 | }
13 | if c.Timeout != 0 && name == "ldapsearch" {
14 | cmd = append(cmd, "-l", fmt.Sprintf("%.0f", c.Timeout.Seconds()))
15 | }
16 | if c.BindDN != "" {
17 | cmd = append(cmd, "-D", c.BindDN)
18 | }
19 | if c.SaslMech == "" {
20 | cmd = append(cmd, "-x")
21 | } else {
22 | cmd = append(cmd, "-Y", c.SaslMech)
23 | }
24 | if c.SaslAuthCID != "" {
25 | cmd = append(cmd, "-U", c.SaslAuthCID)
26 | }
27 | if c.Password != "" {
28 | cmd = append(cmd, "-w", "$LDAPPASSWORD")
29 | }
30 | cmd = append(cmd, args...)
31 | for i, arg := range cmd {
32 | cmd[i] = ShellQuote(arg)
33 | }
34 | return strings.Join(cmd, " ")
35 | }
36 |
37 | var specialChars = ` "*!()[]{}` + "`"
38 |
39 | func NeedsQuote(s string) bool {
40 | if s == "" {
41 | return true
42 | }
43 | for i := range s {
44 | if strings.ContainsAny(s[i:i+1], specialChars) {
45 | return true
46 | }
47 | }
48 | return false
49 | }
50 |
51 | func ShellQuote(arg string) string {
52 | if arg == "" {
53 | return `''`
54 | }
55 |
56 | quoteParts := strings.Split(arg, `'`)
57 | b := strings.Builder{}
58 | for i, part := range quoteParts {
59 | if i > 0 {
60 | b.WriteString(`"'"`)
61 | }
62 |
63 | if part == "" {
64 | continue
65 | }
66 |
67 | if NeedsQuote(part) {
68 | b.WriteString(`'`)
69 | b.WriteString(part)
70 | b.WriteString(`'`)
71 |
72 | } else {
73 | b.WriteString(part)
74 | }
75 |
76 | }
77 | return b.String()
78 | }
79 |
--------------------------------------------------------------------------------
/internal/ldap/command_test.go:
--------------------------------------------------------------------------------
1 | package ldap_test
2 |
3 | import "github.com/dalibo/ldap2pg/v6/internal/ldap"
4 |
5 | func (suite *Suite) TestCommandSearch() {
6 | r := suite.Require()
7 |
8 | c := ldap.Client{
9 | URI: "ldaps://pouet",
10 | }
11 | cmd := c.Command("ldapsearch", "(filter=*)", "cn", "member")
12 | r.Equal(`ldapsearch -H ldaps://pouet -x '(filter=*)' cn member`, cmd)
13 | }
14 |
15 | func (suite *Suite) TestQuote() {
16 | r := suite.Require()
17 |
18 | r.Equal(`''`, ldap.ShellQuote(""))
19 | r.Equal(`"'"`, ldap.ShellQuote("'"))
20 | r.Equal(`'"'`, ldap.ShellQuote(`"`))
21 | r.Equal(`' '`, ldap.ShellQuote(` `))
22 | r.Equal("'`'", ldap.ShellQuote("`"))
23 | r.Equal(`'*'`, ldap.ShellQuote(`*`))
24 | r.Equal(`'!'`, ldap.ShellQuote(`!`))
25 | r.Equal(`'(cn=*)'`, ldap.ShellQuote(`(cn=*)`))
26 | r.Equal(`d"'"accord`, ldap.ShellQuote(`d'accord`))
27 | r.Equal(`'(cn="toto")'`, ldap.ShellQuote(`(cn="toto")`))
28 | r.Equal(`'(cn='"'"toto"'"')'`, ldap.ShellQuote(`(cn='toto')`))
29 | r.Equal(`'"'"'"'"'`, ldap.ShellQuote(`"'"`))
30 | }
31 |
--------------------------------------------------------------------------------
/internal/ldap/config.go:
--------------------------------------------------------------------------------
1 | package ldap
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log/slog"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 |
11 | "github.com/knadh/koanf/providers/confmap"
12 | "github.com/knadh/koanf/providers/env"
13 | "github.com/knadh/koanf/providers/posflag"
14 | "github.com/knadh/koanf/v2"
15 | "github.com/spf13/pflag"
16 | )
17 |
18 | var KnownRDNs = []string{"cn", "l", "st", "o", "ou", "c", "street", "dc", "uid"}
19 |
20 | type Config struct {
21 | KnownRDNs []string `mapstructure:"known_rdns"`
22 | }
23 |
24 | func (c Config) apply() {
25 | if c.KnownRDNs != nil {
26 | slog.Debug("Setting known RDNs.", "known_rdns", c.KnownRDNs)
27 | KnownRDNs = c.KnownRDNs
28 | }
29 | }
30 |
31 | var k = koanf.New(".")
32 |
33 | // cf. https://git.openldap.org/openldap/openldap/-/blob/bf01750381726db3052d94514eec4048c90a616a/libraries/libldap/init.c#L640
34 | func Initialize(conf Config) error {
35 | conf.apply()
36 | _, ok := os.LookupEnv("LDAPNOINIT")
37 | if ok {
38 | slog.Debug("Skip LDAP initialization.")
39 | return nil
40 | }
41 |
42 | _ = k.Load(confmap.Provider(map[string]any{
43 | "URI": "ldap://localhost",
44 | "NETWORK_TIMEOUT": "30",
45 | "RC": "ldaprc",
46 | "TLS_REQCERT": "try",
47 | "TIMEOUT": "30",
48 | }, k.Delim()), nil)
49 |
50 | _ = k.Load(env.Provider("LDAP", k.Delim(), func(key string) string {
51 | slog.Debug("Loading LDAP environment var.", "var", key)
52 | return strings.TrimPrefix(key, "LDAP")
53 | }), nil)
54 |
55 | _ = k.Load(posflag.ProviderWithFlag(pflag.CommandLine, k.Delim(), k, func(f *pflag.Flag) (string, any) {
56 | if !strings.HasPrefix(f.Name, "ldap") {
57 | return "", nil
58 | }
59 | // Rename LDAP flags
60 | // e.g. --ldapppassword_file -> PASSWORD_FILE
61 | key := strings.ToUpper(f.Name)
62 | key = strings.TrimPrefix(key, "LDAP")
63 | key = strings.ReplaceAll(key, "-", "_")
64 | return key, posflag.FlagVal(pflag.CommandLine, f)
65 | }), nil)
66 |
67 | passwordFilePath := k.String("PASSWORD_FILE")
68 | if passwordFilePath != "" {
69 | slog.Debug("Reading password from file.", "path", passwordFilePath)
70 | data, err := readSecretFromFile(passwordFilePath)
71 | if err != nil {
72 | return fmt.Errorf("ldap password: %w", err)
73 | }
74 | // Set() only throws error when using StrictMerge which is not the case.
75 | _ = k.Set("PASSWORD", data)
76 | }
77 |
78 | // cf. https://git.openldap.org/openldap/openldap/-/blob/bf01750381726db3052d94514eec4048c90a616a/libraries/libldap/init.c#L741
79 | home, _ := os.UserHomeDir()
80 | files := []string{
81 | "/etc/ldap/ldap.conf",
82 | filepath.Join(home, "ldaprc"),
83 | filepath.Join(home, ".ldaprc"),
84 | "ldaprc", // search in CWD
85 | // Read CONF and RC only from env, before above files are effectively read.
86 | k.String("CONF"),
87 | filepath.Join(home, k.String("RC")),
88 | filepath.Join(home, fmt.Sprintf(".%s", k.String("RC"))),
89 | k.String("RC"), // Search in CWD.
90 | }
91 | for _, candidate := range files {
92 | if candidate == "" {
93 | continue
94 | }
95 |
96 | err := k.Load(newLooseFileProvider(candidate), parser{k.Delim()})
97 | if err != nil {
98 | return fmt.Errorf("%s: %w", candidate, err)
99 | }
100 | }
101 | return nil
102 | }
103 |
104 | // readSecretFromFile reads a file and returns its content.
105 | // It returns an error if the file does not exist or has too open permissions.
106 | func readSecretFromFile(path string) (string, error) {
107 | info, err := os.Stat(path)
108 | if err != nil {
109 | return "", err
110 | }
111 | if (info.Mode().Perm() & 0o007) != 0 {
112 | return "", errors.New("permissions too wide")
113 | }
114 | data, err := os.ReadFile(path)
115 | if err != nil {
116 | return "", err
117 | }
118 | return strings.TrimSpace(string(data)), nil
119 | }
120 |
--------------------------------------------------------------------------------
/internal/ldap/filter.go:
--------------------------------------------------------------------------------
1 | package ldap
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 | )
7 |
8 | // Prepare a YAML filter string for compilation by ldapv3.CompileFilter.
9 | // go-ldap is stricter than openldap when implementing RFC4515 filter. No
10 | // spaces are allowed around parenthesises.
11 | func CleanFilter(filter string) string {
12 | filter = strings.ReplaceAll(filter, `\n`, "")
13 | re, _ := regexp.Compile(`\s+\(`)
14 | filter = re.ReplaceAllLiteralString(filter, "(")
15 | re, _ = regexp.Compile(`\)\s+`)
16 | filter = re.ReplaceAllLiteralString(filter, ")")
17 | return filter
18 | }
19 |
--------------------------------------------------------------------------------
/internal/ldap/filter_test.go:
--------------------------------------------------------------------------------
1 | package ldap_test
2 |
3 | import (
4 | "github.com/dalibo/ldap2pg/v6/internal/ldap"
5 | ldap3 "github.com/go-ldap/ldap/v3"
6 | )
7 |
8 | func (suite *Suite) TestCleanFilter() {
9 | r := suite.Require()
10 | var (
11 | f string
12 | err error
13 | )
14 |
15 | f = ldap.CleanFilter("(cn=dba)")
16 | _, err = ldap3.CompileFilter(f)
17 | r.Nil(err, f)
18 |
19 | f = ldap.CleanFilter(" (& (cn=dba) (member=*) ) ")
20 | _, err = ldap3.CompileFilter(f)
21 | r.Nil(err, f)
22 |
23 | f = ldap.CleanFilter(`\n (&\n (cn=dba)\n (member=*)\n )\n`)
24 | _, err = ldap3.CompileFilter(f)
25 | r.Nil(err, f)
26 | }
27 |
--------------------------------------------------------------------------------
/internal/ldap/generate_test.go:
--------------------------------------------------------------------------------
1 | package ldap_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/ldap"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestResolveRDNUpper(t *testing.T) {
11 | r := require.New(t)
12 |
13 | attrValues := map[string]string{
14 | "member": "CN=Alice,OU=Users,DC=bridoulou,DC=fr",
15 | }
16 | expressions := []string{"member.cn"}
17 | result := &ldap.Result{}
18 |
19 | exprMap := result.ResolveExpressions(expressions, attrValues, nil)
20 | r.Equal("Alice", exprMap["member.cn"])
21 | }
22 |
--------------------------------------------------------------------------------
/internal/ldap/ldap_test.go:
--------------------------------------------------------------------------------
1 | package ldap_test
2 |
3 | import (
4 | "log/slog"
5 | "testing"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal"
8 | "github.com/stretchr/testify/suite"
9 | )
10 |
11 | // Global test suite for ldap package.
12 | type Suite struct {
13 | suite.Suite
14 | }
15 |
16 | func TestConfig(t *testing.T) {
17 | if testing.Verbose() {
18 | internal.SetLoggingHandler(slog.LevelDebug, false)
19 | } else {
20 | internal.SetLoggingHandler(slog.LevelWarn, false)
21 | }
22 | suite.Run(t, new(Suite))
23 | }
24 |
--------------------------------------------------------------------------------
/internal/ldap/rc.go:
--------------------------------------------------------------------------------
1 | // Implements ldap.conf(5)
2 | package ldap
3 |
4 | import (
5 | "bufio"
6 | "errors"
7 | "fmt"
8 | "log/slog"
9 | "os"
10 | "path/filepath"
11 | "regexp"
12 | "strings"
13 |
14 | "github.com/knadh/koanf/maps"
15 | "github.com/knadh/koanf/v2"
16 | )
17 |
18 | // Avoid error if file does not exist.
19 | type looseFileProvider struct {
20 | path string
21 | }
22 |
23 | func newLooseFileProvider(path string) koanf.Provider {
24 | if !filepath.IsAbs(path) {
25 | path, _ = filepath.Abs(path)
26 | }
27 | return looseFileProvider{path: path}
28 | }
29 |
30 | func (p looseFileProvider) ReadBytes() ([]byte, error) {
31 | data, err := os.ReadFile(p.path)
32 | if errors.Is(err, os.ErrNotExist) {
33 | return nil, nil
34 | }
35 | slog.Debug("Found LDAP configuration file.", "path", p.path, "err", err)
36 | return data, err
37 | }
38 |
39 | func (looseFileProvider) Read() (map[string]any, error) {
40 | panic("not implemented")
41 | }
42 |
43 | // parser returns ldaprc as plain map for koanf.
44 | // delim defines the nesting hierarchy of keys.
45 | type parser struct {
46 | delim string
47 | }
48 |
49 | func (p parser) Unmarshal(data []byte) (map[string]any, error) {
50 | out := make(map[string]any)
51 | scanner := bufio.NewScanner(strings.NewReader(string(data)))
52 | re := regexp.MustCompile(`\s+`)
53 | for scanner.Scan() {
54 | line := scanner.Text()
55 | if strings.HasPrefix(line, "#") {
56 | continue
57 | }
58 | line = strings.TrimSpace(line)
59 | if line == "" {
60 | continue
61 | }
62 | fields := re.Split(line, 2)
63 | if len(fields) < 2 {
64 | return nil, fmt.Errorf("invalid line: %s", line)
65 | }
66 | out[fields[0]] = fields[1]
67 | }
68 | return maps.Unflatten(out, p.delim), nil
69 | }
70 |
71 | func (parser) Marshal(map[string]any) ([]byte, error) {
72 | panic("not implemented")
73 | }
74 |
--------------------------------------------------------------------------------
/internal/ldap/scope.go:
--------------------------------------------------------------------------------
1 | package ldap
2 |
3 | import (
4 | "fmt"
5 |
6 | ldap3 "github.com/go-ldap/ldap/v3"
7 | )
8 |
9 | type Scope int
10 |
11 | func ParseScope(s string) (Scope, error) {
12 | switch s {
13 | case "sub":
14 | return ldap3.ScopeWholeSubtree, nil
15 | case "base":
16 | return ldap3.ScopeBaseObject, nil
17 | case "one":
18 | return ldap3.ScopeSingleLevel, nil
19 | default:
20 | return 0, fmt.Errorf("bad scope: %s", s)
21 | }
22 | }
23 |
24 | func (s Scope) String() string {
25 | switch s {
26 | case ldap3.ScopeBaseObject:
27 | return "base"
28 | case ldap3.ScopeWholeSubtree:
29 | return "sub"
30 | case ldap3.ScopeSingleLevel:
31 | return "one"
32 | default:
33 | return "!INVALID"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/internal/ldap/search.go:
--------------------------------------------------------------------------------
1 | package ldap
2 |
3 | import "golang.org/x/exp/maps"
4 |
5 | type Search struct {
6 | Base string
7 | Scope Scope
8 | Filter string
9 | Attributes []string
10 | Subsearches map[string]Subsearch `mapstructure:"joins"`
11 | }
12 |
13 | func (s Search) SubsearchAttribute() string {
14 | keys := maps.Keys(s.Subsearches)
15 | if len(keys) == 0 {
16 | return ""
17 | }
18 | return keys[0]
19 | }
20 |
21 | type Subsearch struct {
22 | Filter string
23 | Scope Scope
24 | Attributes []string
25 | }
26 |
--------------------------------------------------------------------------------
/internal/lists/blacklist.go:
--------------------------------------------------------------------------------
1 | // fnmatch pattern list
2 | package lists
3 |
4 | import (
5 | "path/filepath"
6 | )
7 |
8 | type (
9 | Blacklist []string
10 | Blacklistable interface {
11 | BlacklistKey() string
12 | }
13 | )
14 |
15 | // Check verify patterns are valid.
16 | //
17 | // Use it before using MatchString().
18 | func (bl *Blacklist) Check() error {
19 | for _, pattern := range *bl {
20 | _, err := filepath.Match(pattern, "pouet")
21 | if err != nil {
22 | return err
23 | }
24 | }
25 | return nil
26 | }
27 |
28 | func (bl *Blacklist) Filter(items []Blacklistable) []Blacklistable {
29 | var filteredItems []Blacklistable
30 | for _, item := range items {
31 | match := bl.Match(item)
32 | if match == "" {
33 | filteredItems = append(filteredItems, item)
34 | }
35 | }
36 | return filteredItems
37 | }
38 |
39 | // MatchString returns the first pattern that matches the item.
40 | //
41 | // Use Check() before using MatchString().
42 | // panics if pattern is invalid.
43 | // returns empty string if no match.
44 | func (bl *Blacklist) MatchString(item string) string {
45 | for _, pattern := range *bl {
46 | ok, err := filepath.Match(pattern, item)
47 | if err != nil {
48 | // Use Check() before using MatchString().
49 | panic(err)
50 | }
51 | if ok {
52 | return pattern
53 | }
54 | }
55 | return ""
56 | }
57 |
58 | func (bl *Blacklist) Match(item Blacklistable) string {
59 | return bl.MatchString(item.BlacklistKey())
60 | }
61 |
--------------------------------------------------------------------------------
/internal/lists/blacklist_test.go:
--------------------------------------------------------------------------------
1 | package lists_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/lists"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestBlacklist(t *testing.T) {
11 | r := require.New(t)
12 | bl := lists.Blacklist{"pif", "paf*"}
13 | r.Equal("", bl.MatchString("pouf"))
14 | r.Equal("paf*", bl.MatchString("paf"))
15 | }
16 |
17 | func TestBlacklistError(t *testing.T) {
18 | r := require.New(t)
19 | // filepath fails if pattern has bad escaping.
20 | bl := lists.Blacklist{"\\"}
21 | r.Error(bl.Check())
22 | }
23 |
--------------------------------------------------------------------------------
/internal/lists/bool.go:
--------------------------------------------------------------------------------
1 | package lists
2 |
3 | func And[T any](s []T, fn func(T) bool) bool {
4 | for _, i := range s {
5 | if !fn(i) {
6 | return false
7 | }
8 | }
9 | return true
10 | }
11 |
12 | // Filter items from a slice
13 | //
14 | // Keep items for which fn returns true.
15 | func Filter[T any](s []T, fn func(T) bool) (out []T) {
16 | for _, i := range s {
17 | if fn(i) {
18 | out = append(out, i)
19 | }
20 | }
21 | return
22 | }
23 |
--------------------------------------------------------------------------------
/internal/lists/lists_test.go:
--------------------------------------------------------------------------------
1 | package lists_test
2 |
3 | import (
4 | "log/slog"
5 | "testing"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal"
8 |
9 | "github.com/stretchr/testify/suite"
10 | )
11 |
12 | // Global test suite for lists package.
13 | type Suite struct {
14 | suite.Suite
15 | }
16 |
17 | func Test(t *testing.T) {
18 | if testing.Verbose() {
19 | internal.SetLoggingHandler(slog.LevelDebug, false)
20 | } else {
21 | internal.SetLoggingHandler(slog.LevelWarn, false)
22 | }
23 | suite.Run(t, new(Suite))
24 | }
25 |
--------------------------------------------------------------------------------
/internal/lists/product.go:
--------------------------------------------------------------------------------
1 | package lists
2 |
3 | func Product[T any](lists ...[]T) <-chan []T {
4 | ch := make(chan []T)
5 | go func() {
6 | defer close(ch)
7 | if len(lists) == 0 {
8 | return
9 | }
10 |
11 | indices := make([]int, len(lists))
12 | combination := make([]T, len(lists))
13 | for i, list := range lists {
14 | if len(list) == 0 {
15 | // Multiplying by empty breaks everything.
16 | return
17 | }
18 | combination[i] = list[0]
19 | }
20 |
21 | clone := make([]T, len(combination))
22 | copy(clone, combination)
23 | ch <- clone
24 |
25 | last := len(lists) - 1
26 | for { // Loop until we have looped the first list and last list together.
27 |
28 | // Each iteration, we loop lists from right to left to
29 | // increment the position in the list. We loop on
30 | // previous list only if the previous is exhausted.
31 | for i := last; i >= -1; i-- {
32 | if i == -1 {
33 | // We have rolled over all lists. Stop here.
34 | return
35 | }
36 |
37 | list := lists[i]
38 | // First increment. Index 0 is already sent by
39 | // combination 0 or by previous rollover.
40 | indices[i]++
41 | if indices[i] == len(list) {
42 | // (0, 1, 1) -> (0, 2, 0)
43 | // Reset position on this list.
44 | indices[i] = 0
45 | }
46 |
47 | combination[i] = list[indices[i]]
48 |
49 | if 0 < indices[i] {
50 | // Break (and yield a combination) only if we are the left-most list, the one that didn't rollover.
51 |
52 | // (0, 1, 1) -> (0, 1, 2)
53 | // OR
54 | // (0, 1, 0) -> (0, 2, 0) if last list had rollover.
55 | break
56 | }
57 | }
58 |
59 | clone := make([]T, len(combination))
60 | copy(clone, combination)
61 | ch <- clone
62 | }
63 | }()
64 | return ch
65 | }
66 |
--------------------------------------------------------------------------------
/internal/lists/product_test.go:
--------------------------------------------------------------------------------
1 | package lists_test
2 |
3 | import (
4 | "github.com/dalibo/ldap2pg/v6/internal/lists"
5 | )
6 |
7 | func (suite *Suite) TestProductNoLists() {
8 | r := suite.Require()
9 | for item := range lists.Product[string]() {
10 | r.Fail("Got item: %s", item)
11 | }
12 | }
13 |
14 | func (suite *Suite) TestProductOneEmptyList() {
15 | r := suite.Require()
16 | for item := range lists.Product(
17 | []string{"1", "2"},
18 | []string{},
19 | []string{"a", "b"},
20 | ) {
21 | r.Fail("Got item: %s", item)
22 | }
23 | }
24 |
25 | type dumbStruct struct {
26 | A string
27 | }
28 |
29 | func (suite *Suite) TestProductAny() {
30 | r := suite.Require()
31 |
32 | var combinations [][]any
33 | s0 := dumbStruct{A: "s0"}
34 | s1 := dumbStruct{A: "s1"}
35 | for item := range lists.Product[any](
36 | []any{"1", "2", "3"},
37 | []any{s0, s1},
38 | ) {
39 | combinations = append(combinations, item)
40 | }
41 |
42 | r.Equal(3*2, len(combinations))
43 | r.Equal([]any{"1", s0}, combinations[0])
44 | r.Equal([]any{"1", s1}, combinations[1])
45 | r.Equal([]any{"2", s0}, combinations[2])
46 | r.Equal([]any{"2", s1}, combinations[3])
47 | r.Equal([]any{"3", s0}, combinations[4])
48 | r.Equal([]any{"3", s1}, combinations[5])
49 | }
50 |
51 | func (suite *Suite) TestProductString() {
52 | r := suite.Require()
53 |
54 | var combinations [][]string
55 | for item := range lists.Product(
56 | []string{"1", "2", "3"},
57 | []string{"a", "b", "c"},
58 | []string{"A", "B"},
59 | ) {
60 | combinations = append(combinations, item)
61 | }
62 |
63 | r.Equal(3*3*2, len(combinations))
64 | r.Equal([]string{"1", "a", "A"}, combinations[0])
65 | r.Equal([]string{"1", "a", "B"}, combinations[1])
66 | r.Equal([]string{"1", "b", "A"}, combinations[2])
67 | r.Equal([]string{"1", "b", "B"}, combinations[3])
68 | r.Equal([]string{"1", "c", "A"}, combinations[4])
69 | r.Equal([]string{"1", "c", "B"}, combinations[5])
70 | r.Equal([]string{"2", "a", "A"}, combinations[6])
71 | r.Equal([]string{"2", "a", "B"}, combinations[7])
72 | r.Equal([]string{"2", "b", "A"}, combinations[8])
73 | r.Equal([]string{"2", "b", "B"}, combinations[9])
74 | r.Equal([]string{"2", "c", "A"}, combinations[10])
75 | r.Equal([]string{"2", "c", "B"}, combinations[11])
76 | r.Equal([]string{"3", "a", "A"}, combinations[12])
77 | r.Equal([]string{"3", "a", "B"}, combinations[13])
78 | r.Equal([]string{"3", "b", "A"}, combinations[14])
79 | r.Equal([]string{"3", "b", "B"}, combinations[15])
80 | r.Equal([]string{"3", "c", "A"}, combinations[16])
81 | r.Equal([]string{"3", "c", "B"}, combinations[17])
82 | }
83 |
84 | func (suite *Suite) TestProductInt() {
85 | r := suite.Require()
86 |
87 | var combinations [][]int
88 | for item := range lists.Product(
89 | []int{1, 3},
90 | []int{2, 4},
91 | ) {
92 | combinations = append(combinations, item)
93 | }
94 |
95 | r.Equal(2*2, len(combinations))
96 | r.Equal([]int{1, 2}, combinations[0])
97 | r.Equal([]int{1, 4}, combinations[1])
98 | r.Equal([]int{3, 2}, combinations[2])
99 | r.Equal([]int{3, 4}, combinations[3])
100 | }
101 |
--------------------------------------------------------------------------------
/internal/logging.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "os"
7 |
8 | mapset "github.com/deckarep/golang-set/v2"
9 | "github.com/lmittmann/tint"
10 | )
11 |
12 | // Level for changes only. Aka Magnus owns level. See #219
13 | const LevelChange slog.Level = slog.LevelInfo + 2
14 |
15 | var CurrentLevel = slog.LevelInfo
16 |
17 | func SetLoggingHandler(level slog.Level, color bool) {
18 | var h slog.Handler
19 | if color {
20 | h = tint.NewHandler(os.Stderr, buildTintOptions(level))
21 | } else {
22 | h = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
23 | Level: level,
24 | ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
25 | if slog.LevelKey == a.Key {
26 | if a.Value.Any().(slog.Level) == LevelChange {
27 | a.Value = slog.StringValue("CHANGE")
28 | }
29 | }
30 | return a
31 | },
32 | })
33 | }
34 | slog.SetDefault(slog.New(h))
35 | CurrentLevel = level
36 | }
37 |
38 | var levelStrings = map[string]string{
39 | // Colors from journalctl. Pad with spaces to fit 6 characters.
40 | "DEBUG": "\033[2mDEBUG ",
41 | "INFO": "\033[1mINFO ",
42 | "INFO+2": "\033[1mCHANGE",
43 | "WARN": "\033[1;38;5;185mWARN ",
44 | "ERROR": "\033[1;31mERROR ",
45 | }
46 |
47 | func buildTintOptions(level slog.Level) *tint.Options {
48 | return &tint.Options{
49 | Level: level,
50 | ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
51 | switch a.Key {
52 | case slog.LevelKey:
53 | a.Value = slog.StringValue(levelStrings[a.Value.String()])
54 | case slog.MessageKey:
55 | // Reset color after message.
56 | a.Value = slog.StringValue(fmt.Sprintf("%-48s", a.Value.String()) + "\033[0m")
57 | default:
58 | if a.Value.Kind() != slog.KindAny {
59 | return a
60 | }
61 | v := a.Value.Any()
62 | switch v := v.(type) {
63 | case mapset.Set[string]:
64 | a.Value = slog.AnyValue(v.ToSlice())
65 | return a
66 | case error: // Automatic tint.Err()
67 | a = tint.Err(v)
68 | case nil:
69 | if a.Key == "err" {
70 | a.Key = "" // Drop nil error.
71 | return a
72 | }
73 | }
74 | }
75 | return a
76 | },
77 | TimeFormat: "15:04:05",
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/internal/logging_test.go:
--------------------------------------------------------------------------------
1 | package internal_test
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal"
8 | "github.com/lmittmann/tint"
9 | )
10 |
11 | func ExampleSetLoggingHandler() {
12 | colors := []bool{false, true}
13 | for _, color := range colors {
14 | internal.SetLoggingHandler(slog.LevelDebug, color)
15 | slog.Debug("Lorem ipsum dolor sit amet.", "version", "v1.0")
16 | slog.Info("Consectetur adipiscing elit.", "vivamus", "ut accumsan elit", "maecenas", 4.23)
17 | slog.Debug("Tristique nulla ac nisl dignissim.")
18 | slog.Debug("Eu feugiat velit dapibus. Curabitur faucibus accumsan purus.", tint.Err(nil))
19 | slog.Warn("Mauris placerat molestie tempor.", "err", nil)
20 | slog.Error("Quisque et posuere libero.", "err", fmt.Errorf("pouet"))
21 | }
22 | // Output:
23 | }
24 |
--------------------------------------------------------------------------------
/internal/normalize/alias.go:
--------------------------------------------------------------------------------
1 | package normalize
2 |
3 | import "fmt"
4 |
5 | // Alias rename a key in a map.
6 | //
7 | // Returns an error if alias and key already co-exists.
8 | func Alias(yaml map[string]any, key, alias string) (err error) {
9 | value, hasAlias := yaml[alias]
10 | if !hasAlias {
11 | return
12 | }
13 |
14 | _, hasKey := yaml[key]
15 | if hasKey {
16 | return &conflict{
17 | key0: key,
18 | key1: alias,
19 | }
20 | }
21 |
22 | delete(yaml, alias)
23 | yaml[key] = value
24 | return
25 | }
26 |
27 | type conflict struct {
28 | key0 string
29 | key1 string
30 | }
31 |
32 | func (err *conflict) Error() string {
33 | return fmt.Sprintf("key conflict between %s and %s", err.key0, err.key1)
34 | }
35 |
--------------------------------------------------------------------------------
/internal/normalize/alias_test.go:
--------------------------------------------------------------------------------
1 | package normalize_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/normalize"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestAlias(t *testing.T) {
11 | r := require.New(t)
12 |
13 | m := map[string]any{
14 | "role": "alice",
15 | }
16 | err := normalize.Alias(m, "roles", "role")
17 | r.Nil(err)
18 | _, found := m["role"]
19 | r.False(found)
20 | _, found = m["roles"]
21 | r.True(found)
22 | }
23 |
24 | func TestAliasEmpty(t *testing.T) {
25 | r := require.New(t)
26 |
27 | m := map[string]any{}
28 | err := normalize.Alias(m, "roles", "role")
29 | r.Nil(err)
30 | _, found := m["roles"]
31 | r.False(found)
32 | }
33 |
34 | func TestAliasConflict(t *testing.T) {
35 | r := require.New(t)
36 |
37 | m := map[string]any{
38 | "key0": "alice",
39 | "alias0": "alice",
40 | }
41 | err := normalize.Alias(m, "key0", "alias0")
42 | r.NotNil(err)
43 | r.ErrorContains(err, "key0")
44 | r.ErrorContains(err, "alias0")
45 | }
46 |
--------------------------------------------------------------------------------
/internal/normalize/boolean.go:
--------------------------------------------------------------------------------
1 | package normalize
2 |
3 | // Boolean sanitizes for mapstructure.
4 | //
5 | // Returns "true" or "false" for common boolean values.
6 | // Unknown values are returned as is for mapstructure validation.
7 | func Boolean(v any) any {
8 | switch v {
9 | case "y", "Y", "yes", "Yes", "YES", "on", "On", "ON":
10 | return "true"
11 | case "n", "N", "no", "No", "NO", "off", "Off", "OFF":
12 | return "false"
13 | default:
14 | return v
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/internal/normalize/boolean_test.go:
--------------------------------------------------------------------------------
1 | package normalize_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/normalize"
9 | )
10 |
11 | func TestBooleans(t *testing.T) {
12 | r := require.New(t)
13 |
14 | r.Equal("true", normalize.Boolean("yes"))
15 | r.Equal("false", normalize.Boolean("OFF"))
16 | // Noop for non boolean.
17 | r.Equal(1, normalize.Boolean(1))
18 | // Noop for effective boolean.
19 | r.Equal(true, normalize.Boolean(true))
20 | }
21 |
--------------------------------------------------------------------------------
/internal/normalize/checks.go:
--------------------------------------------------------------------------------
1 | package normalize
2 |
3 | import (
4 | "fmt"
5 |
6 | "golang.org/x/exp/slices"
7 | )
8 |
9 | // SpuriousKeys checks for unknown keys in a YAML map.
10 | func SpuriousKeys(yaml map[string]any, knownKeys ...string) error {
11 | for key := range yaml {
12 | if !slices.Contains(knownKeys, key) {
13 | return fmt.Errorf("unknown key '%s'", key)
14 | }
15 | }
16 | return nil
17 | }
18 |
--------------------------------------------------------------------------------
/internal/normalize/doc.go:
--------------------------------------------------------------------------------
1 | // Package normalize sanitizes loose YAML input.
2 | //
3 | // For use by higher level normalization functions.
4 | package normalize
5 |
--------------------------------------------------------------------------------
/internal/normalize/list.go:
--------------------------------------------------------------------------------
1 | package normalize
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | )
7 |
8 | // List ensure yaml is a list.
9 | //
10 | // Wraps scalar or map in a list. Returns list as is.
11 | func List(yaml any) (list []any) {
12 | switch v := yaml.(type) {
13 | case []any:
14 | list = v
15 | case []string:
16 | for _, s := range v {
17 | list = append(list, s)
18 | }
19 | default:
20 | list = append(list, yaml)
21 | }
22 | return
23 | }
24 |
25 | // StringList ensure yaml is a list of string.
26 | //
27 | // Like List, but enforce string type for items.
28 | func StringList(yaml any) (list []string, err error) {
29 | switch yaml := yaml.(type) {
30 | case nil:
31 | return
32 | case string:
33 | list = append(list, yaml)
34 | case []any:
35 | for _, iItem := range yaml {
36 | item, ok := iItem.(string)
37 | if !ok {
38 | return nil, errors.New("must be string")
39 | }
40 | list = append(list, item)
41 | }
42 | case []string:
43 | list = yaml
44 | default:
45 | return nil, fmt.Errorf("must be string or list of string, got %v", yaml)
46 | }
47 | return
48 | }
49 |
--------------------------------------------------------------------------------
/internal/normalize/list_test.go:
--------------------------------------------------------------------------------
1 | package normalize_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/normalize"
7 | "github.com/lithammer/dedent"
8 | "github.com/stretchr/testify/require"
9 | "gopkg.in/yaml.v3"
10 | )
11 |
12 | func TestList(t *testing.T) {
13 | r := require.New(t)
14 |
15 | rawYaml := dedent.Dedent(`
16 | role: alice
17 | `)
18 | var value any
19 | yaml.Unmarshal([]byte(rawYaml), &value) //nolint:errcheck
20 |
21 | values := normalize.List(value)
22 | r.Equal(1, len(values))
23 |
24 | values = normalize.List([]string{"string", "list"})
25 | r.Equal(2, len(values))
26 | }
27 |
28 | func TestStringList(t *testing.T) {
29 | r := require.New(t)
30 |
31 | value := any("alice")
32 | values, err := normalize.StringList(value)
33 | r.Nil(err)
34 | r.Equal(1, len(values))
35 | r.Equal("alice", values[0])
36 | }
37 |
--------------------------------------------------------------------------------
/internal/perf/mem.go:
--------------------------------------------------------------------------------
1 | package perf
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "log/slog"
7 | "os"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | func ReadVMPeak() int {
13 | fo, err := os.Open("/proc/self/status")
14 | if err != nil {
15 | slog.Debug("Failed to read /proc/self/status.", "err", err)
16 | return 0
17 | }
18 | defer fo.Close()
19 |
20 | scanner := bufio.NewScanner(fo)
21 | for scanner.Scan() {
22 | line := scanner.Text()
23 | if !strings.HasPrefix(line, "VmPeak:") {
24 | continue
25 | }
26 |
27 | fields := strings.Fields(line)
28 | value, err := strconv.Atoi(fields[1])
29 | if err != nil {
30 | slog.Debug("Failed to parse VmPeak.", "err", err)
31 | return 0
32 | }
33 | return value
34 | }
35 |
36 | if err := scanner.Err(); err != nil {
37 | slog.Debug("Failed to read from file.", "err", err)
38 | }
39 |
40 | return 0
41 | }
42 |
43 | func FormatBytes(value int) string {
44 | const divisor = 1024.
45 | const step = 512.
46 | units := []string{"B", "KiB", "MiB", "GiB"}
47 |
48 | unitIndex := 0
49 | var f float64
50 | for f = float64(value); f > step; f /= divisor {
51 | unitIndex++
52 | }
53 | return strings.Replace(fmt.Sprintf("%.1f%s", f, units[unitIndex]), ".0", "", 1)
54 | }
55 |
--------------------------------------------------------------------------------
/internal/perf/mem_test.go:
--------------------------------------------------------------------------------
1 | package perf_test
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/perf"
7 | )
8 |
9 | func ExampleFormatBytes() {
10 | var value int
11 | value = 5546875
12 | fmt.Printf("%dB = %s\n", value, perf.FormatBytes(value))
13 | value = 4
14 | fmt.Printf("%dB = %s\n", value, perf.FormatBytes(value))
15 | value = 900
16 | fmt.Printf("%dB = %s\n", value, perf.FormatBytes(value))
17 | // Output:
18 | // 5546875B = 5.3MiB
19 | // 4B = 4B
20 | // 900B = 0.9KiB
21 | }
22 |
23 | func (suite *Suite) TestFormatBytes() {
24 | r := suite.Require()
25 |
26 | r.Equal("0B", perf.FormatBytes(0))
27 | r.Equal("1KiB", perf.FormatBytes(999))
28 | }
29 |
--------------------------------------------------------------------------------
/internal/perf/perf_test.go:
--------------------------------------------------------------------------------
1 | package perf_test
2 |
3 | import (
4 | "log/slog"
5 | "testing"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal"
8 |
9 | "github.com/stretchr/testify/suite"
10 | )
11 |
12 | // Global test suite for perf package.
13 | type Suite struct {
14 | suite.Suite
15 | }
16 |
17 | func Test(t *testing.T) {
18 | if testing.Verbose() {
19 | internal.SetLoggingHandler(slog.LevelDebug, false)
20 | } else {
21 | internal.SetLoggingHandler(slog.LevelWarn, false)
22 | }
23 | suite.Run(t, new(Suite))
24 | }
25 |
--------------------------------------------------------------------------------
/internal/perf/watch.go:
--------------------------------------------------------------------------------
1 | package perf
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type StopWatch struct {
8 | Count int
9 | Total time.Duration
10 | }
11 |
12 | type Timeable func()
13 |
14 | func (t *StopWatch) TimeIt(fn Timeable) (duration time.Duration) {
15 | start := time.Now()
16 | t.Count++
17 | defer func() {
18 | duration = time.Since(start)
19 | t.Total += duration
20 | }()
21 |
22 | fn()
23 | return
24 | }
25 |
--------------------------------------------------------------------------------
/internal/perf/watch_test.go:
--------------------------------------------------------------------------------
1 | package perf_test
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/perf"
7 | )
8 |
9 | func (suite *Suite) TestStopwatch() {
10 | r := suite.Require()
11 |
12 | t := perf.StopWatch{}
13 | t.TimeIt(func() {
14 | time.Sleep(time.Microsecond)
15 | })
16 | r.Less(0*time.Nanosecond, t.Total)
17 | r.Equal(1, t.Count)
18 | backup := t.Total
19 |
20 | t.TimeIt(func() {
21 | time.Sleep(time.Microsecond)
22 | })
23 | r.Less(backup, t.Total)
24 | r.Equal(2, t.Count)
25 | }
26 |
--------------------------------------------------------------------------------
/internal/postgres/apply.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal"
9 | "github.com/dalibo/ldap2pg/v6/internal/errorlist"
10 | "github.com/dalibo/ldap2pg/v6/internal/perf"
11 | "github.com/jackc/pgx/v5/pgconn"
12 | "golang.org/x/exp/slices"
13 | )
14 |
15 | var (
16 | Watch perf.StopWatch
17 | formatter = FmtQueryRewriter{}
18 | )
19 |
20 | func Apply(ctx context.Context, diff <-chan SyncQuery, really bool) (count int, err error) {
21 | prefix := ""
22 | if !really {
23 | prefix = "Would "
24 | }
25 |
26 | errs := errorlist.New("synchronisation errors")
27 | for query := range diff {
28 | if !slices.ContainsFunc(query.LogArgs, func(i any) bool {
29 | return i == "database"
30 | }) {
31 | query.LogArgs = append(query.LogArgs, "database", query.Database)
32 | }
33 | slog.Log(ctx, internal.LevelChange, prefix+query.Description, query.LogArgs...)
34 | count++
35 | pgConn, err := GetConn(ctx, query.Database)
36 | if err != nil {
37 | return count, fmt.Errorf("PostgreSQL error: %w", err)
38 | }
39 |
40 | // Rewrite query to log a pasteable query even when in Dry mode.
41 | sql, _, _ := formatter.RewriteQuery(ctx, pgConn, query.Query, query.QueryArgs)
42 | slog.Debug(prefix + "Execute SQL query:\n" + sql)
43 |
44 | if !really {
45 | continue
46 | }
47 |
48 | var tag pgconn.CommandTag
49 | duration := Watch.TimeIt(func() {
50 | _, err = pgConn.Exec(ctx, sql)
51 | })
52 | if err != nil {
53 | slog.Error("Synchronisation error.", "err", err)
54 | if !errs.Append(err) {
55 | break
56 | }
57 | } else {
58 | slog.Debug("Query terminated.", "duration", duration, "rows", tag.RowsAffected())
59 | }
60 | }
61 | if errs.Len() > 0 {
62 | return count, errs
63 | }
64 | return count, nil
65 | }
66 |
--------------------------------------------------------------------------------
/internal/postgres/global.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "time"
7 |
8 | "github.com/jackc/pgx/v5"
9 | "github.com/jackc/pgx/v5/pgconn"
10 | )
11 |
12 | var (
13 | globalConn *pgx.Conn
14 | globalConf *pgx.ConnConfig
15 | )
16 |
17 | func Configure(dsn string) (err error) {
18 | globalConf, err = pgx.ParseConfig(dsn)
19 | if err != nil {
20 | return
21 | }
22 | if globalConf.ConnectTimeout == 0 {
23 | slog.Debug("Setting default Postgres connection timeout.", "timeout", "5s")
24 | globalConf.ConnectTimeout, _ = time.ParseDuration("5s")
25 | globalConf.OnNotice = func(_ *pgconn.PgConn, n *pgconn.Notice) {
26 | switch n.Severity {
27 | case "NOTICE":
28 | slog.Info("Postgres message.", "message", n.Message, "hint", n.Hint, "detail", n.Detail)
29 | case "WARNING":
30 | slog.Warn("Postgres warning.", "message", n.Message, "hint", n.Hint, "detail", n.Detail)
31 | case "ERROR", "FATAL", "PANIC":
32 | slog.Error("Postgres error.", "message", n.Message, "hint", n.Hint, "detail", n.Detail)
33 | panic("Postgres out of band error.") // We should propagate error. No case found yet.
34 | default:
35 | slog.Debug("Postgres message.", "message", n.Message, "severity", n.Severity, "detail", n.Detail)
36 | }
37 | }
38 | }
39 | return
40 | }
41 |
42 | func GetConn(ctx context.Context, database string) (*pgx.Conn, error) {
43 | if database == "" {
44 | database = globalConf.Database
45 | }
46 |
47 | if nil != globalConn {
48 | c := globalConn.Config()
49 | if database != c.Database {
50 | CloseConn(ctx)
51 | }
52 | }
53 |
54 | if nil == globalConn {
55 | var err error
56 | slog.Debug("Opening Postgres global connection.", "database", database)
57 | c := globalConf.Copy()
58 | c.Database = database
59 | globalConn, err = pgx.ConnectConfig(ctx, c)
60 | if err != nil {
61 | return nil, err
62 | }
63 | }
64 |
65 | return globalConn, nil
66 | }
67 |
68 | func CloseConn(ctx context.Context) {
69 | if nil == globalConn {
70 | return
71 | }
72 | c := globalConn.Config()
73 | slog.Debug("Closing Postgres global connection.", "database", c.Database)
74 |
75 | globalConn.Close(ctx)
76 | globalConn = nil
77 | }
78 |
--------------------------------------------------------------------------------
/internal/postgres/objects.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/jackc/pgx/v5"
7 | "golang.org/x/exp/maps"
8 | "golang.org/x/exp/slices"
9 | )
10 |
11 | type Database struct {
12 | Name string
13 | Owner string
14 | Schemas map[string]Schema
15 | }
16 |
17 | type DBMap map[string]Database
18 |
19 | var Databases = make(DBMap)
20 |
21 | func SyncOrder(defaultName string, defaultFirst bool) (out []string) {
22 | m := Databases
23 | names := maps.Keys(m)
24 | slices.Sort(names)
25 | _, ok := m[defaultName]
26 | if defaultFirst && ok {
27 | out = append(out, defaultName)
28 | }
29 | for _, name := range names {
30 | if defaultName != name {
31 | out = append(out, name)
32 | }
33 | }
34 |
35 | if !defaultFirst && ok {
36 | out = append(out, defaultName)
37 | }
38 | return
39 | }
40 |
41 | func RowToDatabase(row pgx.CollectableRow) (database Database, err error) {
42 | err = row.Scan(&database.Name, &database.Owner)
43 | database.Schemas = make(map[string]Schema)
44 | return
45 | }
46 |
47 | type Schema struct {
48 | Name string
49 | Owner string
50 | Creators []string
51 | }
52 |
53 | func RowToSchema(row pgx.CollectableRow) (s Schema, err error) {
54 | switch len(row.FieldDescriptions()) {
55 | case 1:
56 | err = row.Scan(&s.Name)
57 | case 2:
58 | err = row.Scan(&s.Name, &s.Owner)
59 | default:
60 | err = fmt.Errorf("wrong number of returned columns")
61 | }
62 | return
63 | }
64 |
65 | func YamlToSchema(in any) (out Schema, err error) {
66 | var ok bool
67 | out.Name, ok = in.(string)
68 | if !ok {
69 | panic("Unsupported schema value.")
70 | }
71 | return
72 | }
73 |
--------------------------------------------------------------------------------
/internal/postgres/queries.go:
--------------------------------------------------------------------------------
1 | // Configurable and overridable queries.
2 | package postgres
3 |
4 | import (
5 | "context"
6 | "fmt"
7 | "strings"
8 |
9 | "github.com/jackc/pgx/v5"
10 | "github.com/lithammer/dedent"
11 | )
12 |
13 | // SYNC
14 |
15 | type SyncQuery struct {
16 | Description string
17 | LogArgs []any
18 | Database string
19 | Query string
20 | QueryArgs []any
21 | }
22 |
23 | func (q SyncQuery) IsZero() bool {
24 | return q.Query == ""
25 | }
26 |
27 | func (q SyncQuery) String() string {
28 | return q.Description
29 | }
30 |
31 | type FmtQueryRewriter struct{}
32 |
33 | func (q FmtQueryRewriter) RewriteQuery(_ context.Context, conn *pgx.Conn, sql string, args []any) (newSQL string, newArgs []any, err error) {
34 | sql = strings.TrimSpace(dedent.Dedent(sql))
35 | var fmtArgs []any
36 | for _, arg := range args {
37 | arg, err = formatArg(conn, arg)
38 | if err != nil {
39 | return
40 | }
41 | fmtArgs = append(fmtArgs, arg)
42 | }
43 | newSQL = fmt.Sprintf(sql, fmtArgs...)
44 | return
45 | }
46 |
47 | func formatArg(conn *pgx.Conn, arg any) (newArg any, err error) {
48 | switch arg := arg.(type) {
49 | case pgx.Identifier:
50 | newArg = arg.Sanitize()
51 | case string:
52 | s, err := conn.PgConn().EscapeString(arg)
53 | if err != nil {
54 | return newArg, err
55 | }
56 | newArg = "'" + s + "'"
57 | case []any:
58 | b := strings.Builder{}
59 | for _, item := range arg {
60 | item, err := formatArg(conn, item)
61 | if err != nil {
62 | return newArg, err
63 | }
64 | if b.Len() > 0 {
65 | b.WriteString(", ")
66 | }
67 | b.WriteString(fmt.Sprintf("%s", item))
68 | }
69 | newArg = b.String()
70 | default:
71 | newArg = arg
72 | }
73 | return
74 | }
75 |
76 | func GroupByDatabase(defaultDatabase string, in <-chan SyncQuery) chan SyncQuery {
77 | ch := make(chan SyncQuery)
78 | go func() {
79 | defer close(ch)
80 | var queries []SyncQuery
81 | databases := SyncOrder(defaultDatabase, false)
82 |
83 | for q := range in {
84 | switch q.Database {
85 | case "":
86 | q.Database = defaultDatabase
87 | case "":
88 | q.Database = databases[0]
89 | }
90 | queries = append(queries, q)
91 | }
92 |
93 | for _, name := range databases {
94 | for _, q := range queries {
95 | if q.Database == name {
96 | ch <- q
97 | }
98 | }
99 | }
100 | }()
101 | return ch
102 | }
103 |
--------------------------------------------------------------------------------
/internal/privileges/inspect.go:
--------------------------------------------------------------------------------
1 | package privileges
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
9 | mapset "github.com/deckarep/golang-set/v2"
10 | )
11 |
12 | // Inspect returns ACL items from Postgres instance.
13 | func Inspect(ctx context.Context, db postgres.Database, acl string, roles mapset.Set[string]) (out []Grant, err error) {
14 | inspector := inspector{database: db, acl: acl}
15 | for inspector.Run(ctx); inspector.Next(); {
16 | grant := inspector.Grant()
17 | // Drop wildcard on public if public is not managed.
18 | if grant.IsWildcard() && !roles.Contains(grant.Grantee) {
19 | continue
20 | }
21 | if grant.Owner != "" && !roles.Contains(grant.Owner) {
22 | continue
23 | }
24 |
25 | slog.Debug("Found grant in Postgres instance.", "grant", grant, "database", grant.Database)
26 | out = append(out, grant)
27 | }
28 | err = inspector.Err()
29 | return
30 | }
31 |
32 | // inspector orchestrates privilege inspection
33 | //
34 | // Delegates querying and scanning to ACL.
35 | type inspector struct {
36 | database postgres.Database
37 | acl string
38 |
39 | ctx context.Context
40 | grantChan chan Grant
41 | err error
42 | grant Grant
43 | }
44 |
45 | func (i *inspector) Run(ctx context.Context) {
46 | i.ctx = ctx
47 | i.grantChan = i.iterGrants()
48 | }
49 |
50 | func (i *inspector) Next() bool {
51 | grant, ok := <-i.grantChan
52 | if !ok {
53 | return false
54 | }
55 | if i.err != nil {
56 | return false
57 | }
58 | i.grant = grant
59 | return true
60 | }
61 |
62 | func (i inspector) Grant() Grant {
63 | if i.err != nil {
64 | panic("inconsistent state")
65 | }
66 | return i.grant
67 | }
68 |
69 | func (i inspector) Err() error {
70 | return i.err
71 | }
72 |
73 | func (i *inspector) iterGrants() chan Grant {
74 | ch := make(chan Grant)
75 | go func() {
76 | defer close(ch)
77 | acl := acls[i.acl]
78 | sql := acl.Inspect
79 | types := managedACLs[i.acl]
80 | slog.Debug("Inspecting grants.", "acl", i.acl, "scope", acl.Scope, "database", i.database.Name)
81 | pgconn, err := postgres.GetConn(i.ctx, i.database.Name)
82 | if err != nil {
83 | i.err = err
84 | return
85 | }
86 |
87 | slog.Debug("Executing SQL query:\n"+sql, "arg", types)
88 | rows, err := pgconn.Query(i.ctx, sql, types)
89 | if err != nil {
90 | i.err = fmt.Errorf("bad query: %w", err)
91 | return
92 | }
93 | for rows.Next() {
94 | grant, err := acl.RowTo(rows)
95 | if err != nil {
96 | i.err = fmt.Errorf("bad row: %w", err)
97 | return
98 | }
99 |
100 | if grant.Database != "" {
101 | // GRANT ON DATABASE, filter out unmanaged databases.
102 | _, exists := postgres.Databases[grant.Database]
103 | if !exists {
104 | continue
105 | }
106 | } else if acl.Scope != "instance" {
107 | grant.Database = i.database.Name
108 | }
109 |
110 | if grant.Schema != "" {
111 | _, known := i.database.Schemas[grant.Schema]
112 | if !known {
113 | continue
114 | }
115 | }
116 |
117 | ch <- grant
118 | }
119 | if err := rows.Err(); err != nil {
120 | i.err = fmt.Errorf("%s: %w", i.acl, err)
121 | return
122 | }
123 | }()
124 | return ch
125 | }
126 |
--------------------------------------------------------------------------------
/internal/privileges/privilege.go:
--------------------------------------------------------------------------------
1 | package privileges
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "strings"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/normalize"
9 | )
10 |
11 | // Privilege references a privilege type and an ACL
12 | //
13 | // Example: {Type: "CONNECT", To: "DATABASE"}
14 | type Privilege struct {
15 | Type string // Privilege type (USAGE, etc.)
16 | On string // ACL (DATABASE, GLOBAL DEFAULT, etc)
17 | Object string // TABLES, SCHEMAS, etc.
18 | }
19 |
20 | func (p Privilege) ACL() string {
21 | return p.On
22 | }
23 |
24 | func NormalizePrivilege(rawPrivilege any) (any, error) {
25 | m, ok := rawPrivilege.(map[string]any)
26 | if !ok {
27 | return nil, fmt.Errorf("bad type")
28 | }
29 |
30 | // DEPRECATED: v6.2 compat
31 | def, _ := m["default"].(string)
32 | if def != "" {
33 | // 6.2 has only scalar type.
34 | m["object"] = m["type"]
35 | m["on"] = fmt.Sprintf("%s DEFAULT", strings.ToUpper(def))
36 | delete(m, "default")
37 | slog.Warn("Deprecated default scope.")
38 | slog.Warn("Use 'object' instead of 'default' in privilege definition.", "on", m["on"], "object", m["object"])
39 | }
40 |
41 | err := normalize.Alias(m, "types", "type")
42 | if err != nil {
43 | return m, err
44 | }
45 | m["types"] = normalize.List(m["types"])
46 |
47 | err = normalize.SpuriousKeys(m, "types", "on", "object")
48 |
49 | return m, err
50 | }
51 |
52 | func DuplicatePrivilege(yaml map[string]any) (privileges []any) {
53 | for _, singleType := range yaml["types"].([]any) {
54 | privilege := make(map[string]any)
55 | privilege["type"] = singleType
56 | for key, value := range yaml {
57 | if key == "types" {
58 | continue
59 | }
60 | privilege[key] = value
61 | }
62 | privileges = append(privileges, privilege)
63 | }
64 | return
65 | }
66 |
--------------------------------------------------------------------------------
/internal/privileges/profile.go:
--------------------------------------------------------------------------------
1 | package privileges
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/tree"
9 | )
10 |
11 | // Profile lists privileges to grant.
12 | //
13 | // e.g. readonly Profile lists SELECT on TABLES, USAGE on SCHEMAS, etc.
14 | //
15 | // Rules references profiles by name and generates grant for each privileges in the profile.
16 | type Profile []Privilege
17 |
18 | func (p Profile) Register(name string) error {
19 | var errs []error
20 | for _, priv := range p {
21 | t := priv.Type
22 | a, ok := acls[priv.On]
23 | if !ok {
24 | errs = append(errs, fmt.Errorf("ACL %s not found", priv.On))
25 | continue
26 | }
27 | if a.Uses("owner") {
28 | // Couple type and object in type. This is hacky.
29 | // A more elegant way would be to send an array of couple type/object.
30 | // Not sure if this is worth the effort.
31 | // See global-default.sql and schema-default.sql for other side.
32 | t = fmt.Sprintf("%s ON %s", t, priv.Object)
33 | }
34 | managedACLs[priv.On] = append(managedACLs[priv.On], t)
35 | }
36 |
37 | profiles[name] = p
38 |
39 | return errors.Join(errs...)
40 | }
41 |
42 | func NormalizeProfiles(value any) (map[string][]any, error) {
43 | m, ok := value.(map[string]any)
44 | if !ok {
45 | return nil, fmt.Errorf("bad type")
46 | }
47 | for key, value := range m {
48 | if value == nil {
49 | return nil, fmt.Errorf(" %s is nil", key)
50 | }
51 | if _, ok := value.([]any); !ok {
52 | return nil, fmt.Errorf(" %s is not a list", key)
53 | }
54 | privileges := []any{}
55 | for _, rawPrivilege := range value.([]any) {
56 | _, ok := rawPrivilege.(string)
57 | if ok {
58 | // profile inclusion
59 | privileges = append(privileges, rawPrivilege)
60 | continue
61 | }
62 | privilege, err := NormalizePrivilege(rawPrivilege)
63 | if err != nil {
64 | return nil, fmt.Errorf("%s: %w", key, err)
65 | }
66 | privileges = append(privileges, DuplicatePrivilege(privilege.(map[string]any))...)
67 | }
68 | m[key] = privileges
69 | }
70 | out := flattenProfiles(m)
71 |
72 | return out, nil
73 | }
74 |
75 | func flattenProfiles(value map[string]any) map[string][]any {
76 | // Map privilege name -> list of privileges to include.
77 | heritance := make(map[string][]string)
78 | // Map privilege name -> list of map[type:... on:...] without inclusion.
79 | refMap := make(map[string][]any)
80 |
81 | // copyRefs moves string items in heritance map and ref maps in refMap.
82 | copyRefs := func(refs map[string]any) {
83 | for key, item := range refs {
84 | list := item.([]any)
85 | for _, item := range list {
86 | s, ok := item.(string)
87 | if ok {
88 | heritance[key] = append(heritance[key], s)
89 | } else {
90 | refMap[key] = append(refMap[key], item)
91 | }
92 | }
93 | }
94 | }
95 |
96 | // First copy builtins
97 | copyRefs(BuiltinsProfiles)
98 | copyRefs(value)
99 |
100 | // Walk the tree and copy parents refs back to children.
101 | for _, priv := range tree.Walk(heritance) {
102 | for _, parent := range heritance[priv] {
103 | refMap[priv] = append(refMap[priv], refMap[parent]...)
104 | }
105 | }
106 |
107 | // Remove builtin
108 | for key := range refMap {
109 | if strings.HasPrefix(key, "__") {
110 | delete(refMap, key)
111 | }
112 | }
113 |
114 | return refMap
115 | }
116 |
117 | var profiles = make(map[string]Profile)
118 |
--------------------------------------------------------------------------------
/internal/privileges/profile_test.go:
--------------------------------------------------------------------------------
1 | package privileges_test
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal/privileges"
8 | "github.com/lithammer/dedent"
9 | "github.com/stretchr/testify/require"
10 | "gopkg.in/yaml.v3"
11 | )
12 |
13 | func TestPrivilegeAlias(t *testing.T) {
14 | r := require.New(t)
15 |
16 | rawYaml := strings.TrimSpace(dedent.Dedent(`
17 | ro:
18 | - type: SELECT
19 | on: ALL TABLES IN SCHEMA
20 | - type: USAGE
21 | on: SCHEMA
22 | rw:
23 | - ro
24 | - type: SELECT
25 | on: ALL SEQUENCES IN SCHEMA
26 | ddl:
27 | - rw
28 | - type: CREATE
29 | on: SCHEMA
30 | `))
31 | var raw any
32 | err := yaml.Unmarshal([]byte(rawYaml), &raw)
33 | r.Nil(err, rawYaml)
34 |
35 | value, err := privileges.NormalizeProfiles(raw)
36 | r.Nil(err)
37 | r.Len(value, 3)
38 | r.Len(value["ro"], 2)
39 | r.Len(value["rw"], 3)
40 | r.Len(value["ddl"], 4)
41 | }
42 |
43 | func TestBuiltinPrivilege(t *testing.T) {
44 | r := require.New(t)
45 |
46 | rawYaml := strings.TrimSpace(dedent.Dedent(`
47 | ro:
48 | - __select_on_tables__
49 | `))
50 | var raw any
51 | err := yaml.Unmarshal([]byte(rawYaml), &raw)
52 | r.Nil(err, rawYaml)
53 |
54 | value, err := privileges.NormalizeProfiles(raw)
55 | r.Nil(err)
56 | r.Len(value, 1)
57 | r.Contains(value, "ro")
58 | ro := value["ro"]
59 | r.Len(ro, 3)
60 | }
61 |
62 | func TestPrivilegeTypes(t *testing.T) {
63 | r := require.New(t)
64 |
65 | rawYaml := strings.TrimSpace(dedent.Dedent(`
66 | ro:
67 | - on: ALL TABLES IN SCHEMA
68 | type: [INSERT, UPDATE, DELETE]
69 | - on: SCHEMA
70 | type: CREATE
71 | rw:
72 | - ro
73 | - types: SELECT
74 | on: ALL TABLES IN SCHEMA
75 | - type: USAGE
76 | on: SCHEMA
77 | `))
78 | var raw any
79 | err := yaml.Unmarshal([]byte(rawYaml), &raw)
80 | r.Nil(err, rawYaml)
81 |
82 | value, err := privileges.NormalizeProfiles(raw)
83 | r.Nil(err)
84 | r.Len(value, 2)
85 | r.Contains(value, "ro")
86 | ro := value["ro"]
87 | r.Len(ro, 4)
88 | }
89 |
--------------------------------------------------------------------------------
/internal/privileges/rule.go:
--------------------------------------------------------------------------------
1 | package privileges
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal/ldap"
8 | "github.com/dalibo/ldap2pg/v6/internal/lists"
9 | "github.com/dalibo/ldap2pg/v6/internal/normalize"
10 | "github.com/dalibo/ldap2pg/v6/internal/pyfmt"
11 | "golang.org/x/exp/maps"
12 | )
13 |
14 | // NormalizeGrantRule from loose YAML
15 | //
16 | // Sets default values. Checks some conflicts.
17 | // Hormonize types for DuplicateGrantRules.
18 | func NormalizeGrantRule(yaml any) (rule map[string]any, err error) {
19 | rule = map[string]any{
20 | "owners": "__auto__",
21 | "schemas": "__all__",
22 | "databases": "__all__",
23 | }
24 |
25 | yamlMap, ok := yaml.(map[string]any)
26 | if !ok {
27 | return nil, fmt.Errorf("bad type")
28 | }
29 |
30 | err = normalize.Alias(yamlMap, "owners", "owner")
31 | if err != nil {
32 | return
33 | }
34 | err = normalize.Alias(yamlMap, "privileges", "privilege")
35 | if err != nil {
36 | return
37 | }
38 | err = normalize.Alias(yamlMap, "databases", "database")
39 | if err != nil {
40 | return
41 | }
42 | err = normalize.Alias(yamlMap, "schemas", "schema")
43 | if err != nil {
44 | return
45 | }
46 | err = normalize.Alias(yamlMap, "roles", "to")
47 | if err != nil {
48 | return
49 | }
50 | err = normalize.Alias(yamlMap, "roles", "grantee")
51 | if err != nil {
52 | return
53 | }
54 | err = normalize.Alias(yamlMap, "roles", "role")
55 | if err != nil {
56 | return
57 | }
58 |
59 | maps.Copy(rule, yamlMap)
60 |
61 | keys := []string{"owners", "privileges", "databases", "schemas", "roles"}
62 | for _, k := range keys {
63 | rule[k], err = normalize.StringList(rule[k])
64 | if err != nil {
65 | return nil, fmt.Errorf("%s: %w", k, err)
66 | }
67 | }
68 | err = normalize.SpuriousKeys(rule, keys...)
69 | return
70 | }
71 |
72 | // DuplicateGrantRules split plurals for mapstructure
73 | func DuplicateGrantRules(yaml map[string]any) (rules []any) {
74 | keys := []string{"owners", "databases", "schemas", "roles", "privileges"}
75 | keys = lists.Filter(keys, func(s string) bool {
76 | return len(yaml[s].([]string)) > 0
77 | })
78 | fields := [][]string{}
79 | for _, k := range keys {
80 | fields = append(fields, yaml[k].([]string))
81 | }
82 | for combination := range lists.Product(fields...) {
83 | rule := map[string]any{}
84 | for i, k := range keys {
85 | rule[strings.TrimSuffix(k, "s")] = combination[i]
86 | }
87 | rules = append(rules, rule)
88 | }
89 | return
90 | }
91 |
92 | // GrantRule is a template to generate wanted GRANTS from data
93 | //
94 | // data comes from LDAP search result or static configuration.
95 | type GrantRule struct {
96 | Owner pyfmt.Format
97 | Privilege pyfmt.Format
98 | Database pyfmt.Format
99 | Schema pyfmt.Format
100 | To pyfmt.Format `mapstructure:"role"`
101 | }
102 |
103 | func (r GrantRule) IsStatic() bool {
104 | return lists.And(r.Formats(), func(f pyfmt.Format) bool { return f.IsStatic() })
105 | }
106 |
107 | func (r GrantRule) Formats() []pyfmt.Format {
108 | return []pyfmt.Format{r.Owner, r.Privilege, r.Database, r.Schema, r.To}
109 | }
110 |
111 | func (r GrantRule) Generate(results *ldap.Result) <-chan Grant {
112 | ch := make(chan Grant)
113 | go func() {
114 | defer close(ch)
115 |
116 | var vchan <-chan map[string]string
117 | if nil == results.Entry {
118 | // Create a single-value chan.
119 | vchanw := make(chan map[string]string, 1)
120 | vchanw <- nil
121 | close(vchanw)
122 | vchan = vchanw
123 | } else {
124 | vchan = results.GenerateValues(r.Owner, r.Privilege, r.Database, r.Schema, r.To)
125 | }
126 |
127 | for values := range vchan {
128 | profile := r.Privilege.Format(values)
129 | for _, priv := range profiles[profile] {
130 | acl := acls[priv.ACL()]
131 | grant := Grant{
132 | ACL: priv.On,
133 | Grantee: r.To.Format(values),
134 | Type: priv.Type,
135 | }
136 |
137 | if acl.Uses("owner") {
138 | grant.Owner = r.Owner.Format(values)
139 | }
140 |
141 | if acl.Uses("schema") {
142 | grant.Schema = r.Schema.Format(values)
143 | }
144 |
145 | if acl.Uses("object") {
146 | grant.Object = priv.Object
147 | }
148 |
149 | if acl.Scope != "instance" || acl.Uses("database") {
150 | grant.Database = r.Database.Format(values)
151 | }
152 |
153 | ch <- grant
154 | }
155 | }
156 | }()
157 | return ch
158 | }
159 |
--------------------------------------------------------------------------------
/internal/privileges/sql/all-functions.sql:
--------------------------------------------------------------------------------
1 | WITH
2 | grants AS (SELECT
3 | pronamespace, grantee, privilege_type,
4 | array_agg(DISTINCT proname ORDER BY proname) AS procs
5 | FROM (
6 | SELECT
7 | pronamespace,
8 | proname,
9 | (aclexplode(COALESCE(proacl, acldefault('f', proowner)))).grantee,
10 | (aclexplode(COALESCE(proacl, acldefault('f', proowner)))).privilege_type
11 | FROM pg_catalog.pg_proc
12 | ) AS grants
13 | GROUP BY 1, 2, 3
14 | ),
15 | namespaces AS (
16 | SELECT
17 | nsp.oid, nsp.nspname,
18 | array_remove(array_agg(DISTINCT pro.proname ORDER BY pro.proname), NULL) AS procs
19 | FROM pg_catalog.pg_namespace nsp
20 | LEFT OUTER JOIN pg_catalog.pg_proc AS pro
21 | ON pro.pronamespace = nsp.oid
22 | WHERE nspname NOT LIKE 'pg\_%temp\_%' AND nspname <> 'pg_toast'
23 | GROUP BY 1, 2
24 | )
25 | SELECT
26 | COALESCE(privilege_type, '') AS "privilege",
27 | nspname AS "schema",
28 | COALESCE(rolname, 'public') AS grantee,
29 | nsp.procs <> COALESCE(grants.procs, ARRAY[]::name[]) AS "partial"
30 | FROM namespaces AS nsp
31 | LEFT OUTER JOIN grants
32 | ON pronamespace = nsp.oid
33 | AND privilege_type = ANY($1)
34 | LEFT OUTER JOIN pg_catalog.pg_roles AS grantee ON grantee.oid = grants.grantee
35 | WHERE NOT (array_length(nsp.procs, 1) IS NOT NULL AND grants.procs IS NULL)
36 | ORDER BY 1, 2
37 |
--------------------------------------------------------------------------------
/internal/privileges/sql/all-sequences.sql:
--------------------------------------------------------------------------------
1 | WITH
2 | namespace_rels AS (
3 | SELECT
4 | nsp.oid,
5 | nsp.nspname,
6 | array_remove(array_agg(rel.relname ORDER BY rel.relname), NULL) AS rels
7 | FROM pg_catalog.pg_namespace nsp
8 | LEFT OUTER JOIN pg_catalog.pg_class AS rel
9 | ON rel.relnamespace = nsp.oid AND relkind = 'S'
10 | WHERE nspname NOT LIKE 'pg\\_%temp\\_%'
11 | AND nspname <> 'pg_toast'
12 | GROUP BY 1, 2
13 | ),
14 | grants AS (
15 | SELECT
16 | relnamespace,
17 | (aclexplode(relacl)).privilege_type,
18 | (aclexplode(relacl)).grantee,
19 | array_agg(relname ORDER BY relname) AS rels
20 | FROM pg_catalog.pg_class
21 | WHERE relkind = 'S'
22 | GROUP BY 1, 2, 3
23 | )
24 | SELECT
25 | COALESCE(privilege_type, '') AS "privilege",
26 | nspname AS "schema",
27 | COALESCE(rolname, 'public') AS grantee,
28 | nsp.rels <> COALESCE(grants.rels, ARRAY[]::name[]) AS "partial"
29 | FROM namespace_rels AS nsp
30 | LEFT OUTER JOIN grants AS grants
31 | ON relnamespace = nsp.oid
32 | AND privilege_type = ANY($1)
33 | LEFT OUTER JOIN pg_catalog.pg_roles AS grantee ON grantee.oid = grants.grantee
34 | WHERE NOT (array_length(nsp.rels, 1) IS NOT NULL AND grants.rels IS NULL)
35 | ORDER BY 1, 2
36 |
--------------------------------------------------------------------------------
/internal/privileges/sql/all-tables.sql:
--------------------------------------------------------------------------------
1 | WITH
2 | namespace_rels AS (
3 | SELECT
4 | nsp.oid,
5 | nsp.nspname,
6 | array_remove(array_agg(rel.relname ORDER BY rel.relname), NULL) AS rels
7 | FROM pg_catalog.pg_namespace nsp
8 | LEFT OUTER JOIN pg_catalog.pg_class AS rel
9 | ON rel.relnamespace = nsp.oid AND relkind IN ('r', 'v', 'f', 'm')
10 | WHERE nspname NOT LIKE 'pg\\_%temp\\_%'
11 | AND nspname <> 'pg_toast'
12 | GROUP BY 1, 2
13 | ),
14 | grants AS (
15 | SELECT
16 | relnamespace,
17 | (aclexplode(relacl)).privilege_type,
18 | (aclexplode(relacl)).grantee,
19 | array_agg(relname ORDER BY relname) AS rels
20 | FROM pg_catalog.pg_class
21 | WHERE relkind IN ('r', 'v', 'f', 'm')
22 | GROUP BY 1, 2, 3
23 | )
24 | SELECT
25 | COALESCE(privilege_type, '') AS "privilege",
26 | nspname AS "schema",
27 | COALESCE(rolname, 'public') AS grantee,
28 | nsp.rels <> COALESCE(grants.rels, ARRAY[]::name[]) AS "partial"
29 | FROM namespace_rels AS nsp
30 | LEFT OUTER JOIN grants AS grants
31 | ON relnamespace = nsp.oid
32 | AND privilege_type = ANY($1)
33 | LEFT OUTER JOIN pg_catalog.pg_roles AS grantee ON grantee.oid = grants.grantee
34 | WHERE NOT (array_length(nsp.rels, 1) IS NOT NULL AND grants.rels IS NULL)
35 | ORDER BY 1, 2
36 |
--------------------------------------------------------------------------------
/internal/privileges/sql/database.sql:
--------------------------------------------------------------------------------
1 | WITH grants AS (
2 | SELECT
3 | datname,
4 | (aclexplode(COALESCE(datacl, acldefault('d', datdba)))).grantor AS grantor,
5 | (aclexplode(COALESCE(datacl, acldefault('d', datdba)))).grantee AS grantee,
6 | (aclexplode(COALESCE(datacl, acldefault('d', datdba)))).privilege_type AS priv
7 | FROM pg_catalog.pg_database
8 | )
9 | SELECT
10 | grants.priv AS "privilege",
11 | grants.datname AS "object",
12 | COALESCE(grantee.rolname, 'public') AS grantee
13 | FROM grants
14 | LEFT OUTER JOIN pg_catalog.pg_roles AS grantee ON grantee.oid = grants.grantee
15 | WHERE "priv" = ANY ($1)
16 | ORDER BY 2, 3, 1
17 |
--------------------------------------------------------------------------------
/internal/privileges/sql/global-default.sql:
--------------------------------------------------------------------------------
1 | WITH hardwired(object, priv) AS (
2 | -- Postgres hardwire the following default privileges on self.
3 | VALUES ('FUNCTIONS', 'EXECUTE'),
4 | ('SEQUENCES', 'USAGE'),
5 | ('SEQUENCES', 'UPDATE'),
6 | ('SEQUENCES', 'SELECT'),
7 | ('TABLES', 'SELECT'),
8 | ('TABLES', 'INSERT'),
9 | ('TABLES', 'UPDATE'),
10 | ('TABLES', 'DELETE'),
11 | ('TABLES', 'TRUNCATE'),
12 | ('TABLES', 'REFERENCES'),
13 | ('TABLES', 'TRIGGER')
14 | ),
15 | grants AS (
16 | -- Produce default privilege on self from hardwired values.
17 | SELECT 0::oid AS nsp,
18 | pg_roles.oid AS owner,
19 | object,
20 | pg_roles.oid AS grantee,
21 | priv
22 | FROM pg_catalog.pg_roles
23 | LEFT OUTER JOIN pg_catalog.pg_default_acl
24 | ON defaclrole = pg_roles.oid
25 | AND defaclnamespace = 0
26 | CROSS JOIN hardwired
27 | WHERE defaclnamespace IS NULL
28 |
29 | UNION ALL
30 |
31 | SELECT 0::oid AS nsp,
32 | pg_roles.oid AS owner,
33 | 'FUNCTIONS' AS object,
34 | 0::oid AS grantee,
35 | 'EXECUTE' AS priv
36 | FROM pg_catalog.pg_roles
37 | LEFT OUTER JOIN pg_catalog.pg_default_acl
38 | ON defaclrole = pg_roles.oid
39 | AND defaclnamespace = 0
40 | WHERE defaclnamespace IS NULL
41 |
42 | UNION ALL
43 |
44 | SELECT defaclnamespace AS nsp,
45 | defaclrole AS owner,
46 | CASE defaclobjtype
47 | WHEN 'f' THEN 'FUNCTIONS'
48 | WHEN 'S' THEN 'SEQUENCES'
49 | WHEN 'r' THEN 'TABLES'
50 | END AS object,
51 | (aclexplode(defaclacl)).grantee AS grantee,
52 | (aclexplode(defaclacl)).privilege_type AS priv
53 | FROM pg_catalog.pg_default_acl
54 | WHERE defaclnamespace = 0
55 | )
56 | -- column order comes from statement:
57 | -- ALTER DEFAULT PRIVILEGES FOR $owner GRANT $privilege ON $object TO $grantee;
58 | SELECT COALESCE(owner.rolname, 'public') AS owner,
59 | grants.priv AS privilege,
60 | grants.object AS object,
61 | COALESCE(grantee.rolname, 'public') AS grantee
62 | FROM grants
63 | LEFT OUTER JOIN pg_catalog.pg_roles AS owner ON owner.oid = grants.owner
64 | LEFT OUTER JOIN pg_catalog.pg_roles AS grantee ON grantee.oid = grants.grantee
65 | WHERE nsp = 0 -- Handle global default privileges only.
66 | AND priv || ' ON ' || grants.object = ANY ($1)
67 | ORDER BY 1, 3, 4, 2
68 |
--------------------------------------------------------------------------------
/internal/privileges/sql/language.sql:
--------------------------------------------------------------------------------
1 | WITH grants AS (
2 | SELECT
3 | lanname,
4 | (aclexplode(COALESCE(lanacl, acldefault('T', lanowner)))).grantor AS grantor,
5 | (aclexplode(COALESCE(lanacl, acldefault('T', lanowner)))).grantee AS grantee,
6 | (aclexplode(COALESCE(lanacl, acldefault('T', lanowner)))).privilege_type AS priv
7 | FROM pg_catalog.pg_language
8 | )
9 | SELECT
10 | grants.priv AS "privilege",
11 | grants.lanname AS "object",
12 | COALESCE(grantee.rolname, 'public') AS grantee
13 | FROM grants
14 | LEFT OUTER JOIN pg_catalog.pg_roles AS grantee ON grantee.oid = grants.grantee
15 | WHERE "priv" = ANY ($1)
16 | ORDER BY 2, 3, 1
17 |
--------------------------------------------------------------------------------
/internal/privileges/sql/schema-default.sql:
--------------------------------------------------------------------------------
1 | WITH grants AS (
2 | SELECT
3 | defaclnamespace AS nsp,
4 | defaclrole AS owner,
5 | CASE defaclobjtype
6 | WHEN 'r' THEN 'TABLES'
7 | WHEN 'S' THEN 'SEQUENCES'
8 | WHEN 'f' THEN 'FUNCTIONS'
9 | END AS "object",
10 | defaclobjtype AS objtype,
11 | (aclexplode(defaclacl)).grantee AS grantee,
12 | (aclexplode(defaclacl)).privilege_type AS priv
13 | FROM pg_catalog.pg_default_acl
14 | )
15 | SELECT
16 | COALESCE(owner.rolname, 'public') AS owner,
17 | "nspname" AS "schema",
18 | grants.priv AS "privilege",
19 | grants."object" AS "object",
20 | COALESCE(grantee.rolname, 'public') AS grantee
21 | FROM grants
22 | LEFT OUTER JOIN pg_catalog.pg_roles AS owner ON owner.oid = grants.owner
23 | LEFT OUTER JOIN pg_catalog.pg_roles AS grantee ON grantee.oid = grants.grantee
24 | LEFT OUTER JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = grants.nsp
25 | WHERE "nspname" IS NOT NULL -- Handle schema default privileges only.
26 | AND "priv" || ' ON ' || grants."object" = ANY ($1)
27 | ORDER BY 1, 2, 4, 3, 5
28 |
--------------------------------------------------------------------------------
/internal/privileges/sql/schema.sql:
--------------------------------------------------------------------------------
1 | WITH grants AS (
2 | SELECT
3 | nspname,
4 | (aclexplode(COALESCE(nspacl, acldefault('n', nspowner)))).grantor AS grantor,
5 | (aclexplode(COALESCE(nspacl, acldefault('n', nspowner)))).grantee AS grantee,
6 | (aclexplode(COALESCE(nspacl, acldefault('n', nspowner)))).privilege_type AS priv
7 | FROM pg_catalog.pg_namespace
8 | )
9 | SELECT
10 | grants.priv AS "privilege",
11 | grants.nspname AS "object",
12 | COALESCE(grantee.rolname, 'public') AS grantee,
13 | FALSE AS partial
14 | FROM grants
15 | LEFT OUTER JOIN pg_catalog.pg_roles AS grantee ON grantee.oid = grants.grantee
16 | WHERE "priv" = ANY ($1)
17 | ORDER BY 2, 3, 1
18 |
--------------------------------------------------------------------------------
/internal/privileges/sync.go:
--------------------------------------------------------------------------------
1 | package privileges
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
7 | mapset "github.com/deckarep/golang-set/v2"
8 | )
9 |
10 | func Sync(ctx context.Context, really bool, dbname string, current, wanted []Grant) (int, error) {
11 | wanted = Expand(wanted, postgres.Databases[dbname])
12 | queries := diff(current, wanted)
13 | return postgres.Apply(ctx, queries, really)
14 | }
15 |
16 | func diff(current, wanted []Grant) <-chan postgres.SyncQuery {
17 | ch := make(chan postgres.SyncQuery)
18 | go func() {
19 | defer close(ch)
20 | wantedSet := mapset.NewSet(wanted...)
21 | // Revoke spurious grants.
22 | for _, grant := range current {
23 | wantedGrant := grant
24 | // Always search a full grant in wanted. If we have a
25 | // partial grant in instance, it will be regranted in
26 | // grant loop.
27 | wantedGrant.Partial = false
28 | // Don't revoke irrelevant ANY ... IN SCHEMA
29 | if wantedSet.Contains(wantedGrant) || grant.Type == "" {
30 | continue
31 | }
32 |
33 | q := grant.FormatQuery(acls[grant.ACL].Revoke)
34 | q.Description = "Revoke privileges."
35 | q.Database = grant.Database
36 | q.LogArgs = []any{"grant", grant}
37 | ch <- q
38 | }
39 |
40 | currentSet := mapset.NewSet(current...)
41 | for _, grant := range wanted {
42 | if currentSet.Contains(grant) {
43 | continue
44 | }
45 |
46 | // Test if a GRANT ON ALL ... IN SCHEMA is irrelevant.
47 | // To avoid regranting each run.
48 | wildcardGrant := grant
49 | wildcardGrant.Grantee = "public"
50 | wildcardGrant.Type = ""
51 | if currentSet.Contains(wildcardGrant) {
52 | continue
53 | }
54 |
55 | q := grant.FormatQuery(acls[grant.ACL].Grant)
56 | q.Description = "Grant privileges."
57 | q.Database = grant.Database
58 | q.LogArgs = []any{"grant", grant}
59 | ch <- q
60 | }
61 | }()
62 | return ch
63 | }
64 |
--------------------------------------------------------------------------------
/internal/pyfmt/format_test.go:
--------------------------------------------------------------------------------
1 | package pyfmt_test
2 |
3 | import (
4 | "log/slog"
5 | "testing"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal"
8 | "github.com/dalibo/ldap2pg/v6/internal/pyfmt"
9 | "github.com/stretchr/testify/suite"
10 | )
11 |
12 | type Suite struct {
13 | suite.Suite
14 | }
15 |
16 | func (suite *Suite) TestParseLiteralOnly() {
17 | r := suite.Require()
18 | f, err := pyfmt.Parse("toto")
19 | r.Nil(err)
20 | r.Equal(0, len(f.Fields))
21 | r.Equal(1, len(f.Sections))
22 | r.Equal("toto", f.Sections[0])
23 | }
24 |
25 | func (suite *Suite) TestParseMethod() {
26 | r := suite.Require()
27 | f, err := pyfmt.Parse("{member.cn.lower()}")
28 | r.Nil(err)
29 | r.Equal(1, len(f.Fields))
30 | r.Equal(1, len(f.Sections))
31 | r.Equal("member.cn", f.Fields[0].FieldName)
32 | r.Equal("lower()", f.Fields[0].Method)
33 | }
34 |
35 | func (suite *Suite) TestParseFieldOnly() {
36 | r := suite.Require()
37 | f, err := pyfmt.Parse("{member.cn}")
38 | r.Nil(err)
39 | r.Equal(1, len(f.Fields))
40 | r.Equal(1, len(f.Sections))
41 | r.Equal("member.cn", f.Fields[0].FieldName)
42 | }
43 |
44 | func (suite *Suite) TestParseCombination() {
45 | r := suite.Require()
46 |
47 | f, err := pyfmt.Parse("ext_{member.cn}")
48 | r.Nil(err)
49 | r.Equal(2, len(f.Sections))
50 | r.Equal("ext_", f.Sections[0])
51 | r.Equal("member.cn", f.Fields[0].FieldName)
52 | }
53 |
54 | func (suite *Suite) TestParseEscaped() {
55 | r := suite.Require()
56 | f, err := pyfmt.Parse("literal {{toto}} pouet")
57 | r.Nil(err)
58 | r.Equal(2, len(f.Sections))
59 | r.Equal(0, len(f.Fields))
60 | r.Equal("literal {", f.Sections[0])
61 | r.Equal("toto} pouet", f.Sections[1])
62 | }
63 |
64 | func (suite *Suite) TestParseUnterminatedField() {
65 | r := suite.Require()
66 | _, err := pyfmt.Parse("literal{unterminated_field")
67 | r.Error(err)
68 | }
69 |
70 | func (suite *Suite) TestParseSingleBrace() {
71 | r := suite.Require()
72 | _, err := pyfmt.Parse("{")
73 | r.Error(err)
74 | }
75 |
76 | func (suite *Suite) TestParseConversion() {
77 | r := suite.Require()
78 | f, err := pyfmt.Parse("{!r}")
79 | r.Nil(err)
80 | r.Equal(1, len(f.Fields))
81 | r.Equal("", f.Fields[0].FieldName)
82 | r.Equal("r", f.Fields[0].Conversion)
83 | }
84 |
85 | func (suite *Suite) TestParseSpec() {
86 | r := suite.Require()
87 | f, err := pyfmt.Parse("{:>30}")
88 | r.Nil(err)
89 | r.Equal(1, len(f.Fields))
90 | r.Equal(&pyfmt.Field{FieldName: "", Conversion: "", FormatSpec: ">30"}, f.Fields[0])
91 | }
92 |
93 | func (suite *Suite) TestParseConversionAndSpec() {
94 | r := suite.Require()
95 |
96 | f, err := pyfmt.Parse("{0!r:>30}")
97 | r.Nil(err)
98 | r.Equal(1, len(f.Fields))
99 | r.Equal(&pyfmt.Field{FieldName: "0", Conversion: "r", FormatSpec: ">30"}, f.Fields[0])
100 | }
101 |
102 | func (suite *Suite) TestFormat() {
103 | r := suite.Require()
104 |
105 | f, err := pyfmt.Parse("ext_{dn.cn}_{member.cn.upper()}")
106 | r.Nil(err)
107 |
108 | s := f.Format(map[string]string{
109 | "dn.cn": "dba",
110 | "member.cn": "alice",
111 | })
112 | r.Equal("ext_dba_ALICE", s)
113 | }
114 |
115 | func Test(t *testing.T) {
116 | if testing.Verbose() {
117 | internal.SetLoggingHandler(slog.LevelDebug, false)
118 | } else {
119 | internal.SetLoggingHandler(slog.LevelWarn, false)
120 | }
121 | suite.Run(t, new(Suite))
122 | }
123 |
--------------------------------------------------------------------------------
/internal/role/config.go:
--------------------------------------------------------------------------------
1 | package role
2 |
3 | import "strings"
4 |
5 | type Config map[string]string
6 |
7 | func (c Config) Parse(rows []string) {
8 | for _, row := range rows {
9 | parts := strings.SplitN(row, "=", 2)
10 | if len(parts) != 2 {
11 | continue
12 | }
13 | c[parts[0]] = parts[1]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/internal/role/diff.go:
--------------------------------------------------------------------------------
1 | package role
2 |
3 | import (
4 | "log/slog"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/postgres"
7 | )
8 |
9 | func Diff(all, managed, wanted Map, fallbackOwner string) <-chan postgres.SyncQuery {
10 | ch := make(chan postgres.SyncQuery)
11 | go func() {
12 | defer close(ch)
13 | // Create missing roles.
14 | for _, name := range wanted.Flatten() {
15 | role := wanted[name]
16 | if other, ok := all[name]; ok {
17 | // Check for existing role, even if unmanaged.
18 | if _, ok := managed[name]; !ok {
19 | slog.Warn("Reusing unmanaged role. Ensure managed_roles_query returns all wanted roles.", "role", name)
20 | }
21 | sendQueries(other.Alter(role), ch)
22 | } else {
23 | sendQueries(role.Create(), ch)
24 | }
25 | }
26 |
27 | // Drop spurious roles.
28 | // Only from managed roles.
29 | for name := range managed {
30 | if _, ok := wanted[name]; ok {
31 | continue
32 | }
33 |
34 | if name == "public" {
35 | continue
36 | }
37 |
38 | role, ok := all[name]
39 | if !ok {
40 | // Already dropped. ldap2pg hits this case whan
41 | // ManagedRoles is static.
42 | continue
43 | }
44 |
45 | sendQueries(role.Drop(fallbackOwner), ch)
46 | }
47 | }()
48 | return ch
49 | }
50 |
51 | func sendQueries(queries []postgres.SyncQuery, ch chan postgres.SyncQuery) {
52 | for _, q := range queries {
53 | ch <- q
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/internal/role/map.go:
--------------------------------------------------------------------------------
1 | package role
2 |
3 | import (
4 | "log/slog"
5 |
6 | mapset "github.com/deckarep/golang-set/v2"
7 | )
8 |
9 | type Map map[string]Role
10 |
11 | func (m Map) Check() error {
12 | for _, role := range m {
13 | err := role.Check(m, nil)
14 | if err != nil {
15 | return err
16 | }
17 | }
18 | return nil
19 | }
20 |
21 | func (m Map) Flatten() []string {
22 | var names []string
23 | seen := mapset.NewSet[string]()
24 | for _, role := range m {
25 | names = append(names, m.flattenRole(role, &seen)...)
26 | }
27 | return names
28 | }
29 |
30 | func (m Map) flattenRole(r Role, seen *mapset.Set[string]) []string {
31 | var names []string
32 | if (*seen).Contains(r.Name) {
33 | return names
34 | }
35 | for _, membership := range r.Parents {
36 | parent, ok := m[membership.Name]
37 | if !ok {
38 | slog.Debug("Role inherits unmanaged parent.", "role", r.Name, "parent", membership.Name)
39 | continue
40 | }
41 | names = append(names, m.flattenRole(parent, seen)...)
42 |
43 | (*seen).Add(r.Name)
44 | }
45 | names = append(names, r.Name)
46 | return names
47 | }
48 |
--------------------------------------------------------------------------------
/internal/role/membership.go:
--------------------------------------------------------------------------------
1 | package role
2 |
3 | type Membership struct {
4 | Grantor string
5 | Name string
6 | }
7 |
8 | func (m Membership) String() string {
9 | return m.Name
10 | }
11 |
12 | func (r Role) MemberOf(p string) bool {
13 | for _, m := range r.Parents {
14 | if p == m.Name {
15 | return true
16 | }
17 | }
18 | return false
19 | }
20 |
21 | func (r Role) MissingParents(o []Membership) (out []Membership) {
22 | for _, m := range o {
23 | if !r.MemberOf(m.Name) {
24 | out = append(out, m)
25 | }
26 | }
27 | return
28 | }
29 |
--------------------------------------------------------------------------------
/internal/role/membership_test.go:
--------------------------------------------------------------------------------
1 | package role_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/role"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestMissingParents(t *testing.T) {
11 | r := require.New(t)
12 |
13 | current := role.Role{
14 | Name: "toto",
15 | Parents: []role.Membership{
16 | {Name: "parent1"},
17 | },
18 | }
19 | wanted := role.Role{
20 | Name: "toto",
21 | Parents: []role.Membership{
22 | {Name: "parent1"},
23 | {Name: "parent2"},
24 | },
25 | }
26 |
27 | missing := current.MissingParents(wanted.Parents)
28 | r.Len(missing, 1)
29 | }
30 |
31 | func TestLoop(t *testing.T) {
32 | r := require.New(t)
33 |
34 | toto := role.Role{
35 | Name: "toto",
36 | Parents: []role.Membership{
37 | {Name: "toto"},
38 | },
39 | }
40 | m := make(role.Map)
41 | m[toto.Name] = toto
42 | err := m.Check()
43 | r.Error(err)
44 | }
45 |
--------------------------------------------------------------------------------
/internal/role/options.go:
--------------------------------------------------------------------------------
1 | package role
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "reflect"
7 | "slices"
8 | "strings"
9 | )
10 |
11 | type Options struct {
12 | Super bool `column:"rolsuper" mapstructure:"SUPERUSER"`
13 | CreateDB bool `column:"rolcreatedb" mapstructure:"CREATEDB"`
14 | CreateRole bool `column:"rolcreaterole" mapstructure:"CREATEROLE"`
15 | Inherit bool `column:"rolinherit" mapstructure:"INHERIT"`
16 | CanLogin bool `column:"rolcanlogin" mapstructure:"LOGIN"`
17 | Replication bool `column:"rolreplication" mapstructure:"REPLICATION"`
18 | ByPassRLS bool `column:"rolbypassrls" mapstructure:"BYPASSRLS"`
19 | ConnLimit int `column:"rolconnlimit" mapstructure:"CONNECTION LIMIT"`
20 | }
21 |
22 | func (o Options) String() string {
23 | v := reflect.ValueOf(o)
24 | t := v.Type()
25 | var b strings.Builder
26 | for _, f := range reflect.VisibleFields(t) {
27 | if !isColumnEnabled(f.Tag.Get("column")) {
28 | continue
29 | }
30 | if b.Len() > 0 {
31 | b.WriteByte(' ')
32 | }
33 | fv := v.FieldByName(f.Name)
34 | switch f.Type.Kind() {
35 | case reflect.Bool:
36 | writeBoolOption(&b, fv.Bool(), f.Tag.Get("mapstructure"))
37 | case reflect.Int:
38 | fmt.Fprintf(&b, "%s %d", f.Tag.Get("mapstructure"), fv.Int())
39 | }
40 | }
41 | return b.String()
42 | }
43 |
44 | // Diff returns the SQL to match wanted role options
45 | func (o Options) Diff(wanted Options) string {
46 | v := reflect.ValueOf(o)
47 | wantedV := reflect.ValueOf(wanted)
48 | var b strings.Builder
49 | for _, f := range reflect.VisibleFields(v.Type()) {
50 | if !isColumnEnabled(f.Tag.Get("column")) {
51 | continue
52 | }
53 | fv := v.FieldByName(f.Name)
54 | wantedFV := wantedV.FieldByName(f.Name)
55 | switch f.Type.Kind() {
56 | case reflect.Bool:
57 | if fv.Bool() != wantedFV.Bool() {
58 | if b.Len() > 0 {
59 | b.WriteByte(' ')
60 | }
61 | writeBoolOption(&b, wantedFV.Bool(), f.Tag.Get("mapstructure"))
62 | }
63 | case reflect.Int:
64 | i := wantedFV.Int()
65 | if i != fv.Int() {
66 | if b.Len() > 0 {
67 | b.WriteByte(' ')
68 | }
69 | fmt.Fprintf(&b, "%s %d", f.Tag.Get("mapstructure"), i)
70 | }
71 | }
72 | }
73 | return b.String()
74 | }
75 |
76 | func (o *Options) LoadRow(row []any) {
77 | for i, value := range row {
78 | colName := getColumnNameByOrder(i)
79 | switch colName {
80 | case "rolbypassrls":
81 | o.ByPassRLS = value.(bool)
82 | case "rolcanlogin":
83 | o.CanLogin = value.(bool)
84 | case "rolconnlimit":
85 | o.ConnLimit = int(value.(int32))
86 | case "rolcreatedb":
87 | o.CreateDB = value.(bool)
88 | case "rolcreaterole":
89 | o.CreateRole = value.(bool)
90 | case "rolinherit":
91 | o.Inherit = value.(bool)
92 | case "rolreplication":
93 | o.Replication = value.(bool)
94 | case "rolsuper":
95 | o.Super = value.(bool)
96 | }
97 | }
98 | }
99 |
100 | // Global state of role columns in inspected instance.
101 | var instanceColumns struct {
102 | availability map[string]bool
103 | order []string
104 | }
105 |
106 | var privilegedColumns = []string{"rolsuper", "rolreplication", "rolbypassrls"}
107 |
108 | func ProcessColumns(columns []string, super bool) []string {
109 | instanceColumns.availability = make(map[string]bool)
110 | var knownColumns []string
111 |
112 | t := reflect.TypeOf(Options{})
113 | for _, f := range reflect.VisibleFields(t) {
114 | name := f.Tag.Get("column")
115 | knownColumns = append(knownColumns, name)
116 | instanceColumns.availability[name] = false
117 | }
118 |
119 | for _, name := range columns {
120 | if !slices.Contains(knownColumns, name) {
121 | slog.Debug("Ignoring unhandled role option.", "column", name)
122 | continue
123 | }
124 | if !super && slices.Contains(privilegedColumns, name) {
125 | slog.Debug("Ignoring privileged role column", "column", name)
126 | continue
127 | }
128 | instanceColumns.availability[name] = true
129 | instanceColumns.order = append(instanceColumns.order, name)
130 | }
131 | return instanceColumns.order
132 | }
133 |
134 | func getColumnNameByOrder(order int) string {
135 | return instanceColumns.order[order]
136 | }
137 |
138 | func isColumnEnabled(name string) bool {
139 | available, ok := instanceColumns.availability[name]
140 | return ok && available
141 | }
142 |
143 | func writeBoolOption(b *strings.Builder, value bool, keyword string) {
144 | if !value {
145 | b.WriteString("NO")
146 | }
147 | b.WriteString(keyword)
148 | }
149 |
--------------------------------------------------------------------------------
/internal/role/options_test.go:
--------------------------------------------------------------------------------
1 | package role
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestOptionsDiff(t *testing.T) {
10 | r := require.New(t)
11 |
12 | ProcessColumns([]string{
13 | "rolsuper",
14 | "rolcreatedb",
15 | "rolcreaterole",
16 | "rolinherit",
17 | "rolreplication",
18 | "rolconnlimit",
19 | "rolbypassrls",
20 | "rolcanlogin",
21 | }, true)
22 |
23 | o := Options{Super: true}
24 | diff := o.Diff(Options{ConnLimit: -1})
25 | r.Equal("NOSUPERUSER CONNECTION LIMIT -1", diff)
26 | }
27 |
28 | func TestUnhandledOptions(t *testing.T) {
29 | r := require.New(t)
30 |
31 | ProcessColumns([]string{
32 | "rolsuper",
33 | "rolcreatedb",
34 | "rolcreaterole",
35 | "rolinherit",
36 | "rolreplication",
37 | "rolconnlimit",
38 | "rolbypassrls",
39 | "rolcanlogin",
40 | "rolcustomopt",
41 | }, false)
42 |
43 | r.NotContains(instanceColumns.order, "rolcustomopt")
44 | o := Options{Super: true}
45 | diff := o.Diff(Options{ConnLimit: -1})
46 | r.Equal("CONNECTION LIMIT -1", diff)
47 | }
48 |
--------------------------------------------------------------------------------
/internal/tree/walk.go:
--------------------------------------------------------------------------------
1 | package tree
2 |
3 | import (
4 | mapset "github.com/deckarep/golang-set/v2"
5 | "golang.org/x/exp/maps"
6 | "golang.org/x/exp/slices"
7 | )
8 |
9 | // Walk returns the list of string in topological order.
10 | //
11 | // heritance maps entity -> list of parents.
12 | func Walk(heritance map[string][]string) (out []string) {
13 | seen := mapset.NewSet[string]()
14 | keys := maps.Keys(heritance)
15 | slices.Sort(keys)
16 | for _, key := range keys {
17 | out = append(out, walkOne(key, heritance, &seen)...)
18 | }
19 | return
20 | }
21 |
22 | func walkOne(name string, groups map[string][]string, seen *mapset.Set[string]) (order []string) {
23 | if (*seen).Contains(name) {
24 | return nil
25 | }
26 |
27 | parents := groups[name]
28 | slices.Sort(parents)
29 |
30 | for _, parent := range groups[name] {
31 | order = append(order, walkOne(parent, groups, seen)...)
32 | }
33 | order = append(order, name)
34 | (*seen).Add(name)
35 | return
36 | }
37 |
--------------------------------------------------------------------------------
/internal/tree/walk_test.go:
--------------------------------------------------------------------------------
1 | package tree_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/dalibo/ldap2pg/v6/internal/tree"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestWalk(t *testing.T) {
11 | r := require.New(t)
12 | groups := map[string][]string{
13 | // usage is before select, must be sorted.
14 | "ro": {"__connect__", "__usage__", "__select__"},
15 | // subgroup is before rw, must be sorted.
16 | "subgroup": {"__select__", "__usage__"},
17 | "rw": {"ro"},
18 | "ddl": {"rw"},
19 | }
20 | order := tree.Walk(groups)
21 | wanted := []string{"__connect__", "__select__", "__usage__", "ro", "rw", "ddl", "subgroup"}
22 | r.Equal(wanted, order)
23 | }
24 |
--------------------------------------------------------------------------------
/internal/wanted/map.go:
--------------------------------------------------------------------------------
1 | package wanted
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log/slog"
7 |
8 | "github.com/dalibo/ldap2pg/v6/internal/ldap"
9 | "github.com/dalibo/ldap2pg/v6/internal/lists"
10 | "github.com/dalibo/ldap2pg/v6/internal/privileges"
11 | "github.com/dalibo/ldap2pg/v6/internal/role"
12 | )
13 |
14 | // Rules holds a set of rules to generate wanted state.
15 | type Rules []Step
16 |
17 | func (m Rules) HasLDAPSearches() bool {
18 | for _, item := range m {
19 | if item.HasLDAPSearch() {
20 | return true
21 | }
22 | }
23 | return false
24 | }
25 |
26 | func (m Rules) SplitStaticRules() (newMap Rules) {
27 | newMap = make(Rules, 0)
28 | for _, item := range m {
29 | newMap = append(newMap, item.SplitStaticItems()...)
30 | }
31 | return
32 | }
33 |
34 | func (m Rules) DropGrants() (out Rules) {
35 | out = make(Rules, 0)
36 | for _, item := range m {
37 | item.GrantRules = nil
38 | if 0 < len(item.RoleRules) {
39 | out = append(out, item)
40 | } else {
41 | slog.Debug("Dropping sync map item with grants.", "item", item)
42 | }
43 | }
44 | return
45 | }
46 |
47 | func (m Rules) Run(blacklist lists.Blacklist) (roles role.Map, grants map[string][]privileges.Grant, err error) {
48 | var errList []error
49 | var ldapc ldap.Client
50 | if m.HasLDAPSearches() {
51 | ldapc, err = ldap.Connect()
52 | if err != nil {
53 | return nil, nil, err
54 | }
55 | defer ldapc.Conn.Close()
56 | }
57 |
58 | roles = make(map[string]role.Role)
59 | grants = make(map[string][]privileges.Grant)
60 | for i, item := range m {
61 | if item.Description != "" {
62 | slog.Info(item.Description)
63 | } else {
64 | slog.Debug("Processing sync map item.", "item", i)
65 | }
66 |
67 | for res := range item.search(ldapc) {
68 | if res.err != nil {
69 | slog.Error("Search error. Keep going.", "err", res.err)
70 | errList = append(errList, res.err)
71 | continue
72 | }
73 |
74 | for role := range item.generateRoles(&res.result) {
75 | if role.Name == "" {
76 | continue
77 | }
78 | pattern := blacklist.MatchString(role.Name)
79 | if pattern != "" {
80 | slog.Debug(
81 | "Ignoring blacklisted wanted role.",
82 | "role", role.Name, "pattern", pattern)
83 | continue
84 | }
85 | current, exists := roles[role.Name]
86 | if exists {
87 | current.Merge(role)
88 | role = current
89 | slog.Debug("Updated wanted role.",
90 | "name", role.Name, "options", role.Options,
91 | "parents", role.Parents, "comment", role.Comment)
92 | } else {
93 | slog.Debug("Wants role.",
94 | "name", role.Name, "options", role.Options,
95 | "parents", role.Parents, "comment", role.Comment)
96 | }
97 | roles[role.Name] = role
98 | }
99 |
100 | for grant := range item.generateGrants(&res.result) {
101 | pattern := blacklist.MatchString(grant.Grantee)
102 | if pattern != "" {
103 | slog.Debug(
104 | "Ignoring grant to blacklisted role.",
105 | "to", grant.Grantee, "pattern", pattern)
106 | continue
107 | }
108 | _, exists := roles[grant.Grantee]
109 | if !exists {
110 | slog.Error("Generated grant on unwanted role.", "grant", grant, "role", grant.Grantee)
111 | errList = append(errList, fmt.Errorf("grant on unknown role"))
112 | continue
113 | }
114 | grants[grant.ACL] = append(grants[grant.ACL], grant)
115 | }
116 | }
117 | }
118 |
119 | err = roles.Check()
120 | if err != nil {
121 | errList = append(errList, err)
122 | }
123 |
124 | if 0 < len(errList) {
125 | err = errors.Join(errList...)
126 | }
127 | return
128 | }
129 |
--------------------------------------------------------------------------------
/internal/wanted/rules.go:
--------------------------------------------------------------------------------
1 | package wanted
2 |
3 | import (
4 | "github.com/dalibo/ldap2pg/v6/internal/ldap"
5 | "github.com/dalibo/ldap2pg/v6/internal/lists"
6 | "github.com/dalibo/ldap2pg/v6/internal/pyfmt"
7 | "github.com/dalibo/ldap2pg/v6/internal/role"
8 | )
9 |
10 | type RoleRule struct {
11 | Name pyfmt.Format
12 | Options role.Options
13 | Comment pyfmt.Format
14 | Parents []MembershipRule
15 | Config *role.Config
16 | BeforeCreate pyfmt.Format `mapstructure:"before_create"`
17 | AfterCreate pyfmt.Format `mapstructure:"after_create"`
18 | }
19 |
20 | func (r RoleRule) IsStatic() bool {
21 | return lists.And(r.Formats(), func(f pyfmt.Format) bool { return f.IsStatic() })
22 | }
23 |
24 | func (r RoleRule) Formats() []pyfmt.Format {
25 | fmts := []pyfmt.Format{r.Name, r.Comment, r.BeforeCreate, r.AfterCreate}
26 | for _, p := range r.Parents {
27 | fmts = append(fmts, p.Name)
28 | }
29 | return fmts
30 | }
31 |
32 | func (r RoleRule) Generate(results *ldap.Result) <-chan role.Role {
33 | ch := make(chan role.Role)
34 | go func() {
35 | defer close(ch)
36 | parents := []role.Membership{}
37 | for _, m := range r.Parents {
38 | if results.Entry == nil || len(m.Name.Fields) == 0 {
39 | // Static case.
40 | parents = append(parents, m.Generate(nil))
41 | } else {
42 | // Dynamic case.
43 | for values := range results.GenerateValues(m.Name) {
44 | parents = append(parents, m.Generate(values))
45 | }
46 | }
47 | }
48 |
49 | if nil == results.Entry {
50 | // Case static rule.
51 | role := role.Role{
52 | Name: r.Name.String(),
53 | Comment: r.Comment.String(),
54 | Options: r.Options,
55 | Parents: parents,
56 | Config: r.Config,
57 | BeforeCreate: r.BeforeCreate.String(),
58 | AfterCreate: r.AfterCreate.String(),
59 | }
60 | ch <- role
61 | } else {
62 | // Case dynamic rule.
63 | for values := range results.GenerateValues(r.Name, r.Comment, r.BeforeCreate, r.AfterCreate) {
64 | role := role.Role{}
65 | role.Name = r.Name.Format(values)
66 | role.Comment = r.Comment.Format(values)
67 | role.Options = r.Options
68 | role.Parents = append(parents[0:0], parents...) // copy
69 | role.BeforeCreate = r.BeforeCreate.Format(values)
70 | role.AfterCreate = r.AfterCreate.Format(values)
71 | ch <- role
72 | }
73 | }
74 | }()
75 | return ch
76 | }
77 |
78 | type MembershipRule struct {
79 | Name pyfmt.Format
80 | }
81 |
82 | func (m MembershipRule) String() string {
83 | return m.Name.String()
84 | }
85 |
86 | func (m MembershipRule) IsStatic() bool {
87 | return m.Name.IsStatic()
88 | }
89 |
90 | func (m MembershipRule) Generate(values map[string]string) role.Membership {
91 | return role.Membership{
92 | Name: m.Name.Format(values),
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/internal/wanted/step_test.go:
--------------------------------------------------------------------------------
1 | package wanted_test
2 |
3 | import (
4 | "github.com/dalibo/ldap2pg/v6/internal/config"
5 | "github.com/lithammer/dedent"
6 | "gopkg.in/yaml.v3"
7 | )
8 |
9 | // test helper to build a config object from a YAML string.
10 | //
11 | // rawYAML MUST BE NORMALIZED. No alias, no single entries, etc.
12 | func configFromYAML(rawYAML string) (c config.Config) {
13 | rawYAML = dedent.Dedent(rawYAML)
14 | var out any
15 | _ = yaml.Unmarshal([]byte(rawYAML), &out)
16 | _ = c.DecodeYaml(out)
17 | return
18 | }
19 |
20 | func (suite *Suite) TestItemStatic() {
21 | r := suite.Require()
22 |
23 | c := configFromYAML(`
24 | rules:
25 | - roles:
26 | - name: "toto"
27 | `)
28 | i := c.Rules[0]
29 | i.InferAttributes()
30 | r.False(i.HasLDAPSearch())
31 | r.False(i.HasSubsearch())
32 | }
33 |
34 | func (suite *Suite) TestItemLdapAnalyze() {
35 | r := suite.Require()
36 |
37 | c := configFromYAML(`
38 | rules:
39 | - ldapsearch:
40 | base: cn=toto
41 | roles:
42 | - name: "{member.sAMAccountName}"
43 | `)
44 | i := c.Rules[0]
45 | i.InferAttributes()
46 | r.True(i.HasLDAPSearch())
47 | r.True(i.HasSubsearch())
48 | r.Equal("member", i.LdapSearch.SubsearchAttribute())
49 | }
50 |
51 | func (suite *Suite) TestSyncItemReplaceMemberAsMemberDotDN() {
52 | r := suite.Require()
53 |
54 | c := configFromYAML(`
55 | rules:
56 | - ldapsearch:
57 | base: cn=toto
58 | roles:
59 | - name: "{member.sAMAccountName}"
60 | comment: "{member}"
61 | `)
62 | i := c.Rules[0]
63 | i.InferAttributes()
64 | i.ReplaceAttributeAsSubentryField()
65 | for f := range i.IterFields() {
66 | if f.FieldName == "member.dn" {
67 | return
68 | }
69 | }
70 | r.Fail("member.dn not found")
71 | }
72 |
--------------------------------------------------------------------------------
/internal/wanted/wanted_test.go:
--------------------------------------------------------------------------------
1 | package wanted_test
2 |
3 | import (
4 | "log/slog"
5 | "testing"
6 |
7 | "github.com/dalibo/ldap2pg/v6/internal"
8 | "github.com/stretchr/testify/suite"
9 | )
10 |
11 | type Suite struct {
12 | suite.Suite
13 | }
14 |
15 | func TestConfig(t *testing.T) {
16 | if testing.Verbose() {
17 | internal.SetLoggingHandler(slog.LevelDebug, false)
18 | } else {
19 | internal.SetLoggingHandler(slog.LevelWarn, false)
20 | }
21 | suite.Run(t, new(Suite))
22 | }
23 |
--------------------------------------------------------------------------------
/ldap2pg.yml:
--------------------------------------------------------------------------------
1 | #
2 | #
3 | # L D A P 2 P G S A M P L E C O N F I G U R A T I O N
4 | #
5 | #
6 | # This is a starting point configuration file for ldap2pg.yml. Including static
7 | # roles, groups, privilege and LDAP search.
8 | #
9 | # This configuration assumes the following principles:
10 | #
11 | # - All LDAP users are grouped in `ldap_roles` group.
12 | # - Read privileges are granted to `readers` group.
13 | # - Write privileges are granted to `writers` group.
14 | # - DDL privileges are granted to `owners` group.
15 | # - We have one or more databases with public and maybe a schema.
16 | # - Grants are not specific to a schema. Once you're writer in a database, you
17 | # are writer to all schemas in it.
18 | #
19 | # The LDAP directory content is described in test/fixtures/openldap-data.ldif
20 | #
21 | # Adapt to your needs! See also full documentation on how to configure ldap2pg
22 | # at https://ldap2pg.readthedocs.io/en/latest/config/.
23 | #
24 | # Don't hesitate to suggest improvements for this starting configuration at
25 | # https://github.com/dalibo/ldap2pg/issues/new . Thanks for your contribution !
26 | #
27 |
28 | #
29 | # File format version. Allows ldap2pg to check whether the file is supported.
30 | #
31 | version: 6
32 |
33 | #
34 | # 1. P O S T G R E S I N S P E C T I O N
35 | #
36 | # See https://ldap2pg.readthedocs.io/en/latest/postgres/
37 | #
38 | postgres:
39 | roles_blacklist_query: [nominal, postgres, pg_*]
40 | databases_query: [nominal]
41 |
42 | #
43 | # 2. P R I V I L E G E S D E F I N I T I O N
44 | #
45 | # See https://ldap2pg.readthedocs.io/en/latest/privileges/. Privileges wrapped
46 | # in double underscores are builtin privilege profiles. See
47 | # https://ldap2pg.readthedocs.io/en/latest/builtins/ for a documentation of
48 | # each of them.
49 | #
50 |
51 | privileges:
52 | # Define `ro` privilege group with read-only grants
53 | ro:
54 | - __connect__
55 | - __select_on_tables__
56 | - __select_on_sequences__
57 | - __usage_on_schemas__
58 | - __usage_on_types__
59 |
60 | # `rw` privilege group lists write-only grants
61 | rw:
62 | - __temporary__
63 | - __all_on_tables__
64 | - __all_on_sequences__
65 |
66 | # `ddl` privilege group lists DDL only grants.
67 | ddl:
68 | - __create_on_schemas__
69 |
70 |
71 | #
72 | # 3. S Y N C H R O N I S A T I O N M A P
73 | #
74 | # This list contains rules to declare roles and grants. Each role or grant rule
75 | # can be templated with attributes from LDAP entries returned by a search
76 | # query.
77 | #
78 | # Any role found in cluster and not generated by rules will be dropped. Any
79 | # grant found in cluster and not generated by rules will be revoked.
80 | #
81 |
82 | rules:
83 | - description: "Setup static roles and grants."
84 | roles:
85 | - names:
86 | - readers
87 | options: NOLOGIN
88 | - name: writers
89 | # Grant reading to writers
90 | parent: readers
91 | options: NOLOGIN
92 | - name: owners
93 | # Grant read/write to owners
94 | parent: writers
95 | options: NOLOGIN
96 |
97 | grant:
98 | - privilege: ro
99 | role: readers
100 | # Scope to a single schema
101 | schemas: nominal
102 | - privilege: rw
103 | role: writers
104 | - privilege: ddl
105 | role: owners
106 |
107 | - description: "Search LDAP to create readers, writers and owners."
108 | ldapsearch:
109 | base: cn=users,dc=bridoulou,dc=fr
110 | filter: "
111 | (|
112 | (cn=owners)
113 | (cn=readers)
114 | (cn=writers)
115 | )
116 | "
117 | role:
118 | name: '{member.cn}'
119 | options: LOGIN
120 | parent: "{cn}"
121 |
--------------------------------------------------------------------------------
/ldaprc:
--------------------------------------------------------------------------------
1 | BASE cn=users,dc=bridoulou,dc=fr
2 | BINDDN cn=administrator,cn=users,dc=bridoulou,dc=fr
3 | TLS_REQCERT allow
4 | NETWORK_TIMEOUT 5
5 | TIMEOUT 5
6 | REFERRALS off
7 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/dalibo/ldap2pg/v6/internal/cmd"
5 | )
6 |
7 | var version string // set by goreleaser
8 |
9 | func init() {
10 | cmd.Version = version
11 | }
12 |
13 | func main() {
14 | cmd.Main()
15 | }
16 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | # metadata
2 | site_name: ldap2pg documentation
3 | site_description: Synchronise Postgres roles from LDAP directory
4 | site_author: Dalibo Labs
5 | site_url: https://ldap2pg.readthedocs.io/
6 | copyright: © Dalibo 2017-2022
7 | repo_name: dalibo/ldap2pg
8 | repo_url: https://github.com/dalibo/ldap2pg
9 |
10 | theme:
11 | favicon: img/logo-80.png
12 | logo: img/logo-white.png
13 | name: material
14 | features:
15 | - navigation.instant
16 | - navigation.tabs
17 | - navigation.tabs.sticky
18 | - navigation.top
19 | - navigation.tracking
20 | - search.suggest
21 | - search.highlight
22 |
23 | extra:
24 | social:
25 | - icon: fontawesome/brands/github
26 | link: https://github.com/dalibo/ldap2pg
27 | - icon: fontawesome/brands/docker
28 | link: https://hub.docker.com/r/dalibo/ldap2pg
29 | - icon: fontawesome/brands/twitter
30 | link: https://twitter.com/DaliboLabs
31 | - icon: fontawesome/brands/mastodon
32 | link: https://mastodon.online/@dalibo
33 |
34 | extra_css:
35 | - ldap2pg.css
36 |
37 | nav:
38 | - Home: index.md
39 | - Installation:
40 | - Installation: install.md
41 | - Changelog: changelog.md
42 | - Configuration:
43 | - CLI: cli.md
44 | - ldap2pg.yml: config.md
45 | - Inspecting Cluster: postgres.md
46 | - Managing Roles: roles.md
47 | - Searching Directory: ldap.md
48 | - Managing Privileges: privileges.md
49 | - Builtins Privileges: builtins.md
50 | - Guides:
51 | - Cookbook: guides/cookbook.md
52 | - Custom ACL: guides/acls.md
53 | - Hacking: hacking.md
54 |
55 | site_dir: dist/docs/
56 |
57 | # Markdown settings
58 | markdown_extensions:
59 | - admonition
60 | - attr_list
61 | - pymdownx.highlight:
62 | anchor_linenums: true
63 | - pymdownx.inlinehilite
64 | - pymdownx.snippets
65 | - pymdownx.superfences
66 | - pymdownx.blocks.tab:
67 | alternate_style: true
68 | - sane_lists
69 | - smarty
70 | - toc:
71 | permalink: yes
72 | - wikilinks
73 |
74 |
75 | plugins:
76 | - search:
77 | # From material docs: word, symbols and version.
78 | # https://squidfunk.github.io/mkdocs-material/setup/setting-up-site-search/#+search.separator
79 | separator: '[\s\-,:!=\[\]()"/]+|(?!\b)(?=[A-Z][a-z])|\.(?!\d)|&[lg]t;'
80 | - exclude:
81 | glob:
82 | # make docs/builtins.md produces a .tmp.
83 | # Exclude it to avoid mkdocs serve to fail with a file not found.
84 | - "*.tmp"
85 |
--------------------------------------------------------------------------------
/test/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | test:
5 | image: dalibo/buildpack-python:${DIST-rockylinux8}
6 | volumes:
7 | - .:/workspace
8 | working_dir: /workspace
9 | environment:
10 | PGHOST: postgres
11 | PGUSER: postgres
12 | LDAPURI: ldaps://samba
13 | LDAPPASSWORD: 1Ntegral
14 | CI: "true"
15 | command: test/entrypoint.sh
16 | depends_on:
17 | - samba
18 | - postgres
19 |
--------------------------------------------------------------------------------
/test/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -eux
2 |
3 | teardown() {
4 | # If not on CI, wait for user interrupt on exit
5 | if [ -z "${CI-}" -a "$?" -gt 0 -a $$ = 1 ] ; then
6 | sleep infinity
7 | fi
8 | }
9 |
10 | trap teardown EXIT TERM
11 |
12 | top_srcdir=$(readlink -m "$0/../..")
13 | cd "$top_srcdir"
14 | test -f go.mod
15 |
16 | export LC_ALL=en_US.utf8
17 |
18 | # Choose target Python version. Matches packaging/rpm/build_rpm.sh.
19 | rpmdist=$(rpm --eval '%dist')
20 | case "$rpmdist" in
21 | *.el9)
22 | python=python3.9
23 | pip=pip3.9
24 | ;;
25 | *.el7|*.el8)
26 | python=python3.6
27 | pip=pip3.6
28 | ;;
29 | *.el6)
30 | python=python2
31 | pip=pip2
32 | ;;
33 | esac
34 |
35 | "$pip" --version
36 | if "$pip" --version |& grep -Fiq "python 2.6" ; then
37 | pip26-install https://files.pythonhosted.org/packages/53/67/9620edf7803ab867b175e4fd23c7b8bd8eba11cb761514dcd2e726ef07da/py-1.4.34-py2.py3-none-any.whl
38 | pip26-install https://files.pythonhosted.org/packages/fd/3e/d326a05d083481746a769fc051ae8d25f574ef140ad4fe7f809a2b63c0f0/pytest-3.1.3-py2.py3-none-any.whl
39 | pip26-install https://files.pythonhosted.org/packages/86/84/6bd1384196a6871a9108157ec934a1e1ee0078582cd208b43352566a86dc/pytest_catchlog-1.2.2-py2.py3-none-any.whl
40 | pip26-install https://files.pythonhosted.org/packages/4a/22/17b22ef5b049f12080f5815c41bf94de3c229217609e469001a8f80c1b3d/sh-1.12.14-py2.py3-none-any.whl
41 | else
42 | "$pip" install --prefix=/usr/local --requirement test/requirements.txt
43 | fi
44 |
45 | # Check Postgres and LDAP connectivity
46 | psql -tc "SELECT version();"
47 | # ldap-utils on CentOS does not read properly current ldaprc. Linking it in ~
48 | # workaround this.
49 | ln -fsv "${PWD}/ldaprc" ~/ldaprc
50 | retry ldapsearch -x -v -w "${LDAPPASSWORD}" -z none >/dev/null
51 |
52 | "$python" -m pytest test/ "$@"
53 |
--------------------------------------------------------------------------------
/test/extra.ldap2pg.yml:
--------------------------------------------------------------------------------
1 | # See postgres/extra.sh
2 | #
3 | # extra config tests corner cases or less used features:
4 | #
5 | # - run as superuser
6 | # - LDAP sub-search
7 | # - role config
8 | # - local role inherit
9 | # - sub-set of roles, marked by ldap_roles group.
10 | # - multi-databases
11 | # - no privileges
12 |
13 | version: 6
14 |
15 | postgres:
16 | roles_blacklist_query:
17 | # Postgres 16 cascades dropping ldap2pg roles to memberships granted by ldap2pg.
18 | # https://www.postgresql.org/message-id/flat/dbfd6ee6d4e1d5a9c7ae019a50968ae199436745.camel%40dalibo.com
19 | - ldap2pg
20 | - extra*
21 | - postgres
22 | - pg_*
23 | managed_roles_query: |
24 | SELECT 'public'
25 | UNION
26 | SELECT DISTINCT role.rolname
27 | FROM pg_roles AS role
28 | LEFT OUTER JOIN pg_auth_members AS ms ON ms.member = role.oid
29 | LEFT OUTER JOIN pg_roles AS ldap_roles
30 | ON ldap_roles.rolname = 'ldap_roles' AND ldap_roles.oid = ms.roleid
31 | WHERE role.rolname = 'ldap_roles'
32 | OR ldap_roles.oid IS NOT NULL
33 | ORDER BY 1;
34 | databases_query: [extra0, extra1]
35 | fallback_owner: extra
36 |
37 | acls:
38 | FUNCTION get_random:
39 | scope: database
40 | inspect: |
41 | WITH acls AS (
42 | SELECT pronamespace::regnamespace::text AS schema, proname,
43 | coalesce(proacl, acldefault('f', proowner)) AS proacl
44 | FROM pg_catalog.pg_proc
45 | WHERE proname = 'get_random'
46 | AND pronamespace::regnamespace NOT IN ('pg_catalog', 'information_schema')
47 | ORDER BY 1, 2
48 | ), grants AS (
49 | SELECT schema, proname,
50 | (aclexplode(proacl)).privilege_type,
51 | (aclexplode(proacl)).grantee::regrole::text AS grantee
52 | FROM acls
53 | )
54 | SELECT privilege_type,
55 | schema,
56 | CASE grantee WHEN '-' THEN 'public' ELSE grantee END AS grantee,
57 | FALSE AS partial
58 | FROM grants
59 | WHERE privilege_type = ANY ($1)
60 | ;
61 | grant: GRANT ON FUNCTION .get_random() TO ;
62 | revoke: REVOKE ON FUNCTION .get_random() FROM ;
63 |
64 | privileges:
65 | random:
66 | - type: EXECUTE
67 | on: FUNCTION get_random
68 |
69 | rules:
70 | - description: "Static groups"
71 | roles:
72 | - name: ldap_roles
73 | comment: "Group of roles synchronized by ldap2pg."
74 |
75 | - description: "Managing role configuration"
76 | roles:
77 | - name: charles
78 | config:
79 | client_min_messages: NOTICE
80 | application_name: created
81 | parents: ldap_roles
82 |
83 | - name: alter
84 | config:
85 | client_min_messages: NOTICE
86 | application_name: updated
87 | parents:
88 | - local_parent
89 | - ldap_roles
90 |
91 | - name: alizée
92 | config: {}
93 | options:
94 | LOGIN: true
95 | CONNECTION LIMIT: 10
96 |
97 | - name: nicolas
98 | parents:
99 | - ldap_roles
100 |
101 |
102 | - description: "Superusers recursive"
103 | ldapsearch:
104 | base: "cn=users,dc=bridoulou,dc=fr"
105 | filter: >
106 | (&
107 | (objectClass=user)
108 | (memberOf:1.2.840.113556.1.4.1941:=cn=dba,cn=users,dc=bridoulou,dc=fr)
109 | )
110 | roles:
111 | - name: "{sAMAccountName}"
112 | # Force a sub-search
113 | comment: "group: {memberOf.sAMAccountName}"
114 | options: LOGIN SUPERUSER
115 | parents: [ldap_roles]
116 |
117 | - description: "Hooks"
118 | ldapsearch:
119 | base: "cn=users,dc=bridoulou,dc=fr"
120 | filter: "(cn=corinne)"
121 | role:
122 | name: "{cn}"
123 | parent: ldap_roles
124 | before_create: "CREATE SCHEMA {cn.identifier()};"
125 | after_create: "CREATE TABLE {cn.identifier()}.username AS SELECT {cn.string()}::regrole AS username;"
126 |
127 | - description: "Custom ACL"
128 | grant:
129 | - privilege: random
130 | role: nicolas
131 | database: extra0
132 | schema: public
133 |
--------------------------------------------------------------------------------
/test/fixtures/big.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Initialize a big setup to test performances.
4 | #
5 | set -eux
6 |
7 | export PGUSER=postgres
8 | export PGDATABASE=postgres
9 | psql=(psql -v ON_ERROR_STOP=1 --no-psqlrc)
10 |
11 |
12 | "${psql[@]}" <<'EOSQL'
13 | CREATE ROLE "bigowner" ADMIN "ldap2pg";
14 |
15 | CREATE DATABASE "big0" WITH OWNER "bigowner";
16 | EOSQL
17 |
18 | queries=()
19 | for i in {0..255} ; do
20 | printf -v i "%03d" "$i"
21 | queries+=("CREATE SCHEMA nsp$i AUTHORIZATION bigowner")
22 | for j in {0..3} ; do
23 | printf -v j "%03d" "$j"
24 | queries+=("CREATE UNLOGGED TABLE nsp$i.t$j (id serial PRIMARY KEY)")
25 | queries+=("CREATE VIEW nsp$i.v$j AS SELECT * FROM nsp$i.t$j")
26 | done
27 | queries+=(";") # End CREATE SCHEMA
28 | # Randomly create a function
29 | if (( RANDOM % 2 == 0 )) ; then
30 | queries+=("CREATE FUNCTION nsp$i.f() RETURNS INTEGER LANGUAGE SQL AS \$\$ SELECT 0 \$\$;")
31 | fi
32 | done
33 |
34 | "${psql[@]}" --echo-queries -d "big0" <<-EOSQL
35 | ${queries[*]}
36 | EOSQL
37 |
38 | "${psql[@]}" <<-EOF
39 | CREATE DATABASE big1 WITH OWNER "bigowner" TEMPLATE "big0";
40 | CREATE DATABASE big2 WITH OWNER "bigowner" TEMPLATE "big0";
41 | CREATE DATABASE big3 WITH OWNER "bigowner" TEMPLATE "big0";
42 | EOF
43 |
--------------------------------------------------------------------------------
/test/fixtures/genperfldif.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | cat <<-EOF
6 | version: 2
7 | charset: UTF-8
8 | EOF
9 |
10 | for i in {0..1023} ; do
11 | printf -v u "u%04d" "$i"
12 | cat <<-EOF
13 |
14 | dn: cn=$u,cn=users,dc=bridoulou,dc=fr
15 | changetype: add
16 | objectclass: inetOrgPerson
17 | objectclass: organizationalPerson
18 | objectclass: person
19 | objectclass: top
20 | cn: $u
21 | sn: $u
22 | mail: $u@bridoulou.fr
23 | EOF
24 | done
25 |
26 | for i in {0..255} ; do
27 | printf -v base "big%03d_" "$i"
28 | for g in r w d ; do
29 | g="${base}$g"
30 | cat <<-EOF
31 |
32 | dn: cn=$g,cn=users,dc=bridoulou,dc=fr
33 | changetype: add
34 | objectClass: groupOfNames
35 | objectClass: top
36 | cn: $g
37 | EOF
38 |
39 | for u in {0..1023} ; do
40 | break
41 | if [ $((RANDOM % 128)) -gt 0 ] ; then
42 | continue
43 | fi
44 | printf -v u "u%04d" "$u"
45 | cat <<-EOF
46 | member: cn=$u,cn=users,dc=bridoulou,dc=fr
47 | EOF
48 | done
49 |
50 | # If no user has been added, add a random one.
51 | if [ -n "${u#u*}" ] ; then
52 | printf -v u "u%04d" "$(( RANDOM % 1024 ))"
53 | cat <<-EOF
54 | member: cn=$u,cn=users,dc=bridoulou,dc=fr
55 | EOF
56 | fi
57 | done
58 | done
59 |
--------------------------------------------------------------------------------
/test/fixtures/postgres/extra.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -eux
3 |
4 | export PGDATABASE=postgres
5 | psql=(psql -v ON_ERROR_STOP=1 --no-psqlrc)
6 |
7 | PGUSER=ldap2pg "${psql[@]}" <<'EOSQL'
8 | CREATE ROLE "ldap_roles";
9 |
10 | -- Inherit local parent
11 | CREATE ROLE "local_parent" NOLOGIN;
12 |
13 | -- Test role config definition.
14 | ALTER ROLE "alter" SET client_min_messages TO 'ERROR';
15 | ALTER ROLE "alter" SET application_name TO 'not-updated';
16 |
17 | ALTER ROLE "alizée" SET client_min_messages TO 'NOTICE';
18 | ALTER ROLE "alizée" SET application_name TO 'not-reset';
19 | ALTER ROLE "alizée" CONNECTION LIMIT 5;
20 |
21 | CREATE ROLE "nicolas" IN ROLE "ldap_roles";
22 | ALTER ROLE "nicolas" SET client_min_messages TO 'NOTICE';
23 | ALTER ROLE "nicolas" SET application_name TO 'keep-me';
24 |
25 | CREATE ROLE "domitille with space" IN ROLE "ldap_roles";
26 | EOSQL
27 |
28 | PGUSER=postgres "${psql[@]}" <<'EOSQL'
29 | CREATE ROLE "extra" SUPERUSER;
30 |
31 | CREATE DATABASE "extra0" WITH OWNER "extra";
32 | -- For reassign database.
33 | CREATE ROLE "damien" SUPERUSER IN ROLE "ldap_roles";
34 | CREATE DATABASE "extra1" WITH OWNER "damien";
35 | EOSQL
36 |
37 | PGUSER=postgres PGDATABASE=extra0 "${psql[@]}" <<'EOSQL'
38 | CREATE FUNCTION get_random() RETURNS integer AS $$
39 | BEGIN
40 | RETURN 4; -- chosen by fair dice roll.
41 | END;
42 | $$ LANGUAGE plpgsql;
43 | EOSQL
44 |
--------------------------------------------------------------------------------
/test/fixtures/postgres/nominal.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -eux
3 |
4 | # Dév fixture initializing a cluster with a «previous state», needing a lot of
5 | # synchronization.
6 |
7 | export PGUSER=postgres
8 | export PGDATABASE=postgres
9 | psql=(psql -v ON_ERROR_STOP=1 --no-psqlrc)
10 |
11 |
12 | "${psql[@]}" <<'EOSQL'
13 | CREATE ROLE "ldap2pg" LOGIN CREATEDB CREATEROLE;
14 | EOSQL
15 |
16 | version=$("${psql[@]}" -Atc "SELECT current_setting('server_version_num')")
17 | if [ "$version" -ge 160000 ]; then
18 | "${psql[@]}" <<-'EOSQL'
19 | ALTER ROLE "ldap2pg" SET createrole_self_grant TO 'set,inherit';
20 | EOSQL
21 | else
22 | "${psql[@]}" <<-'EOSQL'
23 | ALTER ROLE ldap2pg SUPERUSER;
24 | EOSQL
25 | fi
26 |
27 | PGUSER=ldap2pg
28 |
29 | "${psql[@]}" <<'EOSQL'
30 | CREATE ROLE "nominal";
31 |
32 | CREATE DATABASE "nominal" WITH OWNER "nominal";
33 |
34 | -- Should be NOLOGIN
35 | CREATE ROLE "readers" LOGIN;
36 | CREATE ROLE "owners" NOLOGIN;
37 |
38 | -- For alter
39 | CREATE ROLE "alter";
40 | CREATE ROLE "alizée"; -- Spurious parent.
41 |
42 | -- For drop
43 | CREATE ROLE "daniel" WITH LOGIN;
44 |
45 | GRANT "owners" TO "alizée";
46 | EOSQL
47 |
48 | "${psql[@]}" -d nominal <<'EOSQL'
49 | ALTER SCHEMA "public" OWNER TO "nominal";
50 |
51 | CREATE SCHEMA "nominal"
52 | AUTHORIZATION "nominal"
53 | CREATE TABLE "t0" (id serial PRIMARY KEY)
54 | CREATE TABLE "t1" (id serial PRIMARY KEY);
55 | CREATE MATERIALIZED VIEW "nominal"."mv0" AS SELECT 1;
56 | ALTER MATERIALIZED VIEW "nominal"."mv0" OWNER TO "nominal";
57 |
58 | -- Partial grant on all tables, for regrant
59 | GRANT SELECT ON TABLE "nominal"."t0" TO "readers";
60 | -- missing grant on t1.
61 |
62 | -- For revoke.
63 | GRANT UPDATE ON TABLE "nominal"."t0" TO "readers";
64 | EOSQL
65 |
--------------------------------------------------------------------------------
/test/fixtures/postgres/reset.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -eux
3 |
4 | export PGUSER=postgres
5 | export PGDATABASE=postgres
6 | psql=(psql -v ON_ERROR_STOP=1 --no-psqlrc)
7 |
8 | "${psql[@]}" < pg_backend_pid();
12 |
13 | DROP DATABASE IF EXISTS nominal;
14 | DROP DATABASE IF EXISTS extra0;
15 | DROP DATABASE IF EXISTS extra1;
16 | DROP DATABASE IF EXISTS big0;
17 | DROP DATABASE IF EXISTS big1;
18 | DROP DATABASE IF EXISTS big2;
19 | DROP DATABASE IF EXISTS big3;
20 | EOSQL
21 |
22 | mapfile -t roles < <("${psql[@]}" -Atc "SELECT rolname FROM pg_roles WHERE rolname NOT LIKE 'pg_%' AND rolname NOT IN (CURRENT_USER, 'postgres');")
23 | printf -v quoted_roles '"%s", ' "${roles[@]+${roles[@]}}"
24 | quoted_roles="${quoted_roles%, }"
25 |
26 | psql=("${psql[@]}" --echo-all)
27 | for role in "${roles[@]+${roles[@]}}" ; do
28 | "${psql[@]}" <<-EOF
29 | DROP OWNED BY "${role}" CASCADE;
30 | EOF
31 | done
32 |
33 | "${psql[@]}" <<-EOSQL
34 | GRANT USAGE ON SCHEMA information_schema TO PUBLIC;
35 | GRANT USAGE, CREATE ON SCHEMA public TO PUBLIC;
36 | GRANT USAGE ON LANGUAGE plpgsql TO PUBLIC;
37 | EOSQL
38 |
39 | # Reset default privileges.
40 | "${psql[@]}" -At <<-EOF | "${psql[@]}"
41 | WITH type_map (typechar, typename) AS (
42 | VALUES
43 | ('T', 'TYPES'),
44 | ('r', 'TABLES'),
45 | ('f', 'FUNCTIONS'),
46 | ('S', 'SEQUENCES')
47 | )
48 | SELECT
49 | 'ALTER DEFAULT PRIVILEGES'
50 | || ' FOR ROLE "' || pg_get_userbyid(defaclrole) || '"'
51 | || ' IN SCHEMA "' || nspname || '"'
52 | || ' REVOKE ' || (aclexplode(defaclacl)).privilege_type || ''
53 | || ' ON ' || COALESCE(typename, defaclobjtype::TEXT)
54 | || ' FROM "' || pg_get_userbyid((aclexplode(defaclacl)).grantee) || '"'
55 | || ';' AS "sql"
56 | FROM pg_catalog.pg_default_acl
57 | JOIN pg_namespace AS nsp ON nsp.oid = defaclnamespace
58 | LEFT OUTER JOIN type_map ON typechar = defaclobjtype;
59 | EOF
60 |
61 | if [ -n "${roles[*]-}" ] ; then
62 | "${psql[@]}" <<-EOSQL
63 | DROP ROLE IF EXISTS ${quoted_roles};
64 | EOSQL
65 | fi
66 |
--------------------------------------------------------------------------------
/test/fixtures/samba/extra.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | adduser() {
6 | samba-tool user add --random-password --mail-address="$1@bridoulou.fr" "$1"
7 | }
8 |
9 | # s* fro superusers
10 | adduser solene
11 | adduser samuel
12 |
13 | samba-tool group add prod
14 | samba-tool group addmembers prod solene
15 |
16 | samba-tool group add dba
17 | samba-tool group addmembers dba samuel,prod
18 |
--------------------------------------------------------------------------------
/test/fixtures/samba/nominal.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | adduser() {
6 | samba-tool user add --random-password "$@"
7 | }
8 |
9 | # a* for alter
10 | adduser alain
11 | # UTF-8 case
12 | adduser alizée
13 | # SQL keyword
14 | adduser alter
15 |
16 | # c* for creation
17 | adduser corinne
18 | adduser charles
19 | # Clothile has a capital letter. This is a test for case insensitivity
20 | adduser Clothilde
21 |
22 | # Blacklisted
23 | adduser postgres
24 |
25 | samba-tool group add readers
26 | samba-tool group addmembers readers alain,corinne,postgres
27 |
28 | samba-tool group add writers
29 | samba-tool group addmembers writers alizée,charles
30 |
31 | samba-tool group add owners
32 | samba-tool group addmembers owners alter,Clothilde
33 |
--------------------------------------------------------------------------------
/test/func/.gitignore:
--------------------------------------------------------------------------------
1 | test/.docker-bash-history
2 |
--------------------------------------------------------------------------------
/test/genbigconfig.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | cat <<-EOF
6 | version: 6
7 |
8 | postgres:
9 | databases_query: [big0, big1, big2, big3]
10 | managed_roles_query: |
11 | SELECT 'public'
12 | UNION
13 | SELECT DISTINCT role.rolname
14 | FROM pg_roles AS role
15 | LEFT OUTER JOIN pg_auth_members AS ms ON ms.member = role.oid
16 | LEFT OUTER JOIN pg_roles AS ldap_roles
17 | ON ldap_roles.rolname = 'ldap_roles' AND ldap_roles.oid = ms.roleid
18 | WHERE role.rolname = 'ldap_roles'
19 | OR ldap_roles.oid IS NOT NULL
20 | ORDER BY 1;
21 |
22 |
23 | privileges:
24 | read:
25 | - __connect__
26 | - __usage_on_schemas__
27 | - __select_on_tables__
28 | - __select_on_sequences__
29 |
30 | write:
31 | - __temporary__
32 | - __execute_on_functions__
33 | - __insert_on_tables__
34 | - __delete_on_tables__
35 | - __update_on_tables__
36 | - __update_on_sequences__
37 | - __usage_on_sequences__
38 | - __trigger_on_tables__
39 | - __truncate_on_tables__
40 | - __references_on_tables__
41 |
42 | define:
43 | - __create_on_schemas__
44 |
45 | rules:
46 | - description: "Base roles"
47 | roles:
48 | - name: ldap_roles
49 | comment: All roles managed by ldap2pg
50 | EOF
51 |
52 | for n in {0..255} ; do
53 | printf -v n "%03d" "$n"
54 | cat <<-EOF
55 |
56 | - description: "Define groups and privileges for schema $n."
57 | roles:
58 | - name: big${n}_r
59 | parents: ldap_roles
60 | - name: big${n}_w
61 | parents:
62 | - ldap_roles
63 | - big${n}_r
64 | - name: big${n}_d
65 | parents:
66 | - ldap_roles
67 | - big${n}_w
68 | grants:
69 | - privilege: read
70 | role: big${n}_r
71 | schemas: nsp$n
72 | - privilege: write
73 | role: big${n}_w
74 | schemas: nsp$n
75 | - privilege: define
76 | role: big${n}_d
77 | schemas: nsp$n
78 | EOF
79 | done
80 |
81 | cat <<-EOF
82 |
83 | - description: "Define roles from directory."
84 | ldapsearch:
85 | base: cn=users,dc=bridoulou,dc=fr
86 | filter: (cn=big*)
87 | roles:
88 | name: "{member.cn}"
89 | parents:
90 | - ldap_roles
91 | - "{cn}"
92 | EOF
93 |
--------------------------------------------------------------------------------
/test/ldap2pg.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # dev wrapper for conftest
3 | exec go run . "$@"
4 |
--------------------------------------------------------------------------------
/test/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | addopts = -vvv -p no:cov -p no:mock
3 | xfail_strict = true
4 | markers =
5 | go: Test applying to Go implementation
6 |
--------------------------------------------------------------------------------
/test/requirements.txt:
--------------------------------------------------------------------------------
1 | # This requiremets set is a frozen set (see pip freeze) of minimal dependencies
2 | # to run func tests in CentOS.
3 | pytest
4 | sh==1.14.1
5 |
--------------------------------------------------------------------------------
/test/test_config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import pytest
4 |
5 |
6 | def test_help(ldap2pg):
7 | ldap2pg('-?')
8 | ldap2pg('--help')
9 |
10 |
11 | def test_version(ldap2pg):
12 | assert "ldap2pg" in ldap2pg("--version")
13 |
14 |
15 | def ldapfree_env():
16 | blacklist = ('LDAPURI', 'LDAPHOST', 'LDAPPORT', 'LDAPPASSWORD')
17 | return dict(
18 | (k, v)
19 | for k, v in os.environ.items()
20 | if k not in blacklist
21 | )
22 |
23 |
24 | def test_stdin(ldap2pg, capsys):
25 | ldap2pg(
26 | '--config=-',
27 | _in="version: 6\nrules:\n- role: stdinuser",
28 | _env=ldapfree_env(),
29 | )
30 |
31 | _, err = capsys.readouterr()
32 | assert 'stdinuser' in err
33 |
34 |
35 | @pytest.mark.xfail(reason="Samba does not support SASL DIGEST-MD5.")
36 | def test_sasl(ldap2pg, capsys):
37 | env = dict(
38 | os.environ,
39 | # py-ldap2pg reads non-standard var USER.
40 | LDAPUSER='testsasl',
41 | # ldap2pg requires explicit SASL_MECH, and standard SASL_AUTHID.
42 | LDAPSASL_MECH='DIGEST-MD5',
43 | LDAPSASL_AUTHCID='testsasl',
44 | LDAPPASSWORD='voyage',
45 | )
46 | ldap2pg(config='ldap2pg.yml', verbose=True, _env=env)
47 |
48 | _, err = capsys.readouterr()
49 | assert 'SASL' in err
50 |
--------------------------------------------------------------------------------
/test/test_extra.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | # Test order matters.
3 |
4 | import os
5 | import io
6 |
7 | import pytest
8 |
9 |
10 | @pytest.fixture(scope='module')
11 | def extrarun(ldap2pg):
12 | ldap2pg = ldap2pg.bake(c='test/extra.ldap2pg.yml')
13 |
14 | # Ensure database is not sync.
15 | ldap2pg('--check', _ok_code=1)
16 |
17 | # Synchronize all
18 | ldap2pg('--real')
19 |
20 | # Prefix LDAPURI with ldaps://localhost:1234 to force HA round-robin.
21 | uri = " ".join(["ldaps://localhost:12345", os.environ['LDAPURI']])
22 | err = io.StringIO()
23 | ldap2pg(
24 | '--check', '--verbose',
25 | _env=dict(os.environ, LDAPURI=uri),
26 | _err=err,
27 | )
28 | return err.getvalue()
29 |
30 |
31 | def test_ha(extrarun):
32 | assert "ldaps://localhost:12345" in extrarun
33 | assert os.environ['LDAPURI'] in extrarun
34 | assert " try=2" in extrarun
35 |
36 |
37 | def test_roles(extrarun, psql):
38 | roles = list(psql.roles())
39 | assert 'charles' in roles
40 |
41 |
42 | def test_sub_search(extrarun, psql):
43 | comment = psql.scalar("""\
44 | SELECT description
45 | FROM pg_shdescription
46 | WHERE description = 'group: prod';
47 | """)
48 | assert comment
49 |
50 |
51 | def test_role_config(extrarun, psql):
52 | expected = {
53 | 'client_min_messages': 'NOTICE',
54 | 'application_name': 'created',
55 | }
56 | assert expected == psql.config('charles')
57 |
58 | expected = {
59 | 'client_min_messages': 'NOTICE',
60 | 'application_name': 'updated',
61 | }
62 | assert expected == psql.config('alter')
63 |
64 | assert {} == psql.config(u'alizée')
65 |
66 | expected_unmodified_config = {
67 | 'client_min_messages': 'NOTICE',
68 | 'application_name': 'keep-me',
69 | }
70 | assert expected_unmodified_config == psql.config('nicolas')
71 |
72 |
73 | def test_role_hook(extrarun, psql):
74 | assert psql.scalar("SELECT username FROM corinne.username;") == "corinne"
75 |
--------------------------------------------------------------------------------
/test/test_nominal.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | import pytest
3 |
4 |
5 | @pytest.fixture(scope='module')
6 | def nominalrun(ldap2pg):
7 | ldap2pg = ldap2pg.bake(c='ldap2pg.yml')
8 |
9 | # Ensure database is not sync.
10 | ldap2pg('--check', _ok_code=1)
11 |
12 | # Synchronize all
13 | ldap2pg('--real')
14 | ldap2pg('--check')
15 | return ldap2pg
16 |
17 |
18 | def test_roles(nominalrun, psql):
19 | roles = list(psql.roles())
20 |
21 | assert 'alain' in roles
22 | assert 'daniel' not in roles
23 |
24 | readers = list(psql.members('readers'))
25 | assert 'corinne' in readers
26 |
27 | writers = list(psql.members('writers'))
28 | assert u'alizée' in writers
29 |
30 | owners = list(psql.members('owners'))
31 | assert 'alter' in owners
32 | assert 'alain' not in owners
33 |
34 |
35 | def test_re_grant(nominalrun, psql):
36 | psql(c='REVOKE CONNECT ON DATABASE nominal FROM readers;')
37 | ldap2pg = nominalrun
38 | # Ensure database is not sync.
39 | ldap2pg('--check', _ok_code=1)
40 | # Synchronize all
41 | ldap2pg('--real')
42 | ldap2pg('--check')
43 |
44 |
45 | def test_re_revoke(nominalrun, psql):
46 | ldap2pg = nominalrun
47 |
48 | psql(c='GRANT CREATE ON SCHEMA public TO readers;')
49 | # Ensure database is not sync.
50 | ldap2pg('--check', _ok_code=1)
51 | # Synchronize all
52 | ldap2pg('--real')
53 | ldap2pg('--check')
54 |
55 |
56 | def test_nothing_to_do(nominalrun, capsys):
57 | nominalrun('--real', '--check')
58 |
59 | _, err = capsys.readouterr()
60 | assert 'Nothing to do' in err
61 |
--------------------------------------------------------------------------------