feat: allow to add pam source from command line (#10388)

The forgejo admin command line allows to deal with all the propose auth mecanism but pam, this PR adds full support for adding and updating pam auth mecanism via the command line without limitation.

Co-authored-by: Gusted <postmaster@gusted.xyz>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10388
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Baptiste Daroussin <bapt@FreeBSD.org>
Co-committed-by: Baptiste Daroussin <bapt@FreeBSD.org>
This commit is contained in:
Baptiste Daroussin 2025-12-19 15:20:52 +01:00 committed by Gusted
parent 7a1935795d
commit 5b73467d02
3 changed files with 440 additions and 0 deletions

View file

@ -64,6 +64,8 @@ func subcmdAuth() *cli.Command {
microcmdAuthUpdateLdapBindDn(),
microcmdAuthAddLdapSimpleAuth(),
microcmdAuthUpdateLdapSimpleAuth(),
microcmdAuthAddPAM(),
microcmdAuthUpdatePAM(),
microcmdAuthAddSMTP(),
microcmdAuthUpdateSMTP(),
microcmdAuthList(),

145
cmd/admin_auth_pam.go Normal file
View file

@ -0,0 +1,145 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
auth_model "forgejo.org/models/auth"
"forgejo.org/services/auth/source/pam"
"github.com/urfave/cli/v3"
)
func pamCLIFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "name",
Value: "",
Usage: "Application Name",
},
&cli.StringFlag{
Name: "service-name",
Value: "PLAIN",
Usage: "PAM service name",
},
&cli.StringFlag{
Name: "email-domain",
Value: "",
Usage: "PAM email domain",
},
&cli.BoolFlag{
Name: "skip-local-2fa",
Usage: "Skip 2FA to log on.",
Value: true,
},
&cli.BoolFlag{
Name: "active",
Usage: "This Authentication Source is Activated.",
Value: true,
},
}
}
func microcmdAuthAddPAM() *cli.Command {
return &cli.Command{
Name: "add-pam",
Usage: "Add new PAM authentication source",
Before: noDanglingArgs,
Action: newAuthService().addPAM,
Flags: pamCLIFlags(),
}
}
func microcmdAuthUpdatePAM() *cli.Command {
return &cli.Command{
Name: "update-pam",
Usage: "Update existing PAM authentication source",
Before: noDanglingArgs,
Action: newAuthService().updatePAM,
Flags: append(pamCLIFlags()[:1], append([]cli.Flag{idFlag()}, pamCLIFlags()[1:]...)...),
}
}
func parsePAMConfig(_ context.Context, c *cli.Command) *pam.Source {
return &pam.Source{
ServiceName: c.String("service-name"),
EmailDomain: c.String("email-domain"),
SkipLocalTwoFA: c.Bool("skip-local-2fa"),
}
}
func (a *authService) addPAM(ctx context.Context, c *cli.Command) error {
ctx, cancel := installSignals(ctx)
defer cancel()
if err := a.initDB(ctx); err != nil {
return err
}
if !c.IsSet("name") || len(c.String("name")) == 0 {
return errors.New("name must be set")
}
if !c.IsSet("service-name") || len(c.String("service-name")) == 0 {
return errors.New("service-name must be set")
}
active := true
if c.IsSet("active") {
active = c.Bool("active")
}
config := parsePAMConfig(ctx, c)
return a.createAuthSource(ctx, &auth_model.Source{
Type: auth_model.PAM,
Name: c.String("name"),
IsActive: active,
Cfg: config,
})
}
func (a *authService) updatePAM(ctx context.Context, c *cli.Command) error {
if !c.IsSet("id") {
return errors.New("--id flag is missing")
}
ctx, cancel := installSignals(ctx)
defer cancel()
if err := a.initDB(ctx); err != nil {
return err
}
source, err := a.getAuthSource(ctx, c, auth_model.PAM)
if err != nil {
return err
}
pamConfig := source.Cfg.(*pam.Source)
if c.IsSet("name") {
source.Name = c.String("name")
}
if c.IsSet("service-name") {
pamConfig.ServiceName = c.String("service-name")
}
if c.IsSet("email-domain") {
pamConfig.EmailDomain = c.String("email-domain")
}
if c.IsSet("skip-local-2fa") {
pamConfig.SkipLocalTwoFA = c.Bool("skip-local-2fa")
}
if c.IsSet("active") {
source.IsActive = c.Bool("active")
}
source.Cfg = pamConfig
return a.updateAuthSource(ctx, source)
}

293
cmd/admin_auth_pam_test.go Normal file
View file

@ -0,0 +1,293 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"testing"
"forgejo.org/models/auth"
"forgejo.org/modules/test"
"forgejo.org/services/auth/source/pam"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestPamService(t *testing.T) {
// Mock cli functions to do not exit on error
defer test.MockVariableValue(&cli.OsExiter, func(code int) {})()
// Test cases
cases := []struct {
args []string
source *auth.Source
errMsg string
}{
// case 0
{
args: []string{
"pam-test",
"--name", "Pam Service",
"--service-name", "myservice",
},
source: &auth.Source{
Type: auth.PAM,
Name: "Pam Service",
IsActive: true,
Cfg: &pam.Source{
ServiceName: "myservice",
EmailDomain: "",
SkipLocalTwoFA: true,
},
},
},
// case 1
{
args: []string{
"pam-test",
"--name", "Pam Service",
"--service-name", "myservice",
"--email-domain", "testdomain.org",
"--skip-local-2fa",
},
source: &auth.Source{
Type: auth.PAM,
Name: "Pam Service",
IsActive: true,
Cfg: &pam.Source{
ServiceName: "myservice",
EmailDomain: "testdomain.org",
SkipLocalTwoFA: true,
},
},
},
// case 2
{
args: []string{
"pam-test",
"--service-name", "myservice",
"--email-domain", "testdomain.org",
"--skip-local-2fa", "false",
"--active", "true",
},
errMsg: "name must be set",
},
// case 3
{
args: []string{
"pam-test",
"--name", "Pam Service",
"--email-domain", "testdomain.org",
"--skip-local-2fa", "false",
"--active", "true",
},
errMsg: "service-name must be set",
},
}
for n, c := range cases {
// Mock functions.
var createdAuthSource *auth.Source
service := &authService{
initDB: func(context.Context) error {
return nil
},
createAuthSource: func(ctx context.Context, authSource *auth.Source) error {
createdAuthSource = authSource
return nil
},
updateAuthSource: func(ctx context.Context, authSource *auth.Source) error {
assert.FailNow(t, "should not call updateAuthSource", "case: %d", n)
return nil
},
getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) {
assert.FailNow(t, "should not call getAuthSourceByID", "case: %d", n)
return nil, nil
},
}
// Create a copy of command to test
app := cli.Command{}
app.Flags = microcmdAuthAddPAM().Flags
app.Action = service.addPAM
// Run it
err := app.Run(t.Context(), c.args)
if c.errMsg != "" {
assert.EqualError(t, err, c.errMsg, "case %d: error should match", n)
} else {
require.NoError(t, err, "case %d: should have no errors", n)
assert.Equal(t, c.source, createdAuthSource, "case %d: wrong authSource", n)
}
}
}
func TestUpdatePAM(t *testing.T) {
// Mock cli functions to do not exit on error
defer test.MockVariableValue(&cli.OsExiter, func(code int) {})()
// Test cases
cases := []struct {
args []string
id int64
existingAuthSource *auth.Source
authSource *auth.Source
errMsg string
}{
// case 0
{
args: []string{
"pam-test",
"--id", "23",
"--name", "PAM Service",
"--service-name", "myservice",
},
id: 23,
existingAuthSource: &auth.Source{
Type: auth.PAM,
IsActive: true,
Cfg: &pam.Source{},
},
authSource: &auth.Source{
Type: auth.PAM,
Name: "PAM Service",
IsActive: true,
Cfg: &pam.Source{
ServiceName: "myservice",
},
},
},
// case 1
{
args: []string{
"pam-test",
"--id", "1",
},
authSource: &auth.Source{
Type: auth.PAM,
Cfg: &pam.Source{},
},
},
// case 2
{
args: []string{
"pam-test",
"--id", "1",
"--name", "pam service",
},
authSource: &auth.Source{
Type: auth.PAM,
Name: "pam service",
Cfg: &pam.Source{},
},
},
// case 3
{
args: []string{
"pam-test",
"--id", "1",
"--active=false",
},
existingAuthSource: &auth.Source{
Type: auth.PAM,
IsActive: true,
Cfg: &pam.Source{},
},
authSource: &auth.Source{
Type: auth.PAM,
IsActive: false,
Cfg: &pam.Source{},
},
},
// case 4
{
args: []string{
"pam-test",
"--id", "1",
"--service-name", "myservice",
},
authSource: &auth.Source{
Type: auth.PAM,
Cfg: &pam.Source{
ServiceName: "myservice",
},
},
},
// case 5
{
args: []string{
"pam-test",
"--id", "1",
"--skip-local-2fa=false",
},
authSource: &auth.Source{
Type: auth.PAM,
Cfg: &pam.Source{
SkipLocalTwoFA: false,
},
},
},
// case 6
{
args: []string{
"pam-test",
"--id", "1",
"--email-domain", "testdomain.org",
},
authSource: &auth.Source{
Type: auth.PAM,
Cfg: &pam.Source{
EmailDomain: "testdomain.org",
},
},
},
}
for n, c := range cases {
// Mock functions.
var updatedAuthSource *auth.Source
service := &authService{
initDB: func(context.Context) error {
return nil
},
createAuthSource: func(ctx context.Context, authSource *auth.Source) error {
assert.FailNow(t, "should not call createAuthSource", "case: %d", n)
return nil
},
updateAuthSource: func(ctx context.Context, authSource *auth.Source) error {
updatedAuthSource = authSource
return nil
},
getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) {
if c.id != 0 {
assert.Equal(t, c.id, id, "case %d: wrong id", n)
}
if c.existingAuthSource != nil {
return c.existingAuthSource, nil
}
return &auth.Source{
Type: auth.PAM,
Cfg: &pam.Source{},
}, nil
},
}
// Create a copy of command to test
app := cli.Command{}
app.Flags = microcmdAuthUpdatePAM().Flags
app.Action = service.updatePAM
// Run it
err := app.Run(t.Context(), c.args)
if c.errMsg != "" {
assert.EqualError(t, err, c.errMsg, "case %d: error should match", n)
} else {
require.NoError(t, err, "case %d: should have no errors", n)
assert.Equal(t, c.authSource, updatedAuthSource, "case %d: wrong authSource", n)
}
}
}