diff --git a/cmd/jimmsrv/service/service.go b/cmd/jimmsrv/service/service.go index 1a53ff085..215ab004c 100644 --- a/cmd/jimmsrv/service/service.go +++ b/cmd/jimmsrv/service/service.go @@ -433,7 +433,7 @@ func NewService(ctx context.Context, p Params) (*Service, error) { return nil, errors.E(op, err) } - s.mux.Mount("/rebac", middleware.AuthenticateRebac("/rebac", rebacBackend.Handler(""), s.jimm)) + s.mux.Mount("/rebac", middleware.AuthenticateRebac("/rebac", rebacBackend.Handler(""), s.jimm.LoginManager())) mountHandler( "/debug", diff --git a/internal/jimm/admin.go b/internal/jimm/admin.go deleted file mode 100644 index c714e35fd..000000000 --- a/internal/jimm/admin.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2024 Canonical. - -package jimm - -import ( - "context" - "net/http" - - "golang.org/x/oauth2" - - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/openfga" - "github.com/canonical/jimm/v3/pkg/names" -) - -// LoginDevice starts the device login flow. -func (j *JIMM) LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { - const op = errors.Op("jimm.LoginDevice") - resp, err := j.OAuthAuthenticator.Device(ctx) - if err != nil { - return nil, errors.E(op, err) - } - return resp, nil -} - -// AuthenticateBrowserSession authenticates a browser login. -func (j *JIMM) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) { - return j.OAuthAuthenticator.AuthenticateBrowserSession(ctx, w, r) -} - -// GetDeviceSessionToken polls an OIDC server while a user logs in and returns a session token scoped to the user's identity. -func (j *JIMM) GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) { - const op = errors.Op("jimm.GetDeviceSessionToken") - - token, err := j.OAuthAuthenticator.DeviceAccessToken(ctx, deviceOAuthResponse) - if err != nil { - return "", errors.E(op, err) - } - - idToken, err := j.OAuthAuthenticator.ExtractAndVerifyIDToken(ctx, token) - if err != nil { - return "", errors.E(op, err) - } - - email, err := j.OAuthAuthenticator.Email(idToken) - if err != nil { - return "", errors.E(op, err) - } - - if err := j.OAuthAuthenticator.UpdateIdentity(ctx, email, token); err != nil { - return "", errors.E(op, err) - } - - encToken, err := j.OAuthAuthenticator.MintSessionToken(email) - if err != nil { - return "", errors.E(op, err) - } - - return string(encToken), nil -} - -// LoginClientCredentials verifies a user's client ID and secret before the user is logged in. -func (j *JIMM) LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) { - const op = errors.Op("jimm.LoginClientCredentials") - // We expect the client to send the service account ID "as-is" and because we know that this is a clientCredentials login, - // we can append the @serviceaccount domain to the clientID (if not already present). - clientIdWithDomain, err := names.EnsureValidServiceAccountId(clientID) - if err != nil { - return nil, errors.E(op, err) - } - - err = j.OAuthAuthenticator.VerifyClientCredentials(ctx, clientID, clientSecret) - if err != nil { - return nil, errors.E(op, err) - } - - return j.UserLogin(ctx, clientIdWithDomain) -} - -// LoginWithSessionToken verifies a user's session token before the user is logged in. -func (j *JIMM) LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) { - const op = errors.Op("jimm.LoginWithSessionToken") - jwtToken, err := j.OAuthAuthenticator.VerifySessionToken(sessionToken) - if err != nil { - return nil, errors.E(op, err) - } - - email := jwtToken.Subject() - return j.UserLogin(ctx, email) -} - -// LoginWithSessionCookie uses the identity ID expected to have come from a session cookie, to log the user in. -// -// The work to parse and store the user's identity from the session cookie takes place in internal/jimmhttp/websocket.go -// [WSHandler.ServerHTTP] during the upgrade from an HTTP connection to a websocket. The user's identity is stored -// and passed to this function with the assumption that the cookie contained a valid session. This function is far from -// the session cookie logic due to the separation between the HTTP layer and Juju's RPC mechanism. -func (j *JIMM) LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) { - const op = errors.Op("jimm.LoginWithSessionCookie") - if identityID == "" { - return nil, errors.E(op, "missing cookie identity") - } - return j.UserLogin(ctx, identityID) -} diff --git a/internal/jimm/admin_test.go b/internal/jimm/admin_test.go deleted file mode 100644 index 7dc405366..000000000 --- a/internal/jimm/admin_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2024 Canonical. - -package jimm_test - -import ( - "context" - "encoding/base64" - "testing" - "time" - - qt "github.com/frankban/quicktest" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/lestrrat-go/jwx/v2/jwt" - "golang.org/x/oauth2" - - "github.com/canonical/jimm/v3/internal/jimm" - "github.com/canonical/jimm/v3/internal/testutils/jimmtest" -) - -func TestLoginDevice(t *testing.T) { - c := qt.New(t) - - j := jimmtest.NewJIMM(c, nil) - - resp, err := j.LoginDevice(context.Background()) - c.Assert(err, qt.IsNil) - c.Assert(*resp, qt.CmpEquals(cmpopts.IgnoreTypes(time.Time{})), oauth2.DeviceAuthResponse{ - DeviceCode: "test-device-code", - UserCode: "test-user-code", - VerificationURI: "http://no-such-uri.canonical.com", - VerificationURIComplete: "http://no-such-uri.canonical.com", - Interval: int64(time.Minute.Seconds()), - }) -} - -func TestGetDeviceSessionToken(t *testing.T) { - c := qt.New(t) - pollingChan := make(chan string, 1) - - mockAuthenticator := jimmtest.NewMockOAuthAuthenticator(c, pollingChan) - - j := jimmtest.NewJIMM(c, &jimm.Parameters{ - OAuthAuthenticator: &mockAuthenticator, - }) - - pollingChan <- "user-foo" - token, err := j.GetDeviceSessionToken(context.Background(), nil) - c.Assert(err, qt.IsNil) - c.Assert(token, qt.Not(qt.Equals), "") - decodedToken, err := base64.StdEncoding.DecodeString(token) - c.Assert(err, qt.IsNil) - parsedToken, err := jwt.ParseInsecure([]byte(decodedToken)) - c.Assert(err, qt.IsNil) - c.Assert(parsedToken.Subject(), qt.Equals, "user-foo@canonical.com") -} - -func TestLoginClientCredentials(t *testing.T) { - c := qt.New(t) - - j := jimmtest.NewJIMM(c, nil) - - ctx := context.Background() - invalidClientID := "123@123@" - _, err := j.LoginClientCredentials(ctx, invalidClientID, "foo-secret") - c.Assert(err, qt.ErrorMatches, "invalid client ID") - - validClientID := "my-svc-acc" - user, err := j.LoginClientCredentials(ctx, validClientID, "foo-secret") - c.Assert(err, qt.IsNil) - c.Assert(user.Name, qt.Equals, "my-svc-acc@serviceaccount") -} - -func TestLoginWithSessionToken(t *testing.T) { - c := qt.New(t) - - j := jimmtest.NewJIMM(c, nil) - - ctx := context.Background() - - token, err := jwt.NewBuilder(). - Subject("alice@canonical.com"). - Build() - c.Assert(err, qt.IsNil) - serialisedToken, err := jwt.NewSerializer().Serialize(token) - c.Assert(err, qt.IsNil) - b64Token := base64.StdEncoding.EncodeToString(serialisedToken) - - _, err = j.LoginWithSessionToken(ctx, "invalid-token") - c.Assert(err, qt.ErrorMatches, "failed to decode token") - - user, err := j.LoginWithSessionToken(ctx, b64Token) - c.Assert(err, qt.IsNil) - c.Assert(user.Name, qt.Equals, "alice@canonical.com") -} - -func TestLoginWithSessionCookie(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - j := jimmtest.NewJIMM(c, nil) - - _, err := j.LoginWithSessionCookie(ctx, "") - c.Assert(err, qt.ErrorMatches, "missing cookie identity") - - user, err := j.LoginWithSessionCookie(ctx, "alice@canonical.com") - c.Assert(err, qt.IsNil) - c.Assert(user.Name, qt.Equals, "alice@canonical.com") -} diff --git a/internal/jimm/export_test.go b/internal/jimm/export_test.go index 365b02cd0..251233fa8 100644 --- a/internal/jimm/export_test.go +++ b/internal/jimm/export_test.go @@ -50,14 +50,6 @@ func (j *JIMM) ParseAndValidateTag(ctx context.Context, key string) (*ofganames. return j.parseAndValidateTag(ctx, key) } -func (j *JIMM) GetUser(ctx context.Context, identifier string) (*openfga.User, error) { - return j.getUser(ctx, identifier) -} - -func (j *JIMM) UpdateUserLastLogin(ctx context.Context, identifier string) error { - return j.updateUserLastLogin(ctx, identifier) -} - func (j *JIMM) EveryoneUser() *openfga.User { return j.everyoneUser() } diff --git a/internal/jimm/identity.go b/internal/jimm/identity.go deleted file mode 100644 index 6119cfc13..000000000 --- a/internal/jimm/identity.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2024 Canonical. - -package jimm - -import ( - "context" - - "github.com/canonical/jimm/v3/internal/common/pagination" - "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/openfga" -) - -// FetchIdentity fetches the user specified by the username and returns the user if it is found. -// Or error "record not found". -func (j *JIMM) FetchIdentity(ctx context.Context, id string) (*openfga.User, error) { - const op = errors.Op("jimm.FetchIdentity") - - identity, err := dbmodel.NewIdentity(id) - if err != nil { - return nil, errors.E(op, err) - } - - if err := j.Database.FetchIdentity(ctx, identity); err != nil { - return nil, err - } - u := openfga.NewUser(identity, j.OpenFGAClient) - - return u, nil -} - -// ListIdentities lists a page of users in our database and parse them into openfga entities. -// `match` will filter the list for fuzzy find on identity name. -func (j *JIMM) ListIdentities(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) { - const op = errors.Op("jimm.ListIdentities") - - if !user.JimmAdmin { - return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - identities, err := j.Database.ListIdentities(ctx, pagination.Limit(), pagination.Offset(), match) - var users []openfga.User - - for _, id := range identities { - users = append(users, *openfga.NewUser(&id, j.OpenFGAClient)) - } - if err != nil { - return nil, errors.E(op, err) - } - return users, nil -} - -// CountIdentities returns the count of all the identities in our database. -func (j *JIMM) CountIdentities(ctx context.Context, user *openfga.User) (int, error) { - const op = errors.Op("jimm.CountIdentities") - - if !user.JimmAdmin { - return 0, errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - count, err := j.Database.CountIdentities(ctx) - if err != nil { - return 0, errors.E(op, err) - } - return count, nil -} diff --git a/internal/jimm/identity/export_test.go b/internal/jimm/identity/export_test.go new file mode 100644 index 000000000..bb3775d66 --- /dev/null +++ b/internal/jimm/identity/export_test.go @@ -0,0 +1,5 @@ +// Copyright 2024 Canonical. +package identity + +// Identity is a type alias to export identityManager for use in tests. +type IdentityManager = identityManager diff --git a/internal/jimm/identity/identity.go b/internal/jimm/identity/identity.go new file mode 100644 index 000000000..c37373133 --- /dev/null +++ b/internal/jimm/identity/identity.go @@ -0,0 +1,82 @@ +// Copyright 2024 Canonical. +package identity + +import ( + "context" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" +) + +// identityManager provides a means to manage identities within JIMM. +type identityManager struct { + store *db.Database + authSvc *openfga.OFGAClient +} + +// NewIdentityManager returns a new identityManager that persists the roles in the provided store. +func NewIdentityManager(store *db.Database, authSvc *openfga.OFGAClient) (*identityManager, error) { + if store == nil { + return nil, errors.E("identity store cannot be nil") + } + if authSvc == nil { + return nil, errors.E("identity authorisation service cannot be nil") + } + return &identityManager{store, authSvc}, nil +} + +// FetchIdentity fetches the user specified by the username and returns the user if it is found. +// Or error "record not found". +func (j *identityManager) FetchIdentity(ctx context.Context, id string) (*openfga.User, error) { + const op = errors.Op("jimm.FetchIdentity") + + identity, err := dbmodel.NewIdentity(id) + if err != nil { + return nil, errors.E(op, err) + } + + if err := j.store.FetchIdentity(ctx, identity); err != nil { + return nil, err + } + u := openfga.NewUser(identity, j.authSvc) + + return u, nil +} + +// ListIdentities lists a page of users in our database and parse them into openfga entities. +// `match` will filter the list for fuzzy find on identity name. +func (j *identityManager) ListIdentities(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) { + const op = errors.Op("jimm.ListIdentities") + + if !user.JimmAdmin { + return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + identities, err := j.store.ListIdentities(ctx, pagination.Limit(), pagination.Offset(), match) + var users []openfga.User + + for _, id := range identities { + users = append(users, *openfga.NewUser(&id, j.authSvc)) + } + if err != nil { + return nil, errors.E(op, err) + } + return users, nil +} + +// CountIdentities returns the count of all the identities in our database. +func (j *identityManager) CountIdentities(ctx context.Context, user *openfga.User) (int, error) { + const op = errors.Op("jimm.CountIdentities") + + if !user.JimmAdmin { + return 0, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + count, err := j.store.CountIdentities(ctx) + if err != nil { + return 0, errors.E(op, err) + } + return count, nil +} diff --git a/internal/jimm/identity_test.go b/internal/jimm/identity/identity_test.go similarity index 51% rename from internal/jimm/identity_test.go rename to internal/jimm/identity/identity_test.go index ea6c08ef0..4a6c0232d 100644 --- a/internal/jimm/identity_test.go +++ b/internal/jimm/identity/identity_test.go @@ -1,45 +1,76 @@ // Copyright 2024 Canonical. - -package jimm_test +package identity_test import ( "context" "testing" + "time" qt "github.com/frankban/quicktest" + "github.com/frankban/quicktest/qtsuite" "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimm/identity" "github.com/canonical/jimm/v3/internal/openfga" "github.com/canonical/jimm/v3/internal/testutils/jimmtest" ) -func TestFetchIdentity(t *testing.T) { - c := qt.New(t) - ctx := context.Background() +type identityManagerSuite struct { + manager *identity.IdentityManager + adminUser *openfga.User + db *db.Database + ofgaClient *openfga.OFGAClient +} + +func (s *identityManagerSuite) Init(c *qt.C) { + // Setup DB + db := &db.Database{ + DB: jimmtest.PostgresDB(c, time.Now), + } + err := db.Migrate(context.Background(), false) + c.Assert(err, qt.IsNil) - j := jimmtest.NewJIMM(c, nil) + s.db = db - user, _, _, _, _, _, _, _ := jimmtest.CreateTestControllerEnvironment(ctx, c, j.Database) - u, err := j.FetchIdentity(ctx, user.Name) + // Setup OFGA + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) c.Assert(err, qt.IsNil) - c.Assert(u.Name, qt.Equals, user.Name) - _, err = j.FetchIdentity(ctx, "bobnotfound@canonical.com") - c.Assert(err, qt.ErrorMatches, "record not found") + s.ofgaClient = ofgaClient + + s.manager, err = identity.NewIdentityManager(db, ofgaClient) + c.Assert(err, qt.IsNil) + + // Create test identity + i, err := dbmodel.NewIdentity("alice") + c.Assert(err, qt.IsNil) + s.adminUser = openfga.NewUser(i, ofgaClient) + s.adminUser.JimmAdmin = true } -func TestListIdentities(t *testing.T) { - c := qt.New(t) +func (s *identityManagerSuite) TestFetchIdentity(c *qt.C) { + c.Parallel() ctx := context.Background() - j := jimmtest.NewJIMM(c, nil) + identity := dbmodel.Identity{Name: "fake-name"} + err := s.db.GetIdentity(ctx, &identity) + c.Assert(err, qt.IsNil) + u, err := s.manager.FetchIdentity(ctx, identity.Name) + c.Assert(err, qt.IsNil) + c.Assert(u.Name, qt.Equals, identity.Name) + + _, err = s.manager.FetchIdentity(ctx, "bobnotfound@canonical.com") + c.Assert(err, qt.ErrorMatches, "record not found") +} - u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, j.OpenFGAClient) - u.JimmAdmin = true +func (s *identityManagerSuite) TestListIdentities(c *qt.C) { + c.Parallel() + ctx := context.Background() pag := pagination.NewOffsetFilter(10, 0) - users, err := j.ListIdentities(ctx, u, pag, "") + users, err := s.manager.ListIdentities(ctx, s.adminUser, pag, "") c.Assert(err, qt.IsNil) c.Assert(len(users), qt.Equals, 0) @@ -51,7 +82,8 @@ func TestListIdentities(t *testing.T) { } // add users for _, name := range userNames { - _, err := j.GetUser(ctx, name) + identity := dbmodel.Identity{Name: name} + err := s.db.GetIdentity(ctx, &identity) c.Assert(err, qt.IsNil) } @@ -91,7 +123,7 @@ func TestListIdentities(t *testing.T) { for _, t := range testCases { c.Run(t.desc, func(c *qt.C) { pag = pagination.NewOffsetFilter(t.limit, t.offset) - identities, err := j.ListIdentities(ctx, u, pag, t.match) + identities, err := s.manager.ListIdentities(ctx, s.adminUser, pag, t.match) c.Assert(err, qt.IsNil) c.Assert(identities, qt.HasLen, len(t.identities)) for i := range len(t.identities) { @@ -101,15 +133,10 @@ func TestListIdentities(t *testing.T) { } } -func TestCountIdentities(t *testing.T) { - c := qt.New(t) +func (s *identityManagerSuite) TestCountIdentities(c *qt.C) { + c.Parallel() ctx := context.Background() - j := jimmtest.NewJIMM(c, nil) - - u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, j.OpenFGAClient) - u.JimmAdmin = true - userNames := []string{ "bob1@canonical.com", "bob3@canonical.com", @@ -118,10 +145,15 @@ func TestCountIdentities(t *testing.T) { } // add users for _, name := range userNames { - _, err := j.GetUser(ctx, name) + identity := dbmodel.Identity{Name: name} + err := s.db.GetIdentity(ctx, &identity) c.Assert(err, qt.IsNil) } - count, err := j.CountIdentities(ctx, u) + count, err := s.manager.CountIdentities(ctx, s.adminUser) c.Assert(err, qt.IsNil) c.Assert(count, qt.Equals, 4) } + +func TestIdentityManager(t *testing.T) { + qtsuite.Run(qt.New(t), &identityManagerSuite{}) +} diff --git a/internal/jimm/jimm.go b/internal/jimm/jimm.go index 961c02ee4..f32fec57f 100644 --- a/internal/jimm/jimm.go +++ b/internal/jimm/jimm.go @@ -31,6 +31,7 @@ import ( "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jimm/credentials" "github.com/canonical/jimm/v3/internal/jimm/group" + "github.com/canonical/jimm/v3/internal/jimm/identity" "github.com/canonical/jimm/v3/internal/jimm/role" "github.com/canonical/jimm/v3/internal/jimmjwx" "github.com/canonical/jimm/v3/internal/openfga" @@ -140,6 +141,29 @@ type GroupManager interface { CountGroups(ctx context.Context, user *openfga.User) (int, error) } +type IdentityManager interface { + FetchIdentity(ctx context.Context, id string) (*openfga.User, error) + ListIdentities(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) + CountIdentities(ctx context.Context, user *openfga.User) (int, error) +} + +type LoginManager interface { + // AuthenticateBrowserSession authenticates a browser login. + AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) + // LoginDevice starts the device login flow. + LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) + // GetDeviceSessionToken returns a session token scoped to the user's identity. + GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) + // LoginClientCredentials logs in a user with client credentials. + LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) + // LoginWithSessionToken logs in a user with a session token. + LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) + // LoginWithSessionCookie logs in a user assuming cookie auth was done previously. + LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) + // UserLogin creates/fetches an identity based on the identity provided and returns an openfga user object. + UserLogin(ctx context.Context, identity string) (*openfga.User, error) +} + // Parameters holds the services and static fields passed to the jimm.New() constructor. // You can provide mock implementations of certain services where necessary for dependency injection. type Parameters struct { @@ -235,18 +259,24 @@ func New(p Parameters) (*JIMM, error) { return nil, errors.E(err) } - roleManager, err := role.NewRoleManager(j.Database, p.OpenFGAClient) + roleManager, err := role.NewRoleManager(j.Database, j.OpenFGAClient) if err != nil { return nil, err } j.roleManager = roleManager - groupManager, err := group.NewGroupManager(j.Database, p.OpenFGAClient) + groupManager, err := group.NewGroupManager(j.Database, j.OpenFGAClient) if err != nil { return nil, err } j.groupManager = groupManager + identityManager, err := identity.NewIdentityManager(j.Database, j.OpenFGAClient) + if err != nil { + return nil, err + } + j.identityManager = identityManager + return j, nil } @@ -262,6 +292,10 @@ type JIMM struct { // groupManager provides a means to manage groups within JIMM. groupManager GroupManager + + identityManager IdentityManager + + loginManager LoginManager } // ResourceTag returns JIMM's controller tag stating its UUID. @@ -284,6 +318,14 @@ func (j *JIMM) GroupManager() GroupManager { return j.groupManager } +func (j *JIMM) IdentityManager() IdentityManager { + return j.identityManager +} + +func (j *JIMM) LoginManager() LoginManager { + return j.loginManager +} + type permission struct { resource string relation string diff --git a/internal/jimm/login/export_test.go b/internal/jimm/login/export_test.go new file mode 100644 index 000000000..ae8cabea0 --- /dev/null +++ b/internal/jimm/login/export_test.go @@ -0,0 +1,15 @@ +// Copyright 2024 Canonical. +package login + +import ( + "context" + + "github.com/canonical/jimm/v3/internal/openfga" +) + +// Login is a type alias to export loginManager for use in tests. +type LoginManager = loginManager + +func (j *LoginManager) GetOrCreateIdentity(ctx context.Context, identifier string) (*openfga.User, error) { + return j.getOrCreateIdentity(ctx, identifier) +} diff --git a/internal/jimm/login/login.go b/internal/jimm/login/login.go new file mode 100644 index 000000000..8f8e0840e --- /dev/null +++ b/internal/jimm/login/login.go @@ -0,0 +1,233 @@ +// Copyright 2024 Canonical. +package login + +import ( + "context" + "database/sql" + "net/http" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/juju/names/v5" + "github.com/lestrrat-go/jwx/v2/jwt" + "golang.org/x/oauth2" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" + jimmnames "github.com/canonical/jimm/v3/pkg/names" +) + +// OAuthAuthenticator is responsible for handling authentication +// via OAuth2.0 AND JWT access tokens to JIMM. +type OAuthAuthenticator interface { + // Device initiates a device flow login and is step ONE of TWO. + // + // This is done via retrieving a: + // - Device code + // - User code + // - VerificationURI + // - Interval + // - Expiry + // From the device /auth endpoint. + // + // The verification uri and user code is sent to the user, as they must enter the code + // into the uri. + // + // The interval, expiry and device code and used to poll the token endpoint for completion. + Device(ctx context.Context) (*oauth2.DeviceAuthResponse, error) + + // DeviceAccessToken continues and collect an access token during the device login flow + // and is step TWO. + // + // See Device(...) godoc for more info pertaining to the flow. + DeviceAccessToken(ctx context.Context, res *oauth2.DeviceAuthResponse) (*oauth2.Token, error) + + // ExtractAndVerifyIDToken extracts the id token from the extras claims of an oauth2 token + // and performs signature verification of the token. + ExtractAndVerifyIDToken(ctx context.Context, oauth2Token *oauth2.Token) (*oidc.IDToken, error) + + // Email retrieves the users email from an id token via the email claim + Email(idToken *oidc.IDToken) (string, error) + + // MintSessionToken mints a session token to be used when logging into JIMM + // via an access token. The token only contains the user's email for authentication. + MintSessionToken(email string) (string, error) + + // VerifySessionToken symmetrically verifies the validty of the signature on the + // access token JWT, returning the parsed token. + // + // The subject of the token contains the user's email and can be used + // for user object creation. + // If verification fails, return error with code CodeInvalidSessionToken + // to indicate to the client to retry login. + VerifySessionToken(token string) (jwt.Token, error) + + // UpdateIdentity updates the database with the display name and access token set for the user. + // And, if present, a refresh token. + UpdateIdentity(ctx context.Context, email string, token *oauth2.Token) error + + // VerifyClientCredentials verifies the provided client ID and client secret. + VerifyClientCredentials(ctx context.Context, clientID string, clientSecret string) error + + // AuthenticateBrowserSession updates the session for a browser, additionally + // retrieving new access tokens upon expiry. If this cannot be done, the cookie + // is deleted and an error is returned. + AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) +} + +// loginManager provides a means to manage identities within JIMM. +type loginManager struct { + store *db.Database + authSvc *openfga.OFGAClient + oAuthAuthenticator OAuthAuthenticator + jimmTag names.ControllerTag +} + +// NewLoginManager returns a new loginManager that persists the roles in the provided store. +func NewLoginManager(store *db.Database, authSvc *openfga.OFGAClient, oAuthAuthenticator OAuthAuthenticator, jimmTag names.ControllerTag) (*loginManager, error) { + if store == nil { + return nil, errors.E("login store cannot be nil") + } + if authSvc == nil { + return nil, errors.E("login authorisation service cannot be nil") + } + return &loginManager{store, authSvc, oAuthAuthenticator, jimmTag}, nil +} + +// LoginDevice starts the device login flow. +func (j *loginManager) LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { + const op = errors.Op("jimm.LoginDevice") + resp, err := j.oAuthAuthenticator.Device(ctx) + if err != nil { + return nil, errors.E(op, err) + } + return resp, nil +} + +// AuthenticateBrowserSession authenticates a browser login. +func (j *loginManager) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) { + return j.oAuthAuthenticator.AuthenticateBrowserSession(ctx, w, r) +} + +// GetDeviceSessionToken polls an OIDC server while a user logs in and returns a session token scoped to the user's identity. +func (j *loginManager) GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) { + const op = errors.Op("jimm.GetDeviceSessionToken") + + token, err := j.oAuthAuthenticator.DeviceAccessToken(ctx, deviceOAuthResponse) + if err != nil { + return "", errors.E(op, err) + } + + idToken, err := j.oAuthAuthenticator.ExtractAndVerifyIDToken(ctx, token) + if err != nil { + return "", errors.E(op, err) + } + + email, err := j.oAuthAuthenticator.Email(idToken) + if err != nil { + return "", errors.E(op, err) + } + + if err := j.oAuthAuthenticator.UpdateIdentity(ctx, email, token); err != nil { + return "", errors.E(op, err) + } + + encToken, err := j.oAuthAuthenticator.MintSessionToken(email) + if err != nil { + return "", errors.E(op, err) + } + + return string(encToken), nil +} + +// LoginClientCredentials verifies a user's client ID and secret before the user is logged in. +func (j *loginManager) LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) { + const op = errors.Op("jimm.LoginClientCredentials") + // We expect the client to send the service account ID "as-is" and because we know that this is a clientCredentials login, + // we can append the @serviceaccount domain to the clientID (if not already present). + clientIdWithDomain, err := jimmnames.EnsureValidServiceAccountId(clientID) + if err != nil { + return nil, errors.E(op, err) + } + + err = j.oAuthAuthenticator.VerifyClientCredentials(ctx, clientID, clientSecret) + if err != nil { + return nil, errors.E(op, err) + } + + return j.UserLogin(ctx, clientIdWithDomain) +} + +// LoginWithSessionToken verifies a user's session token before the user is logged in. +func (j *loginManager) LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) { + const op = errors.Op("jimm.LoginWithSessionToken") + jwtToken, err := j.oAuthAuthenticator.VerifySessionToken(sessionToken) + if err != nil { + return nil, errors.E(op, err) + } + + email := jwtToken.Subject() + return j.UserLogin(ctx, email) +} + +// LoginWithSessionCookie uses the identity ID expected to have come from a session cookie, to log the user in. +// +// The work to parse and store the user's identity from the session cookie takes place in internal/jimmhttp/websocket.go +// [WSHandler.ServerHTTP] during the upgrade from an HTTP connection to a websocket. The user's identity is stored +// and passed to this function with the assumption that the cookie contained a valid session. This function is far from +// the session cookie logic due to the separation between the HTTP layer and Juju's RPC mechanism. +func (j *loginManager) LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) { + const op = errors.Op("jimm.LoginWithSessionCookie") + if identityID == "" { + return nil, errors.E(op, "missing cookie identity") + } + return j.UserLogin(ctx, identityID) +} + +// UserLogin fetches the identity specified by a user's email or a service account ID +// and returns an openfga User that can be used to verify permissions. +// It will create a new identity if one does not exist. +// The identity's last login time is updated. +func (j *loginManager) UserLogin(ctx context.Context, identifier string) (*openfga.User, error) { + const op = errors.Op("jimm.UpdateLastLogin") + ofgaUser, err := j.getOrCreateIdentity(ctx, identifier) + if err != nil { + return nil, errors.E(op, err, errors.CodeUnauthorized) + } + err = j.updateLastLogin(ctx, ofgaUser.Identity) + if err != nil { + return nil, errors.E(op, err) + } + return ofgaUser, nil +} + +func (j *loginManager) getOrCreateIdentity(ctx context.Context, identifier string) (*openfga.User, error) { + const op = errors.Op("jimm.getOrCreateIdentity") + + identity, err := dbmodel.NewIdentity(identifier) + if err != nil { + return nil, errors.E(op, err) + } + + if err := j.store.GetIdentity(ctx, identity); err != nil { + return nil, err + } + ofgaUser := openfga.NewUser(identity, j.authSvc) + + isJimmAdmin, err := openfga.IsAdministrator(ctx, ofgaUser, j.jimmTag) + if err != nil { + return nil, errors.E(op, err) + } + ofgaUser.JimmAdmin = isJimmAdmin + + return ofgaUser, nil +} + +func (j *loginManager) updateLastLogin(ctx context.Context, identity *dbmodel.Identity) error { + identity.LastLogin = sql.NullTime{ + Time: j.store.DB.Config.NowFunc(), + Valid: true, + } + return j.store.UpdateIdentity(ctx, identity) +} diff --git a/internal/jimm/login/login_test.go b/internal/jimm/login/login_test.go new file mode 100644 index 000000000..36a387ceb --- /dev/null +++ b/internal/jimm/login/login_test.go @@ -0,0 +1,189 @@ +// Copyright 2024 Canonical. +package login_test + +import ( + "context" + "encoding/base64" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/frankban/quicktest/qtsuite" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/juju/names/v5" + "github.com/lestrrat-go/jwx/v2/jwt" + "golang.org/x/oauth2" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimm/login" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/testutils/jimmtest" +) + +type loginManagerSuite struct { + manager *login.LoginManager + user *openfga.User + db *db.Database + ofgaClient *openfga.OFGAClient + jimmTag names.ControllerTag + deviceFlowChan chan string +} + +func (s *loginManagerSuite) Init(c *qt.C) { + // Setup DB + db := &db.Database{ + DB: jimmtest.PostgresDB(c, time.Now), + } + err := db.Migrate(context.Background(), false) + c.Assert(err, qt.IsNil) + + s.db = db + + // Setup OFGA + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + s.ofgaClient = ofgaClient + + s.deviceFlowChan = make(chan string, 1) + mockAuthenticator := jimmtest.NewMockOAuthAuthenticator(c, s.deviceFlowChan) + + s.jimmTag = names.NewControllerTag("foo") + + s.manager, err = login.NewLoginManager(db, ofgaClient, &mockAuthenticator, s.jimmTag) + c.Assert(err, qt.IsNil) + + // Create test identity + i, err := dbmodel.NewIdentity("alice") + c.Assert(err, qt.IsNil) + s.user = openfga.NewUser(i, ofgaClient) +} + +func (s *loginManagerSuite) TestLoginDevice(c *qt.C) { + c.Parallel() + resp, err := s.manager.LoginDevice(context.Background()) + c.Assert(err, qt.IsNil) + c.Assert(*resp, qt.CmpEquals(cmpopts.IgnoreTypes(time.Time{})), oauth2.DeviceAuthResponse{ + DeviceCode: "test-device-code", + UserCode: "test-user-code", + VerificationURI: "http://no-such-uri.canonical.com", + VerificationURIComplete: "http://no-such-uri.canonical.com", + Interval: int64(time.Minute.Seconds()), + }) +} + +func (s *loginManagerSuite) TestGetDeviceSessionToken(c *qt.C) { + c.Parallel() + + s.deviceFlowChan <- "user-foo" + token, err := s.manager.GetDeviceSessionToken(context.Background(), nil) + c.Assert(err, qt.IsNil) + c.Assert(token, qt.Not(qt.Equals), "") + + decodedToken, err := base64.StdEncoding.DecodeString(token) + c.Assert(err, qt.IsNil) + + parsedToken, err := jwt.ParseInsecure([]byte(decodedToken)) + c.Assert(err, qt.IsNil) + c.Assert(parsedToken.Subject(), qt.Equals, "user-foo@canonical.com") +} + +func (s *loginManagerSuite) TestLoginClientCredentials(c *qt.C) { + c.Parallel() + ctx := context.Background() + invalidClientID := "123@123@" + _, err := s.manager.LoginClientCredentials(ctx, invalidClientID, "foo-secret") + c.Assert(err, qt.ErrorMatches, "invalid client ID") + + validClientID := "my-svc-acc" + user, err := s.manager.LoginClientCredentials(ctx, validClientID, "foo-secret") + c.Assert(err, qt.IsNil) + c.Assert(user.Name, qt.Equals, "my-svc-acc@serviceaccount") +} + +func (s *loginManagerSuite) TestLoginWithSessionToken(c *qt.C) { + c.Parallel() + ctx := context.Background() + + token, err := jwt.NewBuilder(). + Subject("alice@canonical.com"). + Build() + c.Assert(err, qt.IsNil) + serialisedToken, err := jwt.NewSerializer().Serialize(token) + c.Assert(err, qt.IsNil) + b64Token := base64.StdEncoding.EncodeToString(serialisedToken) + + _, err = s.manager.LoginWithSessionToken(ctx, "invalid-token") + c.Assert(err, qt.ErrorMatches, "failed to decode token") + + user, err := s.manager.LoginWithSessionToken(ctx, b64Token) + c.Assert(err, qt.IsNil) + c.Assert(user.Name, qt.Equals, "alice@canonical.com") +} + +func (s *loginManagerSuite) TestLoginWithSessionCookie(c *qt.C) { + c.Parallel() + ctx := context.Background() + + _, err := s.manager.LoginWithSessionCookie(ctx, "") + c.Assert(err, qt.ErrorMatches, "missing cookie identity") + + user, err := s.manager.LoginWithSessionCookie(ctx, "alice@canonical.com") + c.Assert(err, qt.IsNil) + c.Assert(user.Name, qt.Equals, "alice@canonical.com") +} + +func (s *loginManagerSuite) TestGetOrCreateIdentity(c *qt.C) { + c.Parallel() + ctx := context.Background() + + ofgaUser, err := s.manager.GetOrCreateIdentity(ctx, "bob@canonical.com") + c.Assert(err, qt.IsNil) + // Username -> email + c.Assert(ofgaUser.Name, qt.Equals, "bob@canonical.com") + // As no display name was set for this user as they're being created this time over + c.Assert(ofgaUser.DisplayName, qt.Equals, "bob") + // This user SHOULD NOT be an admin, so ensure admin check is OK + c.Assert(ofgaUser.JimmAdmin, qt.IsFalse) + + // Next we'll update this user to an admin of JIMM and run the same tests. + c.Assert( + ofgaUser.SetControllerAccess( + context.Background(), + s.jimmTag, + ofganames.AdministratorRelation, + ), + qt.IsNil, + ) + + ofgaUser, err = s.manager.GetOrCreateIdentity(ctx, "bob@canonical.com") + c.Assert(err, qt.IsNil) + + c.Assert(ofgaUser.Name, qt.Equals, "bob@canonical.com") + c.Assert(ofgaUser.DisplayName, qt.Equals, "bob") + // This user SHOULD be an admin, so ensure admin check is OK + c.Assert(ofgaUser.JimmAdmin, qt.IsTrue) +} + +func (s *loginManagerSuite) TestUpdateLastLogin(c *qt.C) { + c.Parallel() + + ctx := context.Background() + + ofgaUser, err := s.manager.UserLogin(ctx, "bob@canonical.com") + c.Assert(err, qt.IsNil) + c.Assert(ofgaUser, qt.Not(qt.IsNil)) + + user := dbmodel.Identity{Name: "bob@canonical.com"} + err = s.db.GetIdentity(ctx, &user) + c.Assert(err, qt.IsNil) + c.Assert(user.DisplayName, qt.Equals, "bob") + c.Assert(user.LastLogin.Time, qt.Not(qt.Equals), time.Time{}) + c.Assert(user.LastLogin.Valid, qt.IsTrue) +} + +func TestLoginManager(t *testing.T) { + qtsuite.Run(qt.New(t), &loginManagerSuite{}) +} diff --git a/internal/jimm/model_cleanup_test.go b/internal/jimm/model_cleanup_test.go index 9e8354ffd..be472ca16 100644 --- a/internal/jimm/model_cleanup_test.go +++ b/internal/jimm/model_cleanup_test.go @@ -95,7 +95,11 @@ func (s *modelCleanupSuite) Init(c *qt.C) { s.jimm = jimmtest.NewJIMM(c, nil) err = s.jimm.Database.Migrate(ctx, false) c.Assert(err, qt.IsNil) - s.jimmAdmin, err = s.jimm.GetUser(ctx, "alice@canonical.com") + + i, err := dbmodel.NewIdentity("alice@canonical.com") + c.Assert(err, qt.IsNil) + s.jimmAdmin = openfga.NewUser(i, s.ofgaClient) + s.jimmAdmin.JimmAdmin = true c.Assert(err, qt.IsNil) s.env = jimmtest.ParseEnvironment(c, modelPollerTestEnv) diff --git a/internal/jimm/user.go b/internal/jimm/user.go deleted file mode 100644 index 202be4278..000000000 --- a/internal/jimm/user.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2024 Canonical. - -package jimm - -import ( - "context" - "database/sql" - - "github.com/canonical/jimm/v3/internal/db" - "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/errors" - "github.com/canonical/jimm/v3/internal/openfga" -) - -// UserLogin fetches a user based on their identityName and updates their last login time. -func (j *JIMM) UserLogin(ctx context.Context, identityName string) (*openfga.User, error) { - const op = errors.Op("jimm.UserLogin") - user, err := j.getUser(ctx, identityName) - if err != nil { - return nil, errors.E(op, err, errors.CodeUnauthorized) - } - err = j.updateUserLastLogin(ctx, identityName) - if err != nil { - return nil, errors.E(op, err, errors.CodeUnauthorized) - } - return user, nil -} - -// getUser fetches the user specified by the user's email or the service accounts ID -// and returns an openfga User that can be used to verify user's permissions. -func (j *JIMM) getUser(ctx context.Context, identifier string) (*openfga.User, error) { - const op = errors.Op("jimm.GetUser") - - user, err := dbmodel.NewIdentity(identifier) - if err != nil { - return nil, errors.E(op, err) - } - - if err := j.Database.GetIdentity(ctx, user); err != nil { - return nil, err - } - u := openfga.NewUser(user, j.OpenFGAClient) - - isJimmAdmin, err := openfga.IsAdministrator(ctx, u, j.ResourceTag()) - if err != nil { - return nil, errors.E(op, err) - } - u.JimmAdmin = isJimmAdmin - - return u, nil -} - -// updateUserLastLogin updates the user's last login time in the database. -func (j *JIMM) updateUserLastLogin(ctx context.Context, identifier string) error { - const op = errors.Op("jimm.UpdateUserLastLogin") - user, err := dbmodel.NewIdentity(identifier) - if err != nil { - return err - } - if err := j.Database.Transaction(func(tx *db.Database) error { - if err := tx.GetIdentity(ctx, user); err != nil { - return err - } - user.LastLogin = sql.NullTime{ - Time: j.Database.DB.Config.NowFunc(), - Valid: true, - } - return tx.UpdateIdentity(ctx, user) - }); err != nil { - return errors.E(op, err) - } - return nil -} diff --git a/internal/jimm/user_test.go b/internal/jimm/user_test.go deleted file mode 100644 index 1fbba112b..000000000 --- a/internal/jimm/user_test.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2024 Canonical. - -package jimm_test - -import ( - "context" - "testing" - "time" - - qt "github.com/frankban/quicktest" - "github.com/juju/names/v5" - - "github.com/canonical/jimm/v3/internal/db" - "github.com/canonical/jimm/v3/internal/dbmodel" - "github.com/canonical/jimm/v3/internal/jimm" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" - "github.com/canonical/jimm/v3/internal/testutils/jimmtest" -) - -func TestGetUser(t *testing.T) { - c := qt.New(t) - - ctx := context.Background() - - j := jimmtest.NewJIMM(c, nil) - - ofgaUser, err := j.GetUser(ctx, "bob@canonical.com.com") - c.Assert(err, qt.IsNil) - // Username -> email - c.Assert(ofgaUser.Name, qt.Equals, "bob@canonical.com.com") - // As no display name was set for this user as they're being created this time over - c.Assert(ofgaUser.DisplayName, qt.Equals, "bob") - // This user SHOULD NOT be an admin, so ensure admin check is OK - c.Assert(ofgaUser.JimmAdmin, qt.IsFalse) - - // Next we'll update this user to an admin of JIMM and run the same tests. - c.Assert( - ofgaUser.SetControllerAccess( - context.Background(), - names.NewControllerTag(j.UUID), - ofganames.AdministratorRelation, - ), - qt.IsNil, - ) - - ofgaUser, err = j.GetUser(ctx, "bob@canonical.com.com") - c.Assert(err, qt.IsNil) - - c.Assert(ofgaUser.Name, qt.Equals, "bob@canonical.com.com") - c.Assert(ofgaUser.DisplayName, qt.Equals, "bob") - // This user SHOULD be an admin, so ensure admin check is OK - c.Assert(ofgaUser.JimmAdmin, qt.IsTrue) -} - -func TestUpdateUserLastLogin(t *testing.T) { - c := qt.New(t) - - ctx := context.Background() - - now := time.Now().Truncate(time.Millisecond) - db := &db.Database{ - DB: jimmtest.PostgresDB(c, func() time.Time { return now }), - } - - j := jimmtest.NewJIMM(c, &jimm.Parameters{ - Database: db, - }) - - err := j.UpdateUserLastLogin(ctx, "bob@canonical.com.com") - c.Assert(err, qt.IsNil) - user := dbmodel.Identity{Name: "bob@canonical.com.com"} - err = j.Database.GetIdentity(ctx, &user) - c.Assert(err, qt.IsNil) - c.Assert(user.DisplayName, qt.Equals, "bob") - c.Assert(user.LastLogin.Time, qt.Equals, now) - c.Assert(user.LastLogin.Valid, qt.IsTrue) -} diff --git a/internal/jimmhttp/httpproxy_handler.go b/internal/jimmhttp/httpproxy_handler.go index 9067aa0ec..fc1fb4049 100644 --- a/internal/jimmhttp/httpproxy_handler.go +++ b/internal/jimmhttp/httpproxy_handler.go @@ -42,7 +42,7 @@ func (hph *HTTPProxyHandler) Routes() chi.Router { // SetupMiddleware applies authn and authz middlewares. func (hph *HTTPProxyHandler) SetupMiddleware() { hph.Router.Use(func(h http.Handler) http.Handler { - return middleware.AuthenticateWithSessionTokenViaBasicAuth(h, hph.jimm) + return middleware.AuthenticateWithSessionTokenViaBasicAuth(h, hph.jimm.LoginManager()) }) hph.Router.Use(func(h http.Handler) http.Handler { return middleware.AuthorizeUserForModelAccess(h, ofganames.WriterRelation) diff --git a/internal/jimmhttp/rebac_admin/capabilities_test.go b/internal/jimmhttp/rebac_admin/capabilities_test.go index dc0aac495..22bb00f88 100644 --- a/internal/jimmhttp/rebac_admin/capabilities_test.go +++ b/internal/jimmhttp/rebac_admin/capabilities_test.go @@ -11,14 +11,27 @@ import ( qt "github.com/frankban/quicktest" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/jimmhttp/rebac_admin" + "github.com/canonical/jimm/v3/internal/openfga" "github.com/canonical/jimm/v3/internal/testutils/jimmtest" + "github.com/canonical/jimm/v3/internal/testutils/jimmtest/mocks" ) // test capabilities are reachable func TestCapabilities(t *testing.T) { c := qt.New(t) - jimm := jimmtest.JIMM{} + identityManager := mocks.IdentityManager{ + FetchIdentity_: func(ctx context.Context, id string) (*openfga.User, error) { + return openfga.NewUser(&dbmodel.Identity{Name: id}, nil), nil + }, + } + jimm := jimmtest.JIMM{ + IdentityManager_: func() jimm.IdentityManager { + return &identityManager + }, + } ctx := context.Background() handlers, err := rebac_admin.SetupBackend(ctx, &jimm) c.Assert(err, qt.IsNil) diff --git a/internal/jimmhttp/rebac_admin/identities.go b/internal/jimmhttp/rebac_admin/identities.go index c01acc363..709c6e4da 100644 --- a/internal/jimmhttp/rebac_admin/identities.go +++ b/internal/jimmhttp/rebac_admin/identities.go @@ -39,7 +39,7 @@ func (s *identitiesService) ListIdentities(ctx context.Context, params *resource return nil, err } - count, err := s.jimm.CountIdentities(ctx, user) + count, err := s.jimm.IdentityManager().CountIdentities(ctx, user) if err != nil { return nil, err } @@ -48,7 +48,7 @@ func (s *identitiesService) ListIdentities(ctx context.Context, params *resource if params.Filter != nil && *params.Filter != "" { match = *params.Filter } - users, err := s.jimm.ListIdentities(ctx, user, pagination, match) + users, err := s.jimm.IdentityManager().ListIdentities(ctx, user, pagination, match) if err != nil { return nil, err } @@ -77,7 +77,7 @@ func (s *identitiesService) CreateIdentity(ctx context.Context, identity *resour // GetIdentity returns a single Identity. func (s *identitiesService) GetIdentity(ctx context.Context, identityId string) (*resources.Identity, error) { - user, err := s.jimm.FetchIdentity(ctx, identityId) + user, err := s.jimm.IdentityManager().FetchIdentity(ctx, identityId) if err != nil { if errors.ErrorCode(err) == errors.CodeNotFound { return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) @@ -104,7 +104,7 @@ func (s *identitiesService) GetIdentityRoles(ctx context.Context, identityId str if err != nil { return nil, err } - objUser, err := s.jimm.FetchIdentity(ctx, identityId) + objUser, err := s.jimm.IdentityManager().FetchIdentity(ctx, identityId) if err != nil { return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) } @@ -156,7 +156,7 @@ func (s *identitiesService) PatchIdentityRoles(ctx context.Context, identityId s return false, err } - objUser, err := s.jimm.FetchIdentity(ctx, identityId) + objUser, err := s.jimm.IdentityManager().FetchIdentity(ctx, identityId) if err != nil { return false, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) } @@ -200,7 +200,7 @@ func (s *identitiesService) GetIdentityGroups(ctx context.Context, identityId st if err != nil { return nil, err } - objUser, err := s.jimm.FetchIdentity(ctx, identityId) + objUser, err := s.jimm.IdentityManager().FetchIdentity(ctx, identityId) if err != nil { return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) } @@ -252,7 +252,7 @@ func (s *identitiesService) PatchIdentityGroups(ctx context.Context, identityId return false, err } - objUser, err := s.jimm.FetchIdentity(ctx, identityId) + objUser, err := s.jimm.IdentityManager().FetchIdentity(ctx, identityId) if err != nil { return false, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) } @@ -296,7 +296,7 @@ func (s *identitiesService) GetIdentityEntitlements(ctx context.Context, identit if err != nil { return nil, err } - objUser, err := s.jimm.FetchIdentity(ctx, identityId) + objUser, err := s.jimm.IdentityManager().FetchIdentity(ctx, identityId) if err != nil { if errors.ErrorCode(err) == errors.CodeNotFound { return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) @@ -333,7 +333,7 @@ func (s *identitiesService) PatchIdentityEntitlements(ctx context.Context, ident if err != nil { return false, err } - objUser, err := s.jimm.FetchIdentity(ctx, identityId) + objUser, err := s.jimm.IdentityManager().FetchIdentity(ctx, identityId) if err != nil { return false, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) } diff --git a/internal/jimmhttp/rebac_admin/identities_integration_test.go b/internal/jimmhttp/rebac_admin/identities_integration_test.go index 04343a52d..5a00dedb2 100644 --- a/internal/jimmhttp/rebac_admin/identities_integration_test.go +++ b/internal/jimmhttp/rebac_admin/identities_integration_test.go @@ -74,7 +74,7 @@ func (s *identitiesSuite) TestIdentityPatchGroups(c *gc.C) { c.Assert(changed, gc.Equals, true) // test user added to groups - objUser, err := s.JIMM.FetchIdentity(ctx, username) + objUser, err := s.JIMM.IdentityManager().FetchIdentity(ctx, username) c.Assert(err, gc.IsNil) tuples, _, err := s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ Object: objUser.ResourceTag().String(), @@ -204,7 +204,7 @@ func (s *identitiesSuite) TestIdentityPatchRoles(c *gc.C) { c.Assert(changed, gc.Equals, true) // test user added to roles - objUser, err := s.JIMM.FetchIdentity(ctx, username) + objUser, err := s.JIMM.IdentityManager().FetchIdentity(ctx, username) c.Assert(err, gc.IsNil) tuples, _, err := s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ Object: objUser.ResourceTag().String(), diff --git a/internal/jimmhttp/rebac_admin/identities_test.go b/internal/jimmhttp/rebac_admin/identities_test.go index 13bf66a6e..0b9a068b5 100644 --- a/internal/jimmhttp/rebac_admin/identities_test.go +++ b/internal/jimmhttp/rebac_admin/identities_test.go @@ -28,7 +28,7 @@ import ( func TestGetIdentity(t *testing.T) { c := qt.New(t) - jimm := jimmtest.JIMM{ + identityManager := mocks.IdentityManager{ FetchIdentity_: func(ctx context.Context, username string) (*openfga.User, error) { if username == "bob@canonical.com" { return openfga.NewUser(&dbmodel.Identity{Name: "bob@canonical.com"}, nil), nil @@ -36,6 +36,11 @@ func TestGetIdentity(t *testing.T) { return nil, jimmm_errors.E(jimmm_errors.CodeNotFound) }, } + jimm := jimmtest.JIMM{ + IdentityManager_: func() jimm.IdentityManager { + return &identityManager + }, + } user := openfga.User{} user.JimmAdmin = true ctx := context.Background() @@ -61,7 +66,7 @@ func TestListIdentities(t *testing.T) { *openfga.NewUser(&dbmodel.Identity{Name: "bob4@canonical.com"}, nil), } c := qt.New(t) - jimm := jimmtest.JIMM{ + identityManager := mocks.IdentityManager{ ListIdentities_: func(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) { start := pagination.Offset() end := start + pagination.Limit() @@ -74,6 +79,11 @@ func TestListIdentities(t *testing.T) { return len(testUsers), nil }, } + jimm := jimmtest.JIMM{ + IdentityManager_: func() jimm.IdentityManager { + return &identityManager + }, + } user := openfga.User{} user.JimmAdmin = true ctx := context.Background() @@ -157,13 +167,18 @@ func TestGetIdentityGroups(t *testing.T) { return &dbmodel.GroupEntry{Name: "fake-group-name"}, nil }, } - jimm := jimmtest.JIMM{ - FetchIdentity_: func(ctx context.Context, username string) (*openfga.User, error) { - if username == "bob@canonical.com" { + identityManager := mocks.IdentityManager{ + FetchIdentity_: func(ctx context.Context, id string) (*openfga.User, error) { + if id == "bob@canonical.com" { return openfga.NewUser(&dbmodel.Identity{Name: "bob@canonical.com"}, nil), nil } return nil, dbmodel.IdentityCreationError }, + } + jimm := jimmtest.JIMM{ + IdentityManager_: func() jimm.IdentityManager { + return &identityManager + }, RelationService: mocks.RelationService{ ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { return []openfga.Tuple{testTuple}, "continuation-token", listTuplesErr @@ -198,13 +213,18 @@ func TestGetIdentityGroups(t *testing.T) { func TestPatchIdentityGroups(t *testing.T) { c := qt.New(t) var patchTuplesErr error - jimm := jimmtest.JIMM{ - FetchIdentity_: func(ctx context.Context, username string) (*openfga.User, error) { - if username == "bob@canonical.com" { + identityManager := mocks.IdentityManager{ + FetchIdentity_: func(ctx context.Context, id string) (*openfga.User, error) { + if id == "bob@canonical.com" { return openfga.NewUser(&dbmodel.Identity{Name: "bob@canonical.com"}, nil), nil } return nil, dbmodel.IdentityCreationError }, + } + jimm := jimmtest.JIMM{ + IdentityManager_: func() jimm.IdentityManager { + return &identityManager + }, RelationService: mocks.RelationService{ AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { return patchTuplesErr @@ -257,13 +277,18 @@ func TestGetIdentityRoles(t *testing.T) { return &dbmodel.RoleEntry{Name: "fake-role-name"}, nil }, } - jimm := jimmtest.JIMM{ - FetchIdentity_: func(ctx context.Context, username string) (*openfga.User, error) { - if username == "bob@canonical.com" { + identityManager := mocks.IdentityManager{ + FetchIdentity_: func(ctx context.Context, id string) (*openfga.User, error) { + if id == "bob@canonical.com" { return openfga.NewUser(&dbmodel.Identity{Name: "bob@canonical.com"}, nil), nil } return nil, dbmodel.IdentityCreationError }, + } + jimm := jimmtest.JIMM{ + IdentityManager_: func() jimm.IdentityManager { + return &identityManager + }, RelationService: mocks.RelationService{ ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { return []openfga.Tuple{testTuple}, "continuation-token", listTuplesErr @@ -298,13 +323,18 @@ func TestGetIdentityRoles(t *testing.T) { func TestPatchIdentityRoles(t *testing.T) { c := qt.New(t) var patchTuplesErr error - jimm := jimmtest.JIMM{ - FetchIdentity_: func(ctx context.Context, username string) (*openfga.User, error) { - if username == "bob@canonical.com" { + identityManager := mocks.IdentityManager{ + FetchIdentity_: func(ctx context.Context, id string) (*openfga.User, error) { + if id == "bob@canonical.com" { return openfga.NewUser(&dbmodel.Identity{Name: "bob@canonical.com"}, nil), nil } return nil, dbmodel.IdentityCreationError }, + } + jimm := jimmtest.JIMM{ + IdentityManager_: func() jimm.IdentityManager { + return &identityManager + }, RelationService: mocks.RelationService{ AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { return patchTuplesErr diff --git a/internal/jujuapi/admin.go b/internal/jujuapi/admin.go index c15ff1bf0..49a5bb925 100644 --- a/internal/jujuapi/admin.go +++ b/internal/jujuapi/admin.go @@ -53,7 +53,7 @@ func (r *controllerRoot) LoginDevice(ctx context.Context) (params.LoginDeviceRes const op = errors.Op("jujuapi.LoginDevice") response := params.LoginDeviceResponse{} - deviceResponse, err := r.jimm.LoginDevice(ctx) + deviceResponse, err := r.jimm.LoginManager().LoginDevice(ctx) if err != nil { return response, errors.E(op, err, errors.CodeUnauthorized) } @@ -77,7 +77,7 @@ func (r *controllerRoot) GetDeviceSessionToken(ctx context.Context) (params.GetD const op = errors.Op("jujuapi.GetDeviceSessionToken") response := params.GetDeviceSessionTokenResponse{} - token, err := r.jimm.GetDeviceSessionToken(ctx, r.deviceOAuthResponse) + token, err := r.jimm.LoginManager().GetDeviceSessionToken(ctx, r.deviceOAuthResponse) if err != nil { return response, errors.E(op, err, errors.CodeUnauthorized) } @@ -95,7 +95,7 @@ func (r *controllerRoot) GetDeviceSessionToken(ctx context.Context) (params.GetD func (r *controllerRoot) LoginWithSessionCookie(ctx context.Context) (jujuparams.LoginResult, error) { const op = errors.Op("jujuapi.LoginWithSessionCookie") - user, err := r.jimm.LoginWithSessionCookie(ctx, r.identityId) + user, err := r.jimm.LoginManager().LoginWithSessionCookie(ctx, r.identityId) if err != nil { return jujuparams.LoginResult{}, errors.E(op, err, errors.CodeUnauthorized) } @@ -127,7 +127,7 @@ func (r *controllerRoot) LoginWithSessionCookie(ctx context.Context) (jujuparams func (r *controllerRoot) LoginWithSessionToken(ctx context.Context, req params.LoginWithSessionTokenRequest) (jujuparams.LoginResult, error) { const op = errors.Op("jujuapi.LoginWithSessionToken") - user, err := r.jimm.LoginWithSessionToken(ctx, req.SessionToken) + user, err := r.jimm.LoginManager().LoginWithSessionToken(ctx, req.SessionToken) if err != nil { // Avoid masking the error code on err below. The Juju CLI uses it to determine when to initiate login see [OAuthAuthenticator.VerifySessionToken]. return jujuparams.LoginResult{}, errors.E(op, err) @@ -159,7 +159,7 @@ func (r *controllerRoot) LoginWithSessionToken(ctx context.Context, req params.L func (r *controllerRoot) LoginWithClientCredentials(ctx context.Context, req params.LoginWithClientCredentialsRequest) (jujuparams.LoginResult, error) { const op = errors.Op("jujuapi.LoginWithClientCredentials") - user, err := r.jimm.LoginClientCredentials(ctx, req.ClientID, req.ClientSecret) + user, err := r.jimm.LoginManager().LoginClientCredentials(ctx, req.ClientID, req.ClientSecret) if err != nil { return jujuparams.LoginResult{}, errors.E(err, errors.CodeUnauthorized) } diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index 778e6d85d..a21700e6a 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -89,7 +89,7 @@ func (r *controllerRoot) masquerade(ctx context.Context, userTag string) (*openf if !r.user.JimmAdmin { return nil, errors.E(errors.CodeUnauthorized, "unauthorized") } - user, err := r.jimm.UserLogin(ctx, ut.Id()) + user, err := r.jimm.LoginManager().UserLogin(ctx, ut.Id()) if err != nil { return nil, err } diff --git a/internal/jujuapi/interface.go b/internal/jujuapi/interface.go index a9bfc03dd..0e260420b 100644 --- a/internal/jujuapi/interface.go +++ b/internal/jujuapi/interface.go @@ -25,14 +25,12 @@ import ( type JIMM interface { RelationService ControllerService - LoginService ModelManager AddAuditLogEntry(ale *dbmodel.AuditLogEntry) AddCloudToController(ctx context.Context, user *openfga.User, controllerName string, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddHostedCloud(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddServiceAccount(ctx context.Context, u *openfga.User, clientId string) error CopyServiceAccountCredential(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) - CountIdentities(ctx context.Context, user *openfga.User) (int, error) DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error FindApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) FindAuditEvents(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) @@ -46,9 +44,9 @@ type JIMM interface { GetCloudCredentialAttributes(ctx context.Context, u *openfga.User, cred *dbmodel.CloudCredential, hidden bool) (attrs map[string]string, redacted []string, err error) RoleManager() jimm.RoleManager GroupManager() jimm.GroupManager + IdentityManager() jimm.IdentityManager + LoginManager() jimm.LoginManager GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) - // FetchIdentity finds the user in jimm or returns a not-found error - FetchIdentity(ctx context.Context, username string) (*openfga.User, error) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) GetUserControllerAccess(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) GetUserModelAccess(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) @@ -60,7 +58,6 @@ type JIMM interface { InitiateInternalMigration(ctx context.Context, user *openfga.User, modelNameOrUUID string, targetController string) (jujuparams.InitiateMigrationResult, error) InitiateMigration(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) - ListIdentities(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) ListModels(ctx context.Context, user *openfga.User) ([]base.UserModel, error) ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination, namePrefixFilter, typeFilter string) ([]db.Resource, error) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error @@ -78,5 +75,4 @@ type JIMM interface { ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) UpdateCloud(ctx context.Context, u *openfga.User, ct names.CloudTag, cloud jujuparams.Cloud) error UpdateCloudCredential(ctx context.Context, u *openfga.User, args jimm.UpdateCloudCredentialArgs) ([]jujuparams.UpdateCredentialModelResult, error) - UserLogin(ctx context.Context, identityName string) (*openfga.User, error) } diff --git a/internal/jujuapi/service_account.go b/internal/jujuapi/service_account.go index 2e0b5dc85..bfa8262aa 100644 --- a/internal/jujuapi/service_account.go +++ b/internal/jujuapi/service_account.go @@ -72,7 +72,7 @@ func (r *controllerRoot) getServiceAccount(ctx context.Context, clientID string) return nil, errors.E(errors.CodeUnauthorized, "unauthorized") } - return r.jimm.UserLogin(ctx, clientIdWithDomain) + return r.jimm.LoginManager().UserLogin(ctx, clientIdWithDomain) } // UpdateServiceAccountCredentialsCheckModels updates a set of cloud credentials' content. diff --git a/internal/jujuapi/streamproxy.go b/internal/jujuapi/streamproxy.go index cbb76b6b0..f86de3c9a 100644 --- a/internal/jujuapi/streamproxy.go +++ b/internal/jujuapi/streamproxy.go @@ -58,7 +58,7 @@ func (s streamProxier) ServeWS(ctx context.Context, clientConn *websocket.Conn) } } - user, err := s.jimm.UserLogin(ctx, auth.SessionIdentityFromContext(ctx)) + user, err := s.jimm.LoginManager().UserLogin(ctx, auth.SessionIdentityFromContext(ctx)) if err != nil { zapctx.Error(ctx, "user login error", zap.Error(err)) writeError(err.Error(), errors.CodeUnauthorized) diff --git a/internal/jujuapi/websocket.go b/internal/jujuapi/websocket.go index 3f8d70efa..48c37f3e1 100644 --- a/internal/jujuapi/websocket.go +++ b/internal/jujuapi/websocket.go @@ -182,7 +182,7 @@ func (s apiProxier) ServeWS(ctx context.Context, clientConn *websocket.Conn) { TokenGen: &jwtGenerator, ConnectController: connectionFunc, AuditLog: auditLogger, - LoginService: s.jimm, + LoginService: s.jimm.LoginManager(), AuthenticatedIdentityID: auth.SessionIdentityFromContext(ctx), } if err := jimmRPC.ProxySockets(ctx, proxyHelpers); err != nil { diff --git a/internal/middleware/authn_test.go b/internal/middleware/authn_test.go index 7d8da973d..df2d6e2e5 100644 --- a/internal/middleware/authn_test.go +++ b/internal/middleware/authn_test.go @@ -18,7 +18,6 @@ import ( jimm_errors "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/middleware" "github.com/canonical/jimm/v3/internal/openfga" - "github.com/canonical/jimm/v3/internal/testutils/jimmtest" "github.com/canonical/jimm/v3/internal/testutils/jimmtest/mocks" ) @@ -87,11 +86,9 @@ func TestAuthenticateRebac(t *testing.T) { for _, tt := range tests { c.Run(tt.name, func(c *qt.C) { - j := jimmtest.JIMM{ - LoginService: mocks.LoginService{ - AuthenticateBrowserSession_: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { - return tt.mockAuthBrowserSession(ctx, w, req) - }, + loginManager := mocks.LoginManager{ + AuthenticateBrowserSession_: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return tt.mockAuthBrowserSession(ctx, w, req) }, UserLogin_: func(ctx context.Context, username string) (*openfga.User, error) { user := dbmodel.Identity{Name: username} @@ -124,7 +121,7 @@ func TestAuthenticateRebac(t *testing.T) { }) } - middleware := middleware.AuthenticateRebac(baseURL, handler, &j) + middleware := middleware.AuthenticateRebac(baseURL, handler, &loginManager) middleware.ServeHTTP(w, req) c.Assert(w.Code, qt.Equals, tt.expectedStatus) @@ -139,15 +136,13 @@ func TestAuthenticateRebac(t *testing.T) { func TestAuthenticateViaBasicAuth(t *testing.T) { testUser := "test-user@canonical.com" - jt := jimmtest.JIMM{ - LoginService: mocks.LoginService{ - LoginWithSessionToken_: func(ctx context.Context, sessionToken string) (*openfga.User, error) { - if sessionToken != "good" { - return nil, jimm_errors.E(jimm_errors.CodeSessionTokenInvalid) - } - user := dbmodel.Identity{Name: testUser} - return &openfga.User{Identity: &user, JimmAdmin: true}, nil - }, + loginManager := mocks.LoginManager{ + LoginWithSessionToken_: func(ctx context.Context, sessionToken string) (*openfga.User, error) { + if sessionToken != "good" { + return nil, jimm_errors.E(jimm_errors.CodeSessionTokenInvalid) + } + user := dbmodel.Identity{Name: testUser} + return &openfga.User{Identity: &user, JimmAdmin: true}, nil }, } tests := []struct { @@ -190,7 +185,7 @@ func TestAuthenticateViaBasicAuth(t *testing.T) { c.Assert(user.Name, qt.Equals, testUser) w.WriteHeader(http.StatusOK) }) - middleware := middleware.AuthenticateWithSessionTokenViaBasicAuth(handler, &jt) + middleware := middleware.AuthenticateWithSessionTokenViaBasicAuth(handler, &loginManager) middleware.ServeHTTP(w, req) c.Assert(w.Code, qt.Equals, tt.expectedStatus) b := w.Result().Body diff --git a/internal/testutils/jimmtest/auth.go b/internal/testutils/jimmtest/auth.go index 18030387d..2432ef8fd 100644 --- a/internal/testutils/jimmtest/auth.go +++ b/internal/testutils/jimmtest/auth.go @@ -109,6 +109,8 @@ func (m *mockOAuthAuthenticator) DeviceAccessToken(ctx context.Context, res *oau m.mockAccessToken = uuid.String() case <-ctx.Done(): return &oauth2.Token{}, ctx.Err() + case <-time.After(1 * time.Second): + return nil, errors.New("no user found, make sure to set the PollingChan field and pass a username before starting the test") } return &oauth2.Token{AccessToken: m.mockAccessToken}, nil } diff --git a/internal/testutils/jimmtest/jimm_mock.go b/internal/testutils/jimmtest/jimm_mock.go index 0b61fa46c..dfcfedde5 100644 --- a/internal/testutils/jimmtest/jimm_mock.go +++ b/internal/testutils/jimmtest/jimm_mock.go @@ -32,7 +32,6 @@ import ( type JIMM struct { mocks.RelationService mocks.ControllerService - mocks.LoginService mocks.ModelManager AddAuditLogEntry_ func(ale *dbmodel.AuditLogEntry) AddCloudToController_ func(ctx context.Context, user *openfga.User, controllerName string, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error @@ -41,9 +40,7 @@ type JIMM struct { Authenticate_ func(ctx context.Context, req *jujuparams.LoginRequest) (*openfga.User, error) CheckPermission_ func(ctx context.Context, user *openfga.User, cachedPerms map[string]string, desiredPerms map[string]interface{}) (map[string]string, error) CopyServiceAccountCredential_ func(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) - CountIdentities_ func(ctx context.Context, user *openfga.User) (int, error) DestroyOffer_ func(ctx context.Context, user *openfga.User, offerURL string, force bool) error - FetchIdentity_ func(ctx context.Context, username string) (*openfga.User, error) FindApplicationOffers_ func(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) FindAuditEvents_ func(ctx context.Context, user *openfga.User, filter db.AuditLogFilter) ([]dbmodel.AuditLogEntry, error) ForEachCloud_ func(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error @@ -65,10 +62,11 @@ type JIMM struct { GrantOfferAccess_ func(ctx context.Context, u *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) error GrantServiceAccountAccess_ func(ctx context.Context, u *openfga.User, svcAccTag jimmnames.ServiceAccountTag, entities []string) error GroupManager_ func() jimm.GroupManager + IdentityManager_ func() jimm.IdentityManager + LoginManager_ func() jimm.LoginManager InitiateInternalMigration_ func(ctx context.Context, user *openfga.User, modelNameOrUUID string, targetController string) (jujuparams.InitiateMigrationResult, error) InitiateMigration_ func(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers_ func(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) - ListIdentities_ func(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) ListResources_ func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination, namePrefixFilter, typeFilter string) ([]db.Resource, error) Offer_ func(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error PubSubHub_ func() *pubsub.Hub @@ -82,7 +80,6 @@ type JIMM struct { RevokeModelAccess_ func(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error RevokeOfferAccess_ func(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) RoleManager_ func() jimm.RoleManager - SetIdentityModelDefaults_ func(ctx context.Context, user *dbmodel.Identity, configs map[string]interface{}) error ToJAASTag_ func(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) UpdateApplicationOffer_ func(ctx context.Context, controller *dbmodel.Controller, offerUUID string, removed bool) error UpdateCloud_ func(ctx context.Context, u *openfga.User, ct names.CloudTag, cloud jujuparams.Cloud) error @@ -227,29 +224,25 @@ func (j *JIMM) GroupManager() jimm.GroupManager { return j.GroupManager_() } -func (j *JIMM) GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) { - if j.GetJimmControllerAccess_ == nil { - return "", errors.E(errors.CodeNotImplemented) - } - return j.GetJimmControllerAccess_(ctx, user, tag) -} -func (j *JIMM) FetchIdentity(ctx context.Context, username string) (*openfga.User, error) { - if j.FetchIdentity_ == nil { - return nil, errors.E(errors.CodeNotImplemented) +func (j *JIMM) IdentityManager() jimm.IdentityManager { + if j.IdentityManager_ == nil { + return nil } - return j.FetchIdentity_(ctx, username) + return j.IdentityManager_() } -func (j *JIMM) CountIdentities(ctx context.Context, user *openfga.User) (int, error) { - if j.CountIdentities_ == nil { - return 0, errors.E(errors.CodeNotImplemented) + +func (j *JIMM) LoginManager() jimm.LoginManager { + if j.LoginManager_ == nil { + return nil } - return j.CountIdentities_(ctx, user) + return j.LoginManager_() } -func (j *JIMM) ListIdentities(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) { - if j.ListIdentities_ == nil { - return nil, errors.E(errors.CodeNotImplemented) + +func (j *JIMM) GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) { + if j.GetJimmControllerAccess_ == nil { + return "", errors.E(errors.CodeNotImplemented) } - return j.ListIdentities_(ctx, user, pagination, match) + return j.GetJimmControllerAccess_(ctx, user, tag) } func (j *JIMM) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) { if j.GetUserCloudAccess_ == nil { @@ -391,12 +384,6 @@ func (j *JIMM) RevokeOfferAccess(ctx context.Context, user *openfga.User, offerU } return j.RevokeOfferAccess_(ctx, user, offerURL, ut, access) } -func (j *JIMM) SetIdentityModelDefaults(ctx context.Context, user *dbmodel.Identity, configs map[string]interface{}) error { - if j.SetIdentityModelDefaults_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.SetIdentityModelDefaults_(ctx, user, configs) -} func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) { if j.ToJAASTag_ == nil { return "", errors.E(errors.CodeNotImplemented) diff --git a/internal/testutils/jimmtest/mocks/jimm_controller_mock.go b/internal/testutils/jimmtest/mocks/jimm_controller_mock.go index 501b5599f..57bf530ac 100644 --- a/internal/testutils/jimmtest/mocks/jimm_controller_mock.go +++ b/internal/testutils/jimmtest/mocks/jimm_controller_mock.go @@ -1,4 +1,5 @@ // Copyright 2024 Canonical. + package mocks import ( diff --git a/internal/testutils/jimmtest/mocks/jimm_identity_mock.go b/internal/testutils/jimmtest/mocks/jimm_identity_mock.go new file mode 100644 index 000000000..6a1d18110 --- /dev/null +++ b/internal/testutils/jimmtest/mocks/jimm_identity_mock.go @@ -0,0 +1,37 @@ +// Copyright 2024 Canonical. + +package mocks + +import ( + "context" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" +) + +// IdentityManager is an implementation of the jimm.IdentityManager interface. +type IdentityManager struct { + FetchIdentity_ func(ctx context.Context, id string) (*openfga.User, error) + ListIdentities_ func(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) + CountIdentities_ func(ctx context.Context, user *openfga.User) (int, error) +} + +func (i *IdentityManager) FetchIdentity(ctx context.Context, id string) (*openfga.User, error) { + if i.FetchIdentity_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return i.FetchIdentity_(ctx, id) +} +func (i *IdentityManager) ListIdentities(ctx context.Context, user *openfga.User, pagination pagination.LimitOffsetPagination, match string) ([]openfga.User, error) { + if i.ListIdentities_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return i.ListIdentities_(ctx, user, pagination, match) +} +func (i *IdentityManager) CountIdentities(ctx context.Context, user *openfga.User) (int, error) { + if i.CountIdentities_ == nil { + return 0, errors.E(errors.CodeNotImplemented) + } + return i.CountIdentities_(ctx, user) +} diff --git a/internal/testutils/jimmtest/mocks/login.go b/internal/testutils/jimmtest/mocks/jimm_login_mock.go similarity index 73% rename from internal/testutils/jimmtest/mocks/login.go rename to internal/testutils/jimmtest/mocks/jimm_login_mock.go index 48e54779f..38a2ef2fd 100644 --- a/internal/testutils/jimmtest/mocks/login.go +++ b/internal/testutils/jimmtest/mocks/jimm_login_mock.go @@ -11,53 +11,61 @@ import ( "github.com/canonical/jimm/v3/internal/openfga" ) -type LoginService struct { +type LoginManager struct { AuthenticateBrowserSession_ func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) LoginDevice_ func(ctx context.Context) (*oauth2.DeviceAuthResponse, error) GetDeviceSessionToken_ func(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) LoginClientCredentials_ func(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) LoginWithSessionToken_ func(ctx context.Context, sessionToken string) (*openfga.User, error) LoginWithSessionCookie_ func(ctx context.Context, identityID string) (*openfga.User, error) + UserLogin_ func(ctx context.Context, identityName string) (*openfga.User, error) } -func (j *LoginService) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { +func (j *LoginManager) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { if j.AuthenticateBrowserSession_ == nil { return nil, errors.E(errors.CodeNotImplemented) } return j.AuthenticateBrowserSession_(ctx, w, req) } -func (j *LoginService) LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { +func (j *LoginManager) LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) { if j.LoginDevice_ == nil { return nil, errors.E(errors.CodeNotImplemented) } return j.LoginDevice_(ctx) } -func (j *LoginService) GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) { +func (j *LoginManager) GetDeviceSessionToken(ctx context.Context, deviceOAuthResponse *oauth2.DeviceAuthResponse) (string, error) { if j.GetDeviceSessionToken_ == nil { return "", errors.E(errors.CodeNotImplemented) } return j.GetDeviceSessionToken_(ctx, deviceOAuthResponse) } -func (j *LoginService) LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) { +func (j *LoginManager) LoginClientCredentials(ctx context.Context, clientID string, clientSecret string) (*openfga.User, error) { if j.LoginClientCredentials_ == nil { return nil, errors.E(errors.CodeNotImplemented) } return j.LoginClientCredentials_(ctx, clientID, clientSecret) } -func (j *LoginService) LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) { +func (j *LoginManager) LoginWithSessionToken(ctx context.Context, sessionToken string) (*openfga.User, error) { if j.LoginWithSessionToken_ == nil { return nil, errors.E(errors.CodeNotImplemented) } return j.LoginWithSessionToken_(ctx, sessionToken) } -func (j *LoginService) LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) { +func (j *LoginManager) LoginWithSessionCookie(ctx context.Context, identityID string) (*openfga.User, error) { if j.LoginWithSessionCookie_ == nil { return nil, errors.E(errors.CodeNotImplemented) } return j.LoginWithSessionCookie_(ctx, identityID) } + +func (j *LoginManager) UserLogin(ctx context.Context, identityName string) (*openfga.User, error) { + if j.UserLogin_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.UserLogin(ctx, identityName) +}