├── .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 | [![ldap2pg: PostgreSQL role and privileges management](https://github.com/dalibo/ldap2pg/raw/master/docs/img/logo-phrase.png)](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 |

ldap2pg

7 | 8 | ![ldap2pg](https://github.com/dalibo/ldap2pg/raw/master/docs/img/logo-phrase.png) 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 | ![Screenshot](img/screenshot.png) 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 | --------------------------------------------------------------------------------