Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,40 @@ jobs:
- run: go test -race -covermode=atomic -coverprofile=cover.out ./...
- run: go vet ./...

test-postgres:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
module: ["gonsole/auth"]
defaults:
run:
working-directory: ${{ matrix.module }}
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
ports:
- 5434:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 2s
--health-timeout 2s
--health-retries 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: ${{ matrix.module }}/go.mod
cache-dependency-path: |
${{ matrix.module }}/go.mod
${{ matrix.module }}/go.sum
- run: go test -race -covermode=atomic -coverprofile=cover.out ./...
- run: go vet ./...

gottext:
runs-on: ubuntu-latest
defaults:
Expand Down Expand Up @@ -61,7 +95,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["mailkit", "gonsole"]
module: ["mailkit", "gonsole", "gonsole/auth"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand All @@ -83,7 +117,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ["mailkit", "gonsole"]
module: ["mailkit", "gonsole", "gonsole/auth"]
defaults:
run:
working-directory: ${{ matrix.module }}
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ need and ignore the rest.

- [`gonsole`](gonsole/) runs the command line of a Go program built from
core commands, settings and compiled plugins.
- [`gonsole/auth`](gonsole/auth/) offers the account commands of a program
whose accounts live in gouncer's Postgres store.
- [`gottext`](gottext/) reads, writes and syncs gettext catalogs for
TypeScript applications, published to npm as `@gopherium/gottext`.
- [`mailkit`](mailkit/) renders mail from template files and sends it
Expand Down
53 changes: 53 additions & 0 deletions gonsole/auth/.golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
version: "2"

linters:
default: standard
enable:
- cyclop
- depguard
- gocognit
- lll
- misspell
- revive
- unconvert
- unparam
settings:
cyclop:
max-complexity: 10
gocognit:
min-complexity: 15
lll:
line-length: 120
revive:
rules:
- name: blank-imports
disabled: true
- name: exported
depguard:
rules:
auth-purity:
list-mode: strict
files:
- "**/*.go"
allow:
- $gostd
- github.com/gopherium/framework/gonsole
- github.com/gopherium/gouncer
- github.com/jackc/pgx/v5
- github.com/google/uuid
- github.com/peterldowns/pgtestdb
exclusions:
rules:
- path: _test\.go
linters:
- cyclop
- gocognit

formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/gopherium/framework
25 changes: 25 additions & 0 deletions gonsole/auth/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Changelog

All notable changes to the `gonsole/auth` module are documented in this
file. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
module follows [Semantic Versioning](https://semver.org/). While at
v0.x, minor releases may contain breaking changes.

Releases of this module are tagged `gonsole/auth/vX.Y.Z`.

## [Unreleased]

### Added

- `Migration`, the schema step that applies gouncer's account schema.
- `Account` and `EnsureAccounts`, creating each demo account unless its address is taken.
- `Config` and `Roles`, the capability and the role vocabulary a program hands its account commands.
- `account:create-admin`, creating one account under a known role, its password read from stdin.
- `account:grant-role`, giving a known role to every account holding none, a dry run until `-yes`.
- `account:list`, listing every account with its role and standing, or one JSON document.
- `account:role`, setting one account's role, a dry run until `-yes`.
- `account:disable` and `account:enable`, changing whether one account may log in, each a dry run until `-yes`.
- The last enabled account under a privileged role is never demoted or disabled.
- Every command finds an account by its address trimmed and in lower case.
- `Commands`, every account command in the order it is declared.
42 changes: 42 additions & 0 deletions gonsole/auth/accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: Apache-2.0

package auth

import (
"context"
"fmt"
"io"

"github.com/gopherium/gouncer"
"github.com/gopherium/gouncer/authkit"
)

// Account is one demo account a seed step ensures.
type Account struct {
// Email is the account's address.
Email string
// Name is the account's display name.
Name string
// Password is the account's demo password.
Password string
// Role is the role the account stands under.
Role string
}

// EnsureAccounts creates each account unless its address is taken and writes one line per account.
func EnsureAccounts(ctx context.Context, store gouncer.Store, accounts []Account, w io.Writer) error {
for _, account := range accounts {
created, err := authkit.EnsureAdmin(ctx, store, account.Email, account.Name, account.Password, account.Role)
if err != nil {
return fmt.Errorf("account %s: %w", account.Email, err)
}
verb := "kept"
if created {
verb = "created"
}
if _, err := fmt.Fprintf(w, "%s %s\n", verb, account.Email); err != nil {
return err
}
}
return nil
}
127 changes: 127 additions & 0 deletions gonsole/auth/accounts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: Apache-2.0

package auth_test

import (
"errors"
"strings"
"testing"

"github.com/gopherium/gouncer"

"github.com/gopherium/framework/gonsole/auth"
)

// demoPassword is the password every demo account in these tests holds.
const demoPassword = "password1234"

// demoAccounts returns two demo accounts, one per role.
func demoAccounts() []auth.Account {
return []auth.Account{
{Email: "admin@example.com", Name: "Maria Perez", Password: demoPassword, Role: "admin"},
{Email: "editor@example.com", Name: "Maria Perez", Password: demoPassword, Role: "editor"},
}
}

// failingWriter is a writer whose every write fails.
type failingWriter struct{}

// Write fails.
func (failingWriter) Write([]byte) (int, error) {
return 0, errors.New("the output is closed")
}

func TestEnsureAccountsCreatesEachAccountOnce(t *testing.T) {
t.Parallel()

store := storeAt(t, migrated(t))
var first, second strings.Builder

errFirst := auth.EnsureAccounts(t.Context(), store, demoAccounts(), &first)
errSecond := auth.EnsureAccounts(t.Context(), store, demoAccounts(), &second)

if errFirst != nil || errSecond != nil {
t.Fatalf("EnsureAccounts() = %v, then %v, want nil twice", errFirst, errSecond)
}
if want := "created admin@example.com\ncreated editor@example.com\n"; first.String() != want {
t.Errorf("first run wrote %q, want %q", first.String(), want)
}
if want := "kept admin@example.com\nkept editor@example.com\n"; second.String() != want {
t.Errorf("second run wrote %q, want %q", second.String(), want)
}
editor, err := store.UserByEmail(t.Context(), "editor@example.com")
if err != nil || editor.Role != "editor" {
t.Errorf("editor = %+v, %v, want the editor role", editor, err)
}
}

func TestEnsureAccountsGivesTheRoleToATakenAccountHoldingNone(t *testing.T) {
t.Parallel()

store := storeAt(t, migrated(t))
roleless, err := gouncer.NewUser("admin@example.com", "Maria Perez", demoPassword)
if err != nil {
t.Fatalf("NewUser() = %v", err)
}
if err := store.CreateUser(t.Context(), roleless); err != nil {
t.Fatalf("CreateUser() = %v", err)
}
var out strings.Builder

err = auth.EnsureAccounts(t.Context(), store, demoAccounts()[:1], &out)

if want := "kept admin@example.com\n"; err != nil || out.String() != want {
t.Errorf("EnsureAccounts() = %v, wrote %q, want nil and %q", err, out.String(), want)
}
held, err := store.UserByEmail(t.Context(), "admin@example.com")
if err != nil || held.Role != "admin" {
t.Errorf("taken account = %+v, %v, want it to hold admin", held, err)
}
}

func TestEnsureAccountsStopsAtAnAccountItCannotCreate(t *testing.T) {
t.Parallel()

store := storeAt(t, migrated(t))
accounts := demoAccounts()
accounts = []auth.Account{
accounts[0], {Email: "weak@example.com", Name: "Maria Perez", Password: "short", Role: "editor"}, accounts[1],
}
var out strings.Builder

err := auth.EnsureAccounts(t.Context(), store, accounts, &out)

want := "account weak@example.com: gouncer: password shorter than 12 characters"
if !errors.Is(err, gouncer.ErrWeakPassword) || errorText(err) != want {
t.Errorf("EnsureAccounts() = %v, want the weak password refusal %q", err, want)
}
if want := "created admin@example.com\n"; out.String() != want {
t.Errorf("wrote %q, want %q", out.String(), want)
}
if _, err := store.UserByEmail(t.Context(), "editor@example.com"); !errors.Is(err, gouncer.ErrUserNotFound) {
t.Errorf("the account after the refusal = %v, want it never created", err)
}
}

func TestEnsureAccountsFailsWhenItsLineCannotBeWritten(t *testing.T) {
t.Parallel()

store := storeAt(t, migrated(t))

err := auth.EnsureAccounts(t.Context(), store, demoAccounts(), failingWriter{})

if errorText(err) != "the output is closed" {
t.Errorf("EnsureAccounts() = %v, want the write failure", err)
}
if _, err := store.UserByEmail(t.Context(), "editor@example.com"); !errors.Is(err, gouncer.ErrUserNotFound) {
t.Errorf("the second account = %v, want it never created after the failed line", err)
}
}

// errorText returns the message of err, empty when it is nil.
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
12 changes: 12 additions & 0 deletions gonsole/auth/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0

package auth

import (
"github.com/gopherium/framework/gonsole"
)

// Commands returns every account command over cfg, in the order they are declared.
func Commands(cfg Config) []gonsole.Command {
return []gonsole.Command{CreateAdmin(cfg), GrantRole(cfg), List(cfg), SetRole(cfg), Disable(cfg), Enable(cfg)}
}
Loading
Loading