diff --git a/.github/actions/test-server/action.yaml b/.github/actions/test-server/action.yaml index 2d2933b36..6878542b9 100644 --- a/.github/actions/test-server/action.yaml +++ b/.github/actions/test-server/action.yaml @@ -41,15 +41,21 @@ runs: username: ${{ github.actor }} password: ${{ inputs.ghcr-pat }} + # We can't use a make target here because a composite action + # doesn't have a .git folder when checked out. - name: Start server based on released version if: ${{ inputs.jimm-version != 'dev' }} - run: make integration-test-env + run: | + cd local/traefik/certs; ./certs.sh; cd - && \ + docker compose --profile test up -d --wait shell: bash + working-directory: ${{ github.action_path }}/../../.. env: JIMM_VERSION: ${{ inputs.jimm-version }} - name: Start server based on development version if: ${{ inputs.jimm-version == 'dev' }} + working-directory: ${{ github.action_path }}/../../.. run: make dev-env shell: bash @@ -59,6 +65,7 @@ runs: echo 'jimm-ca<> $GITHUB_OUTPUT cat ./local/traefik/certs/ca.crt >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT + working-directory: ${{ github.action_path }}/../../.. shell: bash - name: Initialise LXD @@ -73,6 +80,7 @@ runs: - name: Setup cloud-init script for bootstraping Juju controllers run: ./local/jimm/setup-controller.sh shell: bash + working-directory: ${{ github.action_path }}/../../.. env: SKIP_BOOTSTRAP: true CLOUDINIT_FILE: "cloudinit.temp.yaml" @@ -83,7 +91,7 @@ runs: provider: "lxd" channel: "5.19/stable" juju-channel: ${{ inputs.juju-channel }} - bootstrap-options: "--config cloudinit.temp.yaml --config login-token-refresh-url=https://jimm.localhost/.well-known/jwks.json" + bootstrap-options: "--config ${{ github.action_path }}/../../../cloudinit.temp.yaml --config login-token-refresh-url=https://jimm.localhost/.well-known/jwks.json" # As described in https://github.com/charmed-kubernetes/actions-operator grab the newly setup controller name - name: Save LXD controller name @@ -100,6 +108,7 @@ runs: - name: Authenticate Juju CLI run: chmod -R 666 ~/.local/share/juju/*.yaml && ./local/jimm/setup-cli-auth.sh + working-directory: ${{ github.action_path }}/../../.. shell: bash # Below is a hardcoded JWT using the same test-secret used in JIMM's docker compose and allows the CLI to authenticate as the jimm-test@canonical.com user. env: @@ -107,6 +116,7 @@ runs: - name: Add LXD Juju controller to JIMM run: ./local/jimm/add-controller.sh + working-directory: ${{ github.action_path }}/../../.. shell: bash env: JIMM_CONTROLLER_NAME: "jimm" @@ -114,4 +124,5 @@ runs: - name: Provide service account with cloud-credentials run: ./local/jimm/setup-service-account.sh + working-directory: ${{ github.action_path }}/../../.. shell: bash diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index 3053f4ec0..9b24d51e7 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -9,7 +9,7 @@ permissions: jobs: golangci: name: Lint - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index 4dd4e6646..64b6cfe29 100644 --- a/Makefile +++ b/Makefile @@ -62,10 +62,10 @@ simplify: gofmt -w -l -s . # Generate version information -version/commit.txt: FORCE +version/commit.txt: git rev-parse --verify HEAD > version/commit.txt -version/version.txt: FORCE +version/version.txt: if [ -z "$(GIT_VERSION)" ]; then \ echo "dev" > version/version.txt; \ else \ @@ -155,6 +155,4 @@ help: @echo 'make rock - Build the JIMM rock.' @echo 'make load-rock - Load the most recently built rock into your local docker daemon.' -.PHONY: build check install release clean format server simplify sys-deps help FORCE - -FORCE: +.PHONY: build check install release clean format server simplify sys-deps help diff --git a/charms/README.md b/charms/README.md index 5c32b0db8..767d3c083 100644 --- a/charms/README.md +++ b/charms/README.md @@ -1,4 +1,13 @@ # JIMM Charms +Charms are a packaging tool for deploying application using Juju. +See more on charms [here](https://juju.is/charms-architecture). + +JIMM has a machine charm and a Kubernetes charm for deploying the +application on different substrates. Currently the machine charm +is not maintained in favor of the K8s charm. + +The machine charm has not yet been moved to a separate repo. + ## K8S Charm The K8S charm can be found [here](https://github.com/canonical/jimm-k8s-operator/). \ No newline at end of file diff --git a/cmd/jimmctl/cmd/group.go b/cmd/jimmctl/cmd/group.go index 759986737..74e8b8aee 100644 --- a/cmd/jimmctl/cmd/group.go +++ b/cmd/jimmctl/cmd/group.go @@ -323,6 +323,9 @@ type listGroupsCommand struct { store jujuclient.ClientStore dialOpts *jujuapi.DialOpts + + limit int + offset int } // Info implements the cmd.Command interface. @@ -349,6 +352,8 @@ func (c *listGroupsCommand) SetFlags(f *gnuflag.FlagSet) { "yaml": cmd.FormatYaml, "json": cmd.FormatJson, }) + f.IntVar(&c.limit, "limit", 0, "The maximum number of groups to return") + f.IntVar(&c.offset, "offset", 0, "The offset to use when requesting groups") } // Run implements Command.Run. @@ -364,7 +369,8 @@ func (c *listGroupsCommand) Run(ctxt *cmd.Context) error { } client := api.NewClient(apiCaller) - groups, err := client.ListGroups() + req := apiparams.ListGroupsRequest{Limit: c.limit, Offset: c.offset} + groups, err := client.ListGroups(&req) if err != nil { return errors.E(err) } diff --git a/cmd/jimmctl/cmd/group_test.go b/cmd/jimmctl/cmd/group_test.go index 4848b74cf..d9ff09db5 100644 --- a/cmd/jimmctl/cmd/group_test.go +++ b/cmd/jimmctl/cmd/group_test.go @@ -9,11 +9,13 @@ import ( "github.com/juju/cmd/v3/cmdtesting" gc "gopkg.in/check.v1" + "gopkg.in/yaml.v3" "github.com/canonical/jimm/v3/cmd/jimmctl/cmd" "github.com/canonical/jimm/v3/internal/cmdtest" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/pkg/api/params" ) type groupSuite struct { @@ -116,6 +118,26 @@ func (s *groupSuite) TestListGroupsSuperuser(c *gc.C) { c.Assert(strings.Contains(output, "test-group2"), gc.Equals, true) } +func (s *groupSuite) TestListGroupsLimitSuperuser(c *gc.C) { + // alice is superuser + bClient := jimmtest.NewUserSessionLogin(c, "alice") + + for i := 0; i < 3; i++ { + _, err := s.JimmCmdSuite.JIMM.Database.AddGroup(context.TODO(), fmt.Sprint("test-group", i)) + c.Assert(err, gc.IsNil) + } + + ctx, err := cmdtesting.RunCommand(c, cmd.NewListGroupsCommandForTesting(s.ClientStore(), bClient), "test-group", "--limit", "1", "--offset", "1") + c.Assert(err, gc.IsNil) + output := cmdtesting.Stdout(ctx) + groups := []params.Group{} + err = yaml.Unmarshal([]byte(output), &groups) + c.Assert(err, gc.IsNil) + c.Assert(groups, gc.HasLen, 1) + c.Assert(groups[0].Name, gc.Equals, "test-group1") + c.Assert(groups[0].UUID, gc.Not(gc.Equals), "") +} + func (s *groupSuite) TestListGroups(c *gc.C) { // bob is not superuser bClient := jimmtest.NewUserSessionLogin(c, "bob") diff --git a/cmd/jimmctl/cmd/relation_test.go b/cmd/jimmctl/cmd/relation_test.go index a1e4d32fb..3464910b8 100644 --- a/cmd/jimmctl/cmd/relation_test.go +++ b/cmd/jimmctl/cmd/relation_test.go @@ -251,7 +251,7 @@ func (s *relationSuite) TestRemoveRelationViaFileSuperuser(c *gc.C) { func (s *relationSuite) TestRemoveRelation(c *gc.C) { // bob is not superuser bClient := jimmtest.NewUserSessionLogin(c, "bob") - _, err := cmdtesting.RunCommand(c, cmd.NewRemoveRelationCommandForTesting(s.ClientStore(), bClient), "test-group1#member", "member", "test-group2") + _, err := cmdtesting.RunCommand(c, cmd.NewRemoveRelationCommandForTesting(s.ClientStore(), bClient), "group-testGroup1#member", "member", "group-testGroup2") c.Assert(err, gc.ErrorMatches, `unauthorized \(unauthorized access\)`) } @@ -440,9 +440,9 @@ func (s *relationSuite) TestListRelationsWithError(c *gc.C) { ctx := context.Background() group := &dbmodel.GroupEntry{Name: "group-1"} - err = s.JIMM.DB().GetGroup(ctx, group) + err = s.JIMM.Database.GetGroup(ctx, group) c.Assert(err, gc.IsNil) - err = s.JIMM.DB().RemoveGroup(ctx, group) + err = s.JIMM.Database.RemoveGroup(ctx, group) c.Assert(err, gc.IsNil) expectedData := apiparams.ListRelationshipTuplesResponse{ diff --git a/cmd/jimmsrv/main.go b/cmd/jimmsrv/main.go index 4f5698bd3..efbb80309 100644 --- a/cmd/jimmsrv/main.go +++ b/cmd/jimmsrv/main.go @@ -142,6 +142,8 @@ func start(ctx context.Context, s *service.Service) error { return errors.E("jimm session store secret must be at least 64 characters") } + corsAllowedOrigins := strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), " ") + jimmsvc, err := jimmsvc.NewService(ctx, jimmsvc.Params{ ControllerUUID: os.Getenv("JIMM_UUID"), DSN: os.Getenv("JIMM_DSN"), @@ -167,17 +169,18 @@ func start(ctx context.Context, s *service.Service) error { JWTExpiryDuration: jwtExpiryDuration, InsecureSecretStorage: insecureSecretStorage, OAuthAuthenticatorParams: jimmsvc.OAuthAuthenticatorParams{ - IssuerURL: issuerURL, - ClientID: clientID, - ClientSecret: clientSecret, - Scopes: scopesParsed, - SessionTokenExpiry: sessionTokenExpiryDuration, - SessionCookieMaxAge: sessionCookieMaxAgeInt, - JWTSessionKey: sessionSecretKey, + IssuerURL: issuerURL, + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopesParsed, + SessionTokenExpiry: sessionTokenExpiryDuration, + SessionCookieMaxAge: sessionCookieMaxAgeInt, + JWTSessionKey: sessionSecretKey, + SecureSessionCookies: secureSessionCookies, }, DashboardFinalRedirectURL: os.Getenv("JIMM_DASHBOARD_FINAL_REDIRECT_URL"), - SecureSessionCookies: secureSessionCookies, CookieSessionKey: []byte(sessionSecretKey), + CorsAllowedOrigins: corsAllowedOrigins, }) if err != nil { return err diff --git a/cmd/jimmsrv/service/service.go b/cmd/jimmsrv/service/service.go index 6ba1dffca..ecfadf702 100644 --- a/cmd/jimmsrv/service/service.go +++ b/cmd/jimmsrv/service/service.go @@ -20,6 +20,7 @@ import ( "github.com/juju/names/v5" "github.com/juju/zaputil/zapctx" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/rs/cors" "go.uber.org/zap" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -37,9 +38,11 @@ import ( "github.com/canonical/jimm/v3/internal/jujuapi" "github.com/canonical/jimm/v3/internal/jujuclient" "github.com/canonical/jimm/v3/internal/logger" + "github.com/canonical/jimm/v3/internal/middleware" "github.com/canonical/jimm/v3/internal/openfga" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/pubsub" + "github.com/canonical/jimm/v3/internal/rebac_admin" "github.com/canonical/jimm/v3/internal/vault" "github.com/canonical/jimm/v3/internal/wellknownapi" ) @@ -82,6 +85,10 @@ type OAuthAuthenticatorParams struct { // SessionCookieMaxAge holds the max age for session cookies in seconds. SessionCookieMaxAge int + // SecureSessionCookies determines if HTTPS must be enabled in order for JIMM + // to set cookies when creating browser based sessions. + SecureSessionCookies bool + // JWTSessionKey holds the secret key used for signing/verifying JWT tokens. // See internal/auth/oauth2.go AuthenticationService.SessionSecretkey for more details. JWTSessionKey string @@ -175,14 +182,14 @@ type Params struct { // the /callback in an authorisation code OAuth2.0 flow to finish the flow. DashboardFinalRedirectURL string - // SecureSessionCookies determines if HTTPS must be enabled in order for JIMM - // to set cookies when creating browser based sessions. - SecureSessionCookies bool - // CookieSessionKey is a randomly generated secret passed via config used for signing // cookie data. The recommended length is 32/64 characters from the Gorilla securecookie lib. // https://github.com/gorilla/securecookie/blob/main/securecookie.go#L124 CookieSessionKey []byte + + // CorsAllowedOrigins represents all addresses that are valid for cross-origin + // requests. A wildcard '*' is accepted to allow all cross-origin requests. + CorsAllowedOrigins []string } // A Service is the implementation of a JIMM server. @@ -347,6 +354,7 @@ func NewService(ctx context.Context, p Params) (*Service, error) { SessionTokenExpiry: p.OAuthAuthenticatorParams.SessionTokenExpiry, SessionCookieMaxAge: p.OAuthAuthenticatorParams.SessionCookieMaxAge, JWTSessionKey: p.OAuthAuthenticatorParams.JWTSessionKey, + SecureCookies: p.OAuthAuthenticatorParams.SecureSessionCookies, Store: &s.jimm.Database, SessionStore: sessionStore, RedirectURL: redirectUrl, @@ -380,6 +388,19 @@ func NewService(ctx context.Context, p Params) (*Service, error) { return nil, errors.E(op, err, "failed to parse final redirect url for the dashboard") } + rebacBackend, err := rebac_admin.SetupBackend(ctx, &s.jimm) + if err != nil { + return nil, errors.E(op, err) + } + + // Setup CORS middleware + corsOpts := cors.New(cors.Options{ + AllowedOrigins: p.CorsAllowedOrigins, + AllowedMethods: []string{"GET"}, + AllowCredentials: true, + }) + s.mux.Use(corsOpts.Handler) + // Setup all HTTP handlers. mountHandler := func(path string, h jimmhttp.JIMMHttpHandler) { s.mux.Mount(path, h.Routes()) @@ -387,6 +408,8 @@ func NewService(ctx context.Context, p Params) (*Service, error) { s.mux.Mount("/metrics", promhttp.Handler()) + s.mux.Mount("/rebac", middleware.AuthenticateRebac(rebacBackend.Handler(""), &s.jimm)) + mountHandler( "/debug", debugapi.NewDebugHandler( @@ -406,7 +429,6 @@ func NewService(ctx context.Context, p Params) (*Service, error) { oauthHandler, err := jimmhttp.NewOAuthHandler(jimmhttp.OAuthHandlerParams{ Authenticator: authSvc, DashboardFinalRedirectURL: p.DashboardFinalRedirectURL, - SecureCookies: p.SecureSessionCookies, }) if err != nil { zapctx.Error(ctx, "failed to setup authentication handler", zap.Error(err)) diff --git a/cmd/jimmsrv/service/service_test.go b/cmd/jimmsrv/service/service_test.go index 9bc815d69..33014065d 100644 --- a/cmd/jimmsrv/service/service_test.go +++ b/cmd/jimmsrv/service/service_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "testing" @@ -273,6 +274,29 @@ func TestPublicKey(t *testing.T) { c.Assert(string(data), qt.Equals, `{"PublicKey":"izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk="}`) } +func TestRebacAdminApi(t *testing.T) { + c := qt.New(t) + + _, _, cofgaParams, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + p := jimmtest.NewTestJimmParams(c) + p.InsecureSecretStorage = true + p.OpenFGAParams = cofgaParamsToJIMMOpenFGAParams(*cofgaParams) + + svc, err := jimmsvc.NewService(context.Background(), p) + c.Assert(err, qt.IsNil) + defer svc.Cleanup() + + srv := httptest.NewTLSServer(svc) + c.Cleanup(srv.Close) + + response, err := srv.Client().Get(srv.URL + "/rebac/v1/swagger.json") + c.Assert(err, qt.IsNil) + defer response.Body.Close() + c.Assert(response.StatusCode, qt.Equals, 401) +} + func TestThirdPartyCaveatDischarge(t *testing.T) { c := qt.New(t) @@ -483,3 +507,46 @@ func TestCleanupDoesNotPanic_SessionStoreRelatedCleanups(t *testing.T) { svc.Cleanup() } + +func TestCORS(t *testing.T) { + c := qt.New(t) + + _, _, cofgaParams, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + p := jimmtest.NewTestJimmParams(c) + p.OpenFGAParams = cofgaParamsToJIMMOpenFGAParams(*cofgaParams) + allowedOrigin := "http://my-referrer.com" + p.CorsAllowedOrigins = []string{allowedOrigin} + p.InsecureSecretStorage = true + + svc, err := jimmsvc.NewService(context.Background(), p) + c.Assert(err, qt.IsNil) + defer svc.Cleanup() + + srv := httptest.NewServer(svc) + c.Cleanup(srv.Close) + + url, err := url.Parse(srv.URL + "/debug/info") + c.Assert(err, qt.IsNil) + // Invalid origin won't receive CORS headers. + req := http.Request{ + Method: "GET", + URL: url, + Header: http.Header{"Origin": []string{"123"}}, + } + response, err := srv.Client().Do(&req) + c.Assert(err, qt.IsNil) + defer response.Body.Close() + c.Assert(response.StatusCode, qt.Equals, http.StatusOK) + c.Assert(response.Header.Get("Access-Control-Allow-Credentials"), qt.Equals, "") + c.Assert(response.Header.Get("Access-Control-Allow-Origin"), qt.Equals, "") + + // Valid origin should receive CORS headers. + req.Header = http.Header{"Origin": []string{allowedOrigin}} + response, err = srv.Client().Do(&req) + c.Assert(err, qt.IsNil) + defer response.Body.Close() + c.Assert(response.StatusCode, qt.Equals, http.StatusOK) + c.Assert(response.Header.Get("Access-Control-Allow-Credentials"), qt.Equals, "true") + c.Assert(response.Header.Get("Access-Control-Allow-Origin"), qt.Equals, allowedOrigin) +} diff --git a/doc/dev-deploy.md b/doc/dev-deploy.md deleted file mode 100644 index a69824dda..000000000 --- a/doc/dev-deploy.md +++ /dev/null @@ -1,276 +0,0 @@ -# Deploy JAAS - -By following this document you will be able to deploy a complete JAAS -system: Candid with one or more identity providers, JIMM and controllers -added to JIMM. - -## Prerequisites - -### Domain - -To deploy JAAS we need a domain as each service needs a separate DNS entry for each of the deployed service. This will enable us to use -certbot to obtain valid certificates from Let's Encrypt. - -We can use AWS Route 53 to host a subdomain as certbot has a -dns-route53 plugin. To achieve that we need to obtain an -**aws-secret-access-key** and **aws-access-key-id** from Route 53. -In my case i set up AWS Route 53 to manage the canonical.stimec.net -subdomain and i will use this as an example domain for the rest of this -document. - -### Deployment bundles - -Deployment bundles for JAAS can be found in - - git.launchpad.net/canonical-jaas - -You can check it out by running: - - git clone git+ssh://git.launchpad.net/canonical-jaas - -## OpenLDAP - -### Deploy - -We can either bootstrap a new controller or create a new model for the -LDAP deploy: - - juju bootstrap aws dev-ldap - -Then we deploy an ubuntu unit and certbot: - - juju deploy ubuntu ldap - juju deploy cs:~yellow/certbot - -### Install OpenLDAP - -SSH into the unit: - - juju ssh ldap/0 - -Install LDAP by running: - - sudo apt install slapd ldap-utils - -Having installed OpenLDAP run: - - sudo dpkg-reconfigure slapd - -Here i configured LDAP so that admin is: - - cn=admin,dc=ldap,dc=canonical,dc=stimec,dc=net - -And specified a password for the admin user. In the remainder of this -document you will see *dc=ldap, dc=canonical, dc=stimec, dc=net* in -command examples - make sure to replace this with the actual domain you -set up in ldap. - -### Configure TLS - -Then we can exit from the LDAP unit and configure certbot by specifying certificate, key and trust chain paths: - - juju config certbot chain-path=/etc/ldap/ldap-chain.pem - juju config certbot key-path=/etc/ldap/ldap-key.pem - juju config certbot cert-path=/etc/ldap/ldap-cert.pem - -And add a relation between ldap and certbot: - - juju add-relation certbot ldap - -Next we then need to create a DNS **A** record for the LDAP unit. Let's -say we create ldap.<**domain**> that will point to the IP of the -LDAP unit. - -Then, to obtain a certficate we run: - - juju run-action --wait certbot/0 get-certificate agree-tos=true aws-access-key-id=<**aws-secret-access-key-id**> aws-secret-access-key=<**aws-secret-access-key**> - domains=ldap.<**domain**> email=<**developer email**> plugin=dns-route53 - -This will result in creating of .pem files specified by the certbot config -above and we can proceede setting up out LDAP instance. - - juju ssh ldap/0 - - cd /etc/ldap - -Create file certinfo.ldif with the following content - - dn: cn=config - replace: olcTLSCACertificateFile - olcTLSCACertificateFile: /etc/ldap/ldap-chain.pem - - - replace: olcTLSCertificateFile - olcTLSCertificateFile: /etc/ldap/ldap-cert.pem - - - replace: olcTLSCertificateKeyFile - olcTLSCertificateKeyFile: /etc/ldap/ldap-key.pem - -And then change the ldap configuration - - sudo ldapmodify -Y EXTERNAL -H ldapi:/// -f certinfo.ldif - -This will make LDAP use certificates provided by the certbot. - -### LDAP Users - -Then to set up LDAP we create content.ldif with the following content: - - dn: ou=People,dc=ldap,dc=canonical,dc=stimec,dc=net - objectClass: organizationalUnit - ou: People - - dn: ou=Groups,dc=ldap,dc=canonical,dc=stimec,dc=net - objectClass: organizationalUnit - ou: Groups - - dn: cn=miners,ou=Groups,dc=ldap,dc=canonical,dc=stimec,dc=net - objectClass: posixGroup - cn: miners - gidNumber: 5000 - -To create an LDAP user we then create a file named <**username**>.ldif with -the following content: - - dn: uid=<**username**>,ou=People,dc=ldap,dc=canonical,dc=stimec,dc=net - objectClass: inetOrgPerson - objectClass: posixAccount - objectClass: shadowAccount - uid: <**username**> - sn: 2 - givenName: <**name**> - cn: <**username**> - displayName: <**display name**> - uidNumber: <**uuid number e.g. 10000**> - gidNumber: <**gid number e.g. 5000**> - userPassword: {CRYPT}x - gecos: <**display name**> - loginShell: /bin/bash - homeDirectory: /home/<**username**> - -And then run: - - ldapadd -x -D cn=admin,dc=ldap,dc=canonical,dc=stimec,dc=net -W -f <**username**>.ldif - -To set a password for the created user run: - - ldappasswd -x -D cn=admin,dc=ldap,dc=canonical,dc=stimec,dc=net -W -S uid=<**username**>,ou=People,dc=ldap,dc=canonical,dc=stimec,dc=net - -## Candid - -We can either bootstrap a new controller or create a new model for the -Candid deploy: - - juju bootstrap aws dev-candid - -Now go into the folder where you checked out canonical-jaas and into -the /bundles/candid folder. - -To deploy candid run (since we are using certbot to obtain certificates): - - juju deploy ./bundle.yaml --overlay ./overlay-certbot.yaml - -This will deploy 2 candid units, 1 postgresql unit and 2 haproxy units. Feel free to modify the bundle to reduce cpu, memory or root disk contraints. - -Once deployed, we need to create a DNS **A** record for the two haproxy -units - in my case candid.canonical.stimec.net pointed to the one haproxy -unit as haproxy seems to have a problem with peer-to-peer relations that -i couldn't be bothered to resolve. - -Now, to obtain a certificate for haproxy we run: - - - juju run-action --wait certbot/0 get-certificate agree-tos=true aws-access-key-id=<**aws-secret-access-key-id**> aws-secret-access-key=<**aws-secret-access-key**> - domains=candid.<**domain**> email=<**developer email**> plugin=dns-route53 - -Then we need to configure Candid. - -Set the location: - - juju config candid location=https://candid.canonical.stimec.net - -To set up identity providers follow instruction on setting up ldap or -azure then use: - - juju config candid identity-providers= `<**list of identity providers**>` - -### LDAP Identity Provider - -Then we need to set identity-providers configuration option to - - - type: ldap - name: TestLDAP - description: LDAPLogin - domain: ldap.canonical.stimec.net - url: ldap://ldap.canonical.stimec.net/dc=ldap,dc=canonical,dc=stimec,dc=net - dn: cn=admin,dc=ldap,dc=canonical,dc=stimec,dc=net - password: - user-query-filter: (objectClass=inetOrgPerson) - user-query-attrs: - id: uid - email: mail - display-name: displayName - group-query-filter: (&(objectClass=groupOfNames)(member={{.User}})) - hidden: false - ca-cert: | - <> - -### Azure Identity Provider - -If, for some reason you want to add Azure an identity provider go to the -[Azure portal](https://portal.azure.com/) and find **App Registration**. -Fill in the name (e.g. Development Candid). -For **Supported account types** select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)**. -For **Redirect URI** select **Web** and enter - - https://candid.azure.canonical.com/login/azure/callback - -as the redirect URI. - -Then go to the created **App Registration** and copy **Application (client) ID** (this is **client_id**). The go to **Certificates & secrets**. Create a **New client secret** and copy it's value (this is -**client_secret**). - -Now we need to make Candid aware of the new identity provider. To do that we need to add another item to the identity_providers configuration value. We need to add the following item: - - - type: azure - client-id: <**client_id**> - client-secret: <**client_secret**> - -## JIMM - -We can either bootstrap a new controller or create a new model for the -JIMM deploy: - - juju bootstrap aws dev-jimm - -Now go into the folder where you checked out canonical-jaas and into -the /bundles/jimm folder. - -To deploy jimm run (since we are using certbot to obtain certificates): - - juju deploy ./bundle.yaml --overlay ./overlay-certbot.yaml - -This will deploy 2 jimm units, 1 mongodb unit and 2 haproxy units. Feel free to modify the bundle to reduce cpu, memory or root disk contraints. - -Once deployed, we need to create a DNS **A** record for the two haproxy -units - in my case jimm.canonical.stimec.net pointed to the one haproxy -unit as haproxy seems to have a problem with peer-to-peer relations that -i couldn't be bothered to resolve. - -Now, to obtain a certificate for haproxy we run: - - - juju run-action --wait certbot/0 get-certificate agree-tos=true aws-access-key-id=<**aws-secret-access-key-id**> aws-secret-access-key=<**aws-secret-access-key**> - domains=jimm.<**domain**> email=<**developer email**> plugin=dns-route53 - -Then we need to configure JIMM to use the deployed Candid: - - juju config identity-location=https://candid.<**domain**> - -And add a controller admin, which should be one of the created LDAP users: - - juju config jimm controller-admins=<**username**>@ldap.<**domain**> - -## JIMM Controllers - -To bootstrap and add controllers to JIMM please follow the guide that can -be found [here](https://docs.google.com/document/d/1rtJne7CV6dRsKvCUE85BA5adPvEa5V-TczKSAMzxP9M/edit#heading=h.yij8xaij9lcy). diff --git a/doc/jimm-facade.md b/doc/jimm-facade.md index 96af1872f..c0ffcb15f 100644 --- a/doc/jimm-facade.md +++ b/doc/jimm-facade.md @@ -1,6 +1,9 @@ JIMM Facade =========== +>This document is out of date and requires rework to include details +>on new JIMM specific facades. + In addition to the facades required to emulate a juju controller, JIMM also advertises a JIMM facade with some additional features. diff --git a/go.mod b/go.mod index 30ba26e37..a8ffed17c 100644 --- a/go.mod +++ b/go.mod @@ -45,9 +45,10 @@ require ( require ( github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a github.com/canonical/ofga v0.10.0 + github.com/canonical/rebac-admin-ui-handlers v0.1.0 github.com/coreos/go-oidc/v3 v3.9.0 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 - github.com/go-chi/chi/v5 v5.0.8 + github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/render v1.0.2 github.com/gorilla/sessions v1.2.1 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -58,11 +59,13 @@ require ( github.com/lestrrat-go/iter v1.0.2 github.com/lestrrat-go/jwx/v2 v2.0.21 github.com/oklog/ulid/v2 v2.1.0 + github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.9.0 golang.org/x/oauth2 v0.16.0 gopkg.in/errgo.v1 v1.0.1 gopkg.in/httprequest.v1 v1.2.1 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -84,6 +87,7 @@ require ( github.com/Rican7/retry v0.3.1 // indirect github.com/adrg/xdg v0.3.3 // indirect github.com/ajg/form v1.5.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.24.0 // indirect github.com/aws/aws-sdk-go-v2/config v1.26.2 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.16.13 // indirect @@ -118,17 +122,22 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gdamore/encoding v1.0.0 // indirect github.com/gdamore/tcell/v2 v2.5.1 // indirect + github.com/getkin/kin-openapi v0.125.0 // indirect github.com/go-goose/goose/v5 v5.0.0-20230421180421-abaee9096e3a // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-jose/go-jose/v4 v4.0.1 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-macaroon-bakery/macaroonpb v1.0.0 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-openapi/swag v0.22.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/godbus/dbus/v5 v5.0.4 // indirect github.com/gofrs/flock v0.8.1 // indirect @@ -155,6 +164,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/im7mortal/kmutex v1.0.1 // indirect github.com/imdario/mergo v0.3.12 // indirect + github.com/invopop/yaml v0.2.0 // indirect github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgio v1.0.0 // indirect @@ -210,6 +220,7 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.5 // indirect @@ -250,10 +261,12 @@ require ( github.com/muhlemmer/gu v0.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/oapi-codegen/runtime v1.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/oracle/oci-go-sdk/v65 v65.55.0 // indirect github.com/packethost/packngo v0.28.1 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.13.6 // indirect @@ -313,7 +326,6 @@ require ( gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.29.0 // indirect k8s.io/apiextensions-apiserver v0.29.0 // indirect k8s.io/apimachinery v0.29.0 // indirect diff --git a/go.sum b/go.sum index e742a5d03..74face0b1 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,8 @@ github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e/go.mod h1:chxPXzS github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= -github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= @@ -87,6 +87,7 @@ github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VM github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/hcsshim v0.9.4 h1:mnUj0ivWy6UzbB1uLFqKR6F+ZyiDc7j4iGgHTpO+5+I= github.com/Microsoft/hcsshim v0.9.4/go.mod h1:7pLA8lDk46WKDWlVsENo92gC0XFa8rbKfyFRBqxEbCc= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/Rican7/retry v0.3.0/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= @@ -97,6 +98,8 @@ github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a h1:dIdcLbck6W67B5JFMewU5Dba1yKZA3MsT67i4No/zh0= github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a/go.mod h1:Sdr/tmSOLEnncCuXS5TwZRxuk7deH1WXVY8cve3eVBM= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -140,6 +143,7 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f h1:gOO/tNZMjjvTKZWpY7YnXC72ULNLErRtp94LountVE8= github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= github.com/canonical/go-dqlite v1.21.0 h1:4gLDdV2GF+vg0yv9Ff+mfZZNQ1JGhnQ3GnS2GeZPHfA= @@ -150,6 +154,8 @@ github.com/canonical/lxd v0.0.0-20231214113525-e676fc63c50a h1:Tfo/MzXK5GeG7gzSH github.com/canonical/lxd v0.0.0-20231214113525-e676fc63c50a/go.mod h1:UxfHGKFoRjgu1NUA9EFiR++dKvyAiT0h9HT0ffMlzjc= github.com/canonical/ofga v0.10.0 h1:DHXhG/DAXWWQT/I+2jzr4qm0uTIYrILmtMxd6ZqmEzE= github.com/canonical/ofga v0.10.0/go.mod h1:u4Ou8dbIhO7FmVlT7W3rX2roD9AOGz/CqmGh7AdF0Lo= +github.com/canonical/rebac-admin-ui-handlers v0.1.0 h1:Bef1N/RgQine8hHX4ZMksQz/1VKsy4DHK2XdhAzQsZs= +github.com/canonical/rebac-admin-ui-handlers v0.1.0/go.mod h1:EIdBoaTHWYPkzNeUeXUBueJkglN9nQz5HLIvaOT7o1k= github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= @@ -240,14 +246,18 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= github.com/gdamore/tcell/v2 v2.5.1 h1:zc3LPdpK184lBW7syF2a5C6MV827KmErk9jGVnmsl/I= github.com/gdamore/tcell/v2 v2.5.1/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= +github.com/getkin/kin-openapi v0.125.0 h1:jyQCyf2qXS1qvs2U00xQzkGCqYPhEhZDmSmVt65fXno= +github.com/getkin/kin-openapi v0.125.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= -github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -272,17 +282,28 @@ github.com/go-macaroon-bakery/macaroon-bakery/v3 v3.0.1 h1:uvQJoKTHrFFu8zxoaopNK github.com/go-macaroon-bakery/macaroon-bakery/v3 v3.0.1/go.mod h1:H59IYeChwvD1po3dhGUPvq5na+4NVD7SJlbhGKvslr0= github.com/go-macaroon-bakery/macaroonpb v1.0.0 h1:It9exBaRMZ9iix1iJ6gwzfwsDE6ExNuwtAJ9e09v6XE= github.com/go-macaroon-bakery/macaroonpb v1.0.0/go.mod h1:UzrGOcbiwTXISFP2XDLDPjfhMINZa+fX/7A2lMd31zc= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= +github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= +github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= -github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe h1:zn8tqiUbec4wR94o7Qj3LZCAT6uGobhEgnDRg6isG5U= github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= @@ -374,8 +395,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU= github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk= @@ -466,6 +487,8 @@ github.com/im7mortal/kmutex v1.0.1/go.mod h1:f71c/Ugk/+58OHRAgvgzPP3QEiWGUjK13fd github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/itchyny/gojq v0.12.12 h1:x+xGI9BXqKoJQZkr95ibpe3cdrTbY8D9lonrK433rcA= github.com/itchyny/gojq v0.12.12/go.mod h1:j+3sVkjxwd7A7Z5jrbKibgOLn0ZfLWkV+Awxr/pyzJE= github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= @@ -751,6 +774,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= @@ -892,6 +917,8 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= @@ -915,6 +942,8 @@ github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwp github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/peterh/liner v1.2.1/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= @@ -956,8 +985,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= -github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -1007,6 +1036,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/std-uritemplate/std-uritemplate/go v0.0.47 h1:erzz/DR4sOzWr0ca2MgSTkMckpLEsDySaTZwVFQq9zw= github.com/std-uritemplate/std-uritemplate/go v0.0.47/go.mod h1:Qov4Ay4U83j37XjgxMYevGJFLbnZ2o9cEOhGufBKgKY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -1033,6 +1063,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/testcontainers/testcontainers-go v0.15.0 h1:3Ex7PUGFv0b2bBsdOv6R42+SK2qoZnWBd21LvZYhUtQ= github.com/testcontainers/testcontainers-go v0.15.0/go.mod h1:PkohMRH2X8Hib0IWtifVexDfLPVT+tb5E9hsf7cW12w= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= @@ -1594,6 +1626,7 @@ gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw= diff --git a/internal/auth/oauth2.go b/internal/auth/oauth2.go index feff81490..e291e9478 100644 --- a/internal/auth/oauth2.go +++ b/internal/auth/oauth2.go @@ -48,7 +48,8 @@ const ( type sessionIdentityContextKey struct{} -func contextWithSessionIdentity(ctx context.Context, sessionIdentityId any) context.Context { +// ContextWithSessionIdentity adds the session identity id to the provided context. +func ContextWithSessionIdentity(ctx context.Context, sessionIdentityId any) context.Context { return context.WithValue(ctx, sessionIdentityContextKey{}, sessionIdentityId) } @@ -76,6 +77,8 @@ type AuthenticationService struct { sessionTokenExpiry time.Duration // sessionCookieMaxAge holds the max age for session cookies in seconds. sessionCookieMaxAge int + // secureCookies decides whether to set the secure flag on cookies. + secureCookies bool // jwtSessionKey holds the secret key used for signing/verifying JWT tokens. // According to https://datatracker.ietf.org/doc/html/rfc7518 minimum key lengths are // HSXXX e.g. HS256 - 256 bits, RSA - at least 2048 bits. @@ -119,6 +122,9 @@ type AuthenticationServiceParams struct { // SessionCookieMaxAge holds the max age for session cookies in seconds. SessionCookieMaxAge int + // SecureCookies decides whether to set the secure flag on cookies. + SecureCookies bool + // JWTSessionKey holds the secret key used for signing/verifying JWT tokens. // See AuthenticationService.JWTSessionKey for more details. JWTSessionKey string @@ -163,6 +169,7 @@ func NewAuthenticationService(ctx context.Context, params AuthenticationServiceP db: params.Store, sessionStore: params.SessionStore, sessionCookieMaxAge: params.SessionCookieMaxAge, + secureCookies: params.SecureCookies, }, nil } @@ -420,13 +427,23 @@ func (as *AuthenticationService) VerifyClientCredentials(ctx context.Context, cl return nil } +// sessionCrossOriginSafe sets parameters on the session that allow its use in cross-origin requests. +// Options are not saved to the database so this must be called whenever a session cookie will be returned to a client. +// +// Note browsers require cookies with the same-site policy as 'none' to additionally have the secure flag set. +func sessionCrossOriginSafe(session *sessions.Session, secure bool) *sessions.Session { + session.Options.Secure = secure // Ensures only sent with HTTPS + session.Options.HttpOnly = true // Don't allow Javascript to modify cookie + session.Options.SameSite = http.SameSiteNoneMode // Allow cross-origin requests via Javascript + return session +} + // CreateBrowserSession creates a session and updates the cookie for a browser // login callback. func (as *AuthenticationService) CreateBrowserSession( ctx context.Context, w http.ResponseWriter, r *http.Request, - secureCookies bool, email string, ) error { const op = errors.Op("auth.AuthenticationService.CreateBrowserSession") @@ -438,8 +455,7 @@ func (as *AuthenticationService) CreateBrowserSession( session.IsNew = true // Sets cookie to a fresh new cookie session.Options.MaxAge = as.sessionCookieMaxAge // Expiry in seconds - session.Options.Secure = secureCookies // Ensures only sent with HTTPS - session.Options.HttpOnly = false // Allow Javascript to read it + session = sessionCrossOriginSafe(session, as.secureCookies) session.Values[SessionIdentityKey] = email if err = session.Save(r, w); err != nil { @@ -465,6 +481,7 @@ func (as *AuthenticationService) AuthenticateBrowserSession(ctx context.Context, if err != nil { return ctx, errors.E(op, err, "failed to retrieve session") } + session = sessionCrossOriginSafe(session, as.secureCookies) identityId, ok := session.Values[SessionIdentityKey] if !ok { @@ -479,7 +496,7 @@ func (as *AuthenticationService) AuthenticateBrowserSession(ctx context.Context, return ctx, errors.E(op, err) } - ctx = contextWithSessionIdentity(ctx, identityId) + ctx = ContextWithSessionIdentity(ctx, identityId) if err := as.extendSession(session, w, req); err != nil { return ctx, errors.E(op, err) diff --git a/internal/auth/oauth2_test.go b/internal/auth/oauth2_test.go index fa6ac6999..9de52948f 100644 --- a/internal/auth/oauth2_test.go +++ b/internal/auth/oauth2_test.go @@ -50,6 +50,7 @@ func setupTestAuthSvc(ctx context.Context, c *qt.C, expiry time.Duration) (*auth SessionStore: sessionStore, SessionCookieMaxAge: 60, JWTSessionKey: "secret-key", + SecureCookies: false, }) c.Assert(err, qt.IsNil) cleanup := func() { @@ -295,7 +296,7 @@ func TestCreateBrowserSession(t *testing.T) { req, err := http.NewRequest("GET", "", nil) c.Assert(err, qt.IsNil) - err = authSvc.CreateBrowserSession(ctx, rec, req, false, "jimm-test@canonical.com") + err = authSvc.CreateBrowserSession(ctx, rec, req, "jimm-test@canonical.com") c.Assert(err, qt.IsNil) cookies := rec.Header().Get("Set-Cookie") @@ -508,6 +509,6 @@ func TestAuthenticateBrowserSessionHandlesMissingOrExpiredRefreshTokens(t *testi c.Assert( setCookieCookies, qt.Equals, - "jimm-browser-session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0", + "jimm-browser-session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; HttpOnly; SameSite=None", ) } diff --git a/internal/cmdtest/jimmsuite.go b/internal/cmdtest/jimmsuite.go index 295125189..bb67fbe08 100644 --- a/internal/cmdtest/jimmsuite.go +++ b/internal/cmdtest/jimmsuite.go @@ -109,7 +109,7 @@ func (s *JimmCmdSuite) SetUpTest(c *gc.C) { store := jjclient.NewMemStore() store.CurrentControllerName = "JIMM" store.Controllers["JIMM"] = jjclient.ControllerDetails{ - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, APIEndpoints: []string{u.Host}, PublicDNSName: s.HTTP.URL, } diff --git a/internal/common/pagination/entitlement.go b/internal/common/pagination/entitlement.go new file mode 100644 index 000000000..276a07e38 --- /dev/null +++ b/internal/common/pagination/entitlement.go @@ -0,0 +1,126 @@ +// Copyright 2024 Canonical. + +package pagination + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/canonical/jimm/v3/internal/openfga" +) + +// Entitlement pagination is a method pagination used with OpenFGA's pagination +// that allows a client to retrieve all the direct relations a user/group has. + +// entitlementResources is used by `CreateEntitlementPaginationFilter` and `NextEntitlementToken` to +// define which resources to expose and the order in which entitlements are returned to clients. +var entitlementResources = []openfga.Kind{ + openfga.ControllerType, + openfga.CloudType, + openfga.ModelType, + openfga.ApplicationOfferType, + openfga.GroupType, + openfga.ServiceAccountType, +} + +// EntitlementToken represents a wrapped OpenFGA token that contains +// extra information used for pagination over resource entitlements. +type EntitlementToken struct { + token string +} + +// String returns the contents of the entitlement token as a string. +func (e EntitlementToken) String() string { + return e.token +} + +// NewEntitlementToken returns a new entitlement token based on the provided string. +func NewEntitlementToken(token string) EntitlementToken { + return EntitlementToken{token: token} +} + +// TODO(Kian): Move the code below into the application layer (i.e. jimm package) +// specifically into the access related service (when one exists). +// The details on encoding/decoding EntitlementTokens is only used by `internal/jimm`. + +// DecodeEntitlementToken accepts an entitlement token and decodes it to return +// the original OpenFGA page token as well as the object Kind that was encoded in the wrapped token. +// +// An error is returned if the provided token could not be decoded. +// Use this function alongside `NextEntitlementToken` to page over entitlements. +func DecodeEntitlementToken(token EntitlementToken) (string, openfga.Kind, error) { + // If the client sends us an empty token, they are making their first request. + // Return a filter with an empty OpenFGA token and the first Kind. + if token.String() == "" { + return "", entitlementResources[0], nil + } + var ct comboToken + err := ct.UnmarshalToken(token.String()) + if err != nil { + return "", "", fmt.Errorf("failed to decode pagination token: %w", err) + } + return ct.OpenFGAToken, ct.Kind, nil +} + +// NextEntitlementToken accepts an OpenFGA token and Kind and returns a wrapped OpenFGA page token in the form +// of an Entitlement token that encodes the provided information together. +// +// Use this function alongside `DecodeEntitlementToken` to page over all entitlements. +func NextEntitlementToken(kind openfga.Kind, openFGAToken string) (EntitlementToken, error) { + var ct comboToken + ct.OpenFGAToken = openFGAToken + ct.Kind = kind + // If the OpenFGA token is empty, we are at the end of the result set for that resource. + if openFGAToken == "" { + resourceIndex := slices.Index(entitlementResources, ct.Kind) + if resourceIndex == -1 { + return EntitlementToken{}, errors.New("failed to generate next entitlement token: unable to determine next resource") + } + // Once we've reached the end of all the resources, return an empty token to indicate no more results are left. + if resourceIndex == len(entitlementResources)-1 { + return EntitlementToken{}, nil + } + ct.Kind = entitlementResources[resourceIndex+1] + } + res, err := ct.MarshalToken() + if err != nil { + return EntitlementToken{}, fmt.Errorf("failed to generate next entitlement token: %w", err) + } + return EntitlementToken{token: res}, nil +} + +// comboToken contains information on the current resource +// and OpenFGA page token used when paginating over entitlements. +type comboToken struct { + Kind openfga.Kind `json:"kind"` + OpenFGAToken string `json:"token"` +} + +// MarshalToken marshals the entitlement pagination token into a base64 encoded token. +func (c *comboToken) MarshalToken() (string, error) { + if c.Kind == openfga.Kind("") { + return "", errors.New("marshal entitlement token: kind not specified") + } + + b, err := json.Marshal(c) + if err != nil { + return "", fmt.Errorf("marshal entitlement token: %w", err) + } + + return base64.StdEncoding.EncodeToString(b), nil +} + +// UnmarshalToken unmarshals a base64 encoded entitlement pagination token. +func (c *comboToken) UnmarshalToken(token string) error { + out, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return fmt.Errorf("failed to decode token: %w", err) + } + if err := json.Unmarshal(out, c); err != nil { + return fmt.Errorf("failed to unmarshal token: %w", err) + } + return nil +} diff --git a/internal/common/pagination/entitlement_test.go b/internal/common/pagination/entitlement_test.go new file mode 100644 index 000000000..592f59ba8 --- /dev/null +++ b/internal/common/pagination/entitlement_test.go @@ -0,0 +1,192 @@ +// Copyright 2024 Canonical. + +package pagination_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/openfga" +) + +func TestMarshalEntitlementToken(t *testing.T) { + c := qt.New(t) + + tests := []struct { + desc string + token pagination.ComboToken + expectedError string + expectedToken string + }{{ + desc: "Valid marshal token", + token: pagination.ComboToken{ + Kind: openfga.ModelType, + OpenFGAToken: "continuation-token", + }, + expectedToken: "eyJraW5kIjoibW9kZWwiLCJ0b2tlbiI6ImNvbnRpbnVhdGlvbi10b2tlbiJ9", + }, { + desc: "invalid - missing kind", + token: pagination.ComboToken{ + Kind: "", + OpenFGAToken: "continuation-token", + }, + expectedError: "marshal entitlement token: kind not specified", + }} + + for _, tC := range tests { + c.Run(tC.desc, func(c *qt.C) { + data, err := tC.token.MarshalToken() + if tC.expectedError != "" { + c.Assert(err, qt.ErrorMatches, tC.expectedError) + } else { + c.Assert(data, qt.Equals, tC.expectedToken) + } + }) + } +} + +func TestUnmarshalEntitlementToken(t *testing.T) { + c := qt.New(t) + + tests := []struct { + desc string + in string + expectedToken pagination.ComboToken + expectedError string + }{ + { + desc: "Valid token", + in: "eyJraW5kIjoibW9kZWwiLCJ0b2tlbiI6ImNvbnRpbnVhdGlvbi10b2tlbiJ9", + expectedToken: pagination.ComboToken{ + Kind: openfga.ModelType, + OpenFGAToken: "continuation-token", + }, + }, + { + desc: "Invalid token", + in: "abc", + expectedError: "failed to decode token: illegal base64 data at input byte 0", + }, + { + desc: "Invalid JSON in valid Base64 string", + in: "c29tZSBpbnZhbGlkIHRva2VuCg==", + expectedError: "failed to unmarshal token: invalid character 's' looking for beginning of value", + }, + } + + for _, tC := range tests { + c.Run(tC.desc, func(c *qt.C) { + var token pagination.ComboToken + err := token.UnmarshalToken(tC.in) + if tC.expectedError != "" { + c.Assert(err, qt.ErrorMatches, tC.expectedError) + } else { + c.Assert(token, qt.DeepEquals, tC.expectedToken) + } + }) + } +} + +func TestDecodeEntitlementFilter(t *testing.T) { + c := qt.New(t) + testCases := []struct { + desc string + nextPageToken func() pagination.EntitlementToken + expectedToken string + expectedKind openfga.Kind + expectedErr string + }{ + { + desc: "empty next page token", + nextPageToken: func() pagination.EntitlementToken { return pagination.NewEntitlementToken("") }, + expectedToken: "", + expectedKind: pagination.EntitlementResources[0], + }, + { + desc: "model resource page token", + nextPageToken: func() pagination.EntitlementToken { + t := pagination.ComboToken{Kind: openfga.ModelType, OpenFGAToken: "123"} + res, err := t.MarshalToken() + c.Assert(err, qt.IsNil) + return pagination.NewEntitlementToken(res) + }, + expectedToken: "123", + expectedKind: openfga.ModelType, + }, + { + desc: "invalid token", + nextPageToken: func() pagination.EntitlementToken { + return pagination.NewEntitlementToken("123") + }, + expectedErr: "failed to decode pagination token.*", + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + input := tC.nextPageToken() + openFGAToken, kind, err := pagination.DecodeEntitlementToken(input) + if tC.expectedErr == "" { + c.Assert(err, qt.IsNil) + c.Assert(kind, qt.Equals, tC.expectedKind) + c.Assert(openFGAToken, qt.Equals, tC.expectedToken) + } else { + c.Assert(err, qt.ErrorMatches, tC.expectedErr) + } + }) + } +} + +func TestNextEntitlementToken(t *testing.T) { + c := qt.New(t) + testCases := []struct { + desc string + openFGAToken string + kind openfga.Kind + expectedToken string + expectedErr string + }{ + { + desc: "empty OpenFGA token - expect next resource type", + openFGAToken: "", + kind: pagination.EntitlementResources[0], + expectedToken: "eyJraW5kIjoiY2xvdWQiLCJ0b2tlbiI6IiJ9", + }, + { + desc: "non-empty OpenFGA token - expect same kind and token", + openFGAToken: "123", + kind: openfga.ModelType, + expectedToken: "eyJraW5kIjoibW9kZWwiLCJ0b2tlbiI6IjEyMyJ9", + }, + { + desc: "empty kind - expect error", + openFGAToken: "123", + kind: "", + expectedErr: ".*kind not specified", + }, + { + desc: "last resource type but not last page - expect same kind and token", + openFGAToken: "123", + kind: pagination.EntitlementResources[len(pagination.EntitlementResources)-1], + expectedToken: "eyJraW5kIjoic2VydmljZWFjY291bnQiLCJ0b2tlbiI6IjEyMyJ9", + }, + { + desc: "last resource type with no more data - expect empty token", + openFGAToken: "", + kind: pagination.EntitlementResources[len(pagination.EntitlementResources)-1], + expectedToken: "", + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + token, err := pagination.NextEntitlementToken(tC.kind, tC.openFGAToken) + if tC.expectedErr == "" { + c.Assert(err, qt.IsNil) + c.Assert(token.String(), qt.Equals, tC.expectedToken) + } else { + c.Assert(err, qt.ErrorMatches, tC.expectedErr) + } + }) + } +} diff --git a/internal/common/pagination/export_test.go b/internal/common/pagination/export_test.go new file mode 100644 index 000000000..db80c2be3 --- /dev/null +++ b/internal/common/pagination/export_test.go @@ -0,0 +1,13 @@ +// Copyright 2024 Canonical. + +package pagination + +var ( + DefaultPageSize = defaultOffsetFilterPageSize + MaxPageSize = maxOffsetFilterPageSize + DefaultOpenFGAPageSize = defaultOpenFGAPageSize + MaxOpenFGAPageSize = maxOpenFGAPageSize + EntitlementResources = entitlementResources +) + +type ComboToken = comboToken diff --git a/internal/common/pagination/pagination.go b/internal/common/pagination/pagination.go new file mode 100644 index 000000000..90d8e9eab --- /dev/null +++ b/internal/common/pagination/pagination.go @@ -0,0 +1,105 @@ +// Copyright 2024 Canonical. + +// pagination holds common pagination patterns. +package pagination + +const ( + defaultOffsetFilterPageSize = 50 + maxOffsetFilterPageSize = 200 + // OpenFGA has internal limits on its page size + // See https://openfga.dev/docs/interacting/read-tuple-changes + defaultOpenFGAPageSize = 50 + maxOpenFGAPageSize = 100 +) + +type LimitOffsetPagination struct { + limit int + offset int +} + +// NewOffsetFilter creates a filter for limit/offset pagination. +// If limit or offset are out of bounds, defaults will be used instead. +func NewOffsetFilter(limit int, offset int) LimitOffsetPagination { + if limit < 0 { + limit = defaultOffsetFilterPageSize + } + if limit > maxOffsetFilterPageSize { + limit = maxOffsetFilterPageSize + } + if offset < 0 { + offset = 0 + } + return LimitOffsetPagination{ + limit: limit, + offset: offset, + } +} + +func (l LimitOffsetPagination) Limit() int { + return l.limit +} + +func (l LimitOffsetPagination) Offset() int { + return l.offset +} + +// CreatePagination returns the current page, the next page if exists, and the pagination.LimitOffsetPagination. +func CreatePagination(sizeP, pageP *int, total int) (currentPage int, nextPage *int, _ LimitOffsetPagination) { + pageSize := -1 + offset := 0 + + if sizeP != nil && pageP != nil { + pageSize = *sizeP + currentPage = *pageP + offset = pageSize * currentPage + } + if (currentPage+1)*pageSize < total { + nPage := currentPage + 1 + nextPage = &nPage + } + return currentPage, nextPage, NewOffsetFilter(pageSize, offset) +} + +// CreatePagination returns the current page, the expected page size, and the pagination.LimitOffsetPagination. +// This method is different approach to the method `CreatePagination` when we don't have the total number of records. +// We return the expectedPageSize, which is pageSize +1, so we fetch one record more from the db. +// We then check the resulting records are enough to advice the consumers to ask for one more page or not. +func CreatePaginationWithoutTotal(sizeP, pageP *int) (currentPage int, expectedPageSize int, _ LimitOffsetPagination) { + pageSize := -1 + offset := 0 + + if sizeP != nil && pageP != nil { + pageSize = *sizeP + currentPage = *pageP + offset = pageSize * currentPage + } + expectedPageSize = pageSize + 1 + return currentPage, expectedPageSize, NewOffsetFilter(pageSize+1, offset) +} + +type OpenFGAPagination struct { + limit int + token string +} + +// NewOpenFGAFilter creates a filter for token pagination. +func NewOpenFGAFilter(limit int, token string) OpenFGAPagination { + if limit < 0 { + limit = defaultOpenFGAPageSize + } + if limit > maxOpenFGAPageSize { + limit = maxOpenFGAPageSize + } + return OpenFGAPagination{ + limit: limit, + token: token, + } +} + +func (l OpenFGAPagination) Limit() int { + return l.limit +} + +func (l OpenFGAPagination) Token() string { + return l.token +} diff --git a/internal/common/pagination/pagination_test.go b/internal/common/pagination/pagination_test.go new file mode 100644 index 000000000..518605215 --- /dev/null +++ b/internal/common/pagination/pagination_test.go @@ -0,0 +1,180 @@ +// Copyright 2024 Canonical. + +package pagination_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/common/utils" +) + +func TestOffsetFilter(t *testing.T) { + testCases := []struct { + desc string + limit int + offset int + wantLimit int + wantOffset int + }{ + { + desc: "Valid value are not changed", + limit: 10, + offset: 5, + wantLimit: 10, + wantOffset: 5, + }, + { + desc: "Negative values are corrected", + limit: -1, + offset: -1, + wantLimit: pagination.DefaultPageSize, + wantOffset: 0, + }, + { + desc: "Very large limit is reduced", + limit: 2000, + offset: 5, + wantLimit: pagination.MaxPageSize, + wantOffset: 5, + }, + } + c := qt.New(t) + for _, tC := range testCases { + c.Run(tC.desc, func(c *qt.C) { + filter := pagination.NewOffsetFilter(tC.limit, tC.offset) + c.Assert(filter.Limit(), qt.Equals, tC.wantLimit) + c.Assert(filter.Offset(), qt.Equals, tC.wantOffset) + }) + } +} + +func TestCreatePagination(t *testing.T) { + c := qt.New(t) + + testCases := []struct { + desc string + size *int + page *int + total int + wantPage int + wantNextPage *int + wantOffset int + wantLimit int + }{ + { + desc: "test with default values", + size: nil, + page: nil, + wantPage: 0, + wantNextPage: utils.IntToPointer(1), + wantOffset: 0, + wantLimit: pagination.DefaultPageSize, + }, + { + desc: "test with set page size", + size: utils.IntToPointer(100), + page: utils.IntToPointer(1), + total: 1000, + wantPage: 1, + wantNextPage: utils.IntToPointer(2), + wantOffset: 100, + wantLimit: 100, + }, + { + desc: "test with set page size number 2", + size: utils.IntToPointer(100), + page: utils.IntToPointer(5), + total: 1000, + wantPage: 5, + wantNextPage: utils.IntToPointer(6), + wantOffset: 500, + wantLimit: 100, + }, + { + desc: "test with last current page and nextPage not present", + size: utils.IntToPointer(10), + page: utils.IntToPointer(0), + total: 10, + wantPage: 0, + wantNextPage: nil, + wantOffset: 0, + wantLimit: 10, + }, + { + desc: "test with current page over the total", + size: utils.IntToPointer(10), + page: utils.IntToPointer(2), + total: 10, + wantPage: 2, + wantNextPage: nil, + wantOffset: 20, + wantLimit: 10, + }, + } + + for _, tC := range testCases { + c.Run(tC.desc, func(c *qt.C) { + page, nextPage, pag := pagination.CreatePagination(tC.size, tC.page, tC.total) + c.Assert(page, qt.Equals, tC.wantPage) + if tC.wantNextPage == nil { + c.Assert(nextPage, qt.IsNil) + } else { + c.Assert(*nextPage, qt.Equals, *tC.wantNextPage) + } + c.Assert(pag.Limit(), qt.Equals, tC.wantLimit) + c.Assert(pag.Offset(), qt.Equals, tC.wantOffset) + }) + } +} + +// test the requested size is 1 more than then page size. +func TestCreatePaginationWithoutTotal(t *testing.T) { + c := qt.New(t) + pPage := utils.IntToPointer(0) + pSize := utils.IntToPointer(10) + page, size, pag := pagination.CreatePaginationWithoutTotal(pSize, pPage) + c.Assert(page, qt.Equals, 0) + c.Assert(pag.Limit(), qt.Equals, 11) + c.Assert(pag.Offset(), qt.Equals, 0) + c.Assert(size, qt.Equals, 11) +} + +func TestTokenFilter(t *testing.T) { + testToken := "test-token" + testCases := []struct { + desc string + limit int + token string + wantLimit int + }{ + { + desc: "Valid value are not changed", + limit: 10, + token: testToken, + wantLimit: 10, + }, + { + desc: "Negative values are corrected", + limit: -1, + token: testToken, + wantLimit: pagination.DefaultOpenFGAPageSize, + }, + { + desc: "Very large limit is reduced", + limit: 2000, + token: testToken, + wantLimit: pagination.MaxOpenFGAPageSize, + }, + } + c := qt.New(t) + for _, tC := range testCases { + c.Run(tC.desc, func(c *qt.C) { + filter := pagination.NewOpenFGAFilter(tC.limit, tC.token) + c.Assert(filter.Limit(), qt.Equals, tC.wantLimit) + c.Assert(filter.Token(), qt.Equals, testToken) + }) + } +} diff --git a/internal/common/utils/test_utils.go b/internal/common/utils/test_utils.go new file mode 100644 index 000000000..4e0b7fdcc --- /dev/null +++ b/internal/common/utils/test_utils.go @@ -0,0 +1,6 @@ +// Copyright 2024 Canonical. +package utils + +func IntToPointer(i int) *int { + return &i +} diff --git a/internal/db/group.go b/internal/db/group.go index d670a1a1f..d9526f865 100644 --- a/internal/db/group.go +++ b/internal/db/group.go @@ -36,6 +36,25 @@ func (d *Database) AddGroup(ctx context.Context, name string) (ge *dbmodel.Group return ge, nil } +// CountGroups returns a count of the number of groups that exist. +func (d *Database) CountGroups(ctx context.Context) (count int, err error) { + const op = errors.Op("db.CountGroups") + if err := d.ready(); err != nil { + return 0, errors.E(op, err) + } + durationObserver := servermon.DurationObserver(servermon.DBQueryDurationHistogram, string(op)) + defer durationObserver() + defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) + + var c int64 + var g dbmodel.GroupEntry + if err := d.DB.WithContext(ctx).Model(g).Count(&c).Error; err != nil { + return 0, errors.E(op, dbError(err)) + } + count = int(c) + return count, nil +} + // GetGroup returns a GroupEntry with the specified name. func (d *Database) GetGroup(ctx context.Context, group *dbmodel.GroupEntry) (err error) { const op = errors.Op("db.GetGroup") @@ -66,7 +85,7 @@ func (d *Database) GetGroup(ctx context.Context, group *dbmodel.GroupEntry) (err // ForEachGroup iterates through every group calling the given function // for each one. If the given function returns an error the iteration // will stop immediately and the error will be returned unmodified. -func (d *Database) ForEachGroup(ctx context.Context, f func(*dbmodel.GroupEntry) error) (err error) { +func (d *Database) ForEachGroup(ctx context.Context, limit, offset int, f func(*dbmodel.GroupEntry) error) (err error) { const op = errors.Op("db.ForEachGroup") if err := d.ready(); err != nil { return errors.E(op, err) @@ -77,7 +96,10 @@ func (d *Database) ForEachGroup(ctx context.Context, f func(*dbmodel.GroupEntry) defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) db := d.DB.WithContext(ctx) - rows, err := db.Model(&dbmodel.GroupEntry{}).Order("name asc").Rows() + db = db.Order("name asc") + db = db.Limit(limit) + db = db.Offset(offset) + rows, err := db.Model(&dbmodel.GroupEntry{}).Rows() if err != nil { return errors.E(op, err) } diff --git a/internal/db/group_test.go b/internal/db/group_test.go index f3b448193..275aa57ae 100644 --- a/internal/db/group_test.go +++ b/internal/db/group_test.go @@ -4,6 +4,7 @@ package db_test import ( "context" + "fmt" "testing" qt "github.com/frankban/quicktest" @@ -54,6 +55,20 @@ func (s *dbSuite) TestAddGroup(c *qt.C) { c.Assert(ge.UUID, qt.Equals, uuid) } +func (s *dbSuite) TestCountGroups(c *qt.C) { + err := s.Database.Migrate(context.Background(), false) + c.Assert(err, qt.IsNil) + + addNGroups := 10 + for i := range addNGroups { + _, err := s.Database.AddGroup(context.Background(), fmt.Sprintf("test-group-%d", i)) + c.Assert(err, qt.IsNil) + } + count, err := s.Database.CountGroups(context.Background()) + c.Assert(err, qt.IsNil) + c.Assert(count, qt.Equals, addNGroups) +} + func (s *dbSuite) TestGetGroup(c *qt.C) { uuid1 := uuid.NewString() c.Patch(db.NewUUID, func() string { @@ -168,3 +183,33 @@ func (s *dbSuite) TestRemoveGroup(c *qt.C) { err = s.Database.GetGroup(context.Background(), ge1) c.Check(errors.ErrorCode(err), qt.Equals, errors.CodeNotFound) } + +func (s *dbSuite) TestForEachGroup(c *qt.C) { + err := s.Database.Migrate(context.Background(), false) + c.Assert(err, qt.IsNil) + + addNGroups := 10 + for i := range addNGroups { + _, err := s.Database.AddGroup(context.Background(), fmt.Sprintf("test-group-%d", i)) + c.Assert(err, qt.IsNil) + } + firstGroups := []*dbmodel.GroupEntry{} + ctx := context.Background() + err = s.Database.ForEachGroup(ctx, 5, 0, func(ge *dbmodel.GroupEntry) error { + firstGroups = append(firstGroups, ge) + return nil + }) + c.Assert(err, qt.IsNil) + for i := 0; i < 5; i++ { + c.Assert(firstGroups[i].Name, qt.Equals, fmt.Sprintf("test-group-%d", i)) + } + secondGroups := []*dbmodel.GroupEntry{} + err = s.Database.ForEachGroup(ctx, 5, 5, func(ge *dbmodel.GroupEntry) error { + secondGroups = append(secondGroups, ge) + return nil + }) + c.Assert(err, qt.IsNil) + for i := 0; i < 5; i++ { + c.Assert(secondGroups[i].Name, qt.Equals, fmt.Sprintf("test-group-%d", i+5)) + } +} diff --git a/internal/db/identity.go b/internal/db/identity.go index cbf674cee..a1f68f6dc 100644 --- a/internal/db/identity.go +++ b/internal/db/identity.go @@ -91,7 +91,7 @@ func (d *Database) UpdateIdentity(ctx context.Context, u *dbmodel.Identity) (err db := d.DB.WithContext(ctx) db = db.Omit("ApplicationOffers").Omit("Clouds").Omit("CloudCredentials").Omit("Models") if err := db.Save(u).Error; err != nil { - return errors.E(op) + return errors.E(op, err) } return nil } @@ -119,3 +119,62 @@ func (d *Database) GetIdentityCloudCredentials(ctx context.Context, u *dbmodel.I } return credentials, nil } + +// ForEachIdentity iterates through every identity calling the given function +// for each one. If the given function returns an error the iteration +// will stop immediately and the error will be returned unmodified. +func (d *Database) ForEachIdentity(ctx context.Context, limit, offset int, f func(*dbmodel.Identity) error) (err error) { + const op = errors.Op("db.ForEachUSer") + if err := d.ready(); err != nil { + return errors.E(op, err) + } + + durationObserver := servermon.DurationObserver(servermon.DBQueryDurationHistogram, string(op)) + defer durationObserver() + defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) + + db := d.DB.WithContext(ctx) + rows, err := db. + Model(&dbmodel.Identity{}). + Order("name asc"). + Limit(limit). + Offset(offset). + Rows() + if err != nil { + return errors.E(op, err) + } + defer rows.Close() + for rows.Next() { + var identity dbmodel.Identity + if err := db.ScanRows(rows, &identity); err != nil { + return errors.E(op, err) + } + if err := f(&identity); err != nil { + return err + } + } + if err := rows.Err(); err != nil { + return errors.E(op, dbError(err)) + } + return nil +} + +// CountIdentities counts the number of identities. +func (d *Database) CountIdentities(ctx context.Context) (_ int, err error) { + const op = errors.Op("db.CountIdentities") + + if err := d.ready(); err != nil { + return 0, errors.E(op, err) + } + + durationObserver := servermon.DurationObserver(servermon.DBQueryDurationHistogram, string(op)) + defer durationObserver() + defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) + + db := d.DB.WithContext(ctx) + var count int64 + if err := db.Model(&dbmodel.Identity{}).Count(&count).Error; err != nil { + return 0, errors.E(op, err) + } + return int(count), nil +} diff --git a/internal/db/identity_test.go b/internal/db/identity_test.go index 92191af6e..7ced4221f 100644 --- a/internal/db/identity_test.go +++ b/internal/db/identity_test.go @@ -4,6 +4,7 @@ package db_test import ( "context" + "fmt" "testing" qt "github.com/frankban/quicktest" @@ -181,3 +182,51 @@ func (s *dbSuite) TestGetIdentityCloudCredentials(c *qt.C) { c.Check(err, qt.IsNil) c.Assert(credentials, qt.DeepEquals, []dbmodel.CloudCredential{cred1, cred2}) } + +func (s *dbSuite) TestForEachIdentity(c *qt.C) { + err := s.Database.Migrate(context.Background(), false) + c.Assert(err, qt.IsNil) + + for i := range 10 { + id, _ := dbmodel.NewIdentity(fmt.Sprintf("bob%d@canonical.com", i)) + err = s.Database.GetIdentity(context.Background(), id) + c.Assert(err, qt.IsNil) + } + firstIdentities := []*dbmodel.Identity{} + ctx := context.Background() + err = s.Database.ForEachIdentity(ctx, 5, 0, func(ge *dbmodel.Identity) error { + firstIdentities = append(firstIdentities, ge) + return nil + }) + c.Assert(err, qt.IsNil) + for i := 0; i < 5; i++ { + c.Assert(firstIdentities[i].Name, qt.Equals, fmt.Sprintf("bob%d@canonical.com", i)) + } + secondIdentities := []*dbmodel.Identity{} + err = s.Database.ForEachIdentity(ctx, 5, 5, func(ge *dbmodel.Identity) error { + secondIdentities = append(secondIdentities, ge) + return nil + }) + c.Assert(err, qt.IsNil) + for i := 0; i < 5; i++ { + c.Assert(secondIdentities[i].Name, qt.Equals, fmt.Sprintf("bob%d@canonical.com", i+5)) + } +} + +func (s *dbSuite) TestForEachIdentityError(c *qt.C) { + err := s.Database.Migrate(context.Background(), false) + c.Assert(err, qt.IsNil) + ctx := context.Background() + // add one identity + id, _ := dbmodel.NewIdentity("bob@canonical.com") + err = s.Database.GetIdentity(context.Background(), id) + c.Assert(err, qt.IsNil) + + // test error is returned + errTest := errors.E("test-error") + err = s.Database.ForEachIdentity(ctx, 5, 0, func(ge *dbmodel.Identity) error { + return errTest + }) + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Equals, errTest.Error()) +} diff --git a/internal/db/resource.go b/internal/db/resource.go new file mode 100644 index 000000000..f5dd58e10 --- /dev/null +++ b/internal/db/resource.go @@ -0,0 +1,108 @@ +// Copyright 2024 Canonical. +package db + +import ( + "context" + "database/sql" + + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/servermon" +) + +// RESOURCES_RAW_SQL contains the raw query fetching entities from multiple tables, with their respective entity parents. +const RESOURCES_RAW_SQL = ` +( + SELECT 'application_offer' AS type, + application_offers.uuid AS id, + application_offers.name AS name, + models.uuid AS parent_id, + models.name AS parent_name, + 'model' AS parent_type + FROM application_offers + JOIN models ON application_offers.model_id = models.id +) +UNION +( + SELECT 'cloud' AS type, + clouds.name AS id, + clouds.name AS name, + '' AS parent_id, + '' AS parent_name, + '' AS parent_type + FROM clouds +) +UNION +( + SELECT 'controller' AS type, + controllers.uuid AS id, + controllers.name AS name, + '' AS parent_id, + '' AS parent_name, + '' AS parent_type + FROM controllers +) +UNION +( + SELECT 'model' AS type, + models.uuid AS id, + models.name AS name, + controllers.uuid AS parent_id, + controllers.name AS parent_name, + 'controller' AS parent_type + FROM models + JOIN controllers ON models.controller_id = controllers.id +) +UNION +( + SELECT 'service_account' AS type, + identities.name AS id, + identities.name AS name, + '' AS parent_id, + '' AS parent_name, + '' AS parent_type + FROM identities + WHERE name LIKE '%@serviceaccount' +) +ORDER BY type, id +OFFSET ? +LIMIT ?; +` + +type Resource struct { + Type string + ID sql.NullString + Name string + ParentId sql.NullString + ParentName string + ParentType string +} + +// ListResources returns a list of models, clouds, controllers, service accounts, and application offers, with its respective parents. +// It has been implemented with a raw query because this is a specific implementation for the ReBAC Admin UI. +func (d *Database) ListResources(ctx context.Context, limit, offset int) (_ []Resource, err error) { + const op = errors.Op("db.ListResources") + if err := d.ready(); err != nil { + return nil, errors.E(op, err) + } + + durationObserver := servermon.DurationObserver(servermon.DBQueryDurationHistogram, string(op)) + defer durationObserver() + defer servermon.ErrorCounter(servermon.DBQueryErrorCount, &err, string(op)) + + db := d.DB.WithContext(ctx) + rows, err := db.Raw(RESOURCES_RAW_SQL, offset, limit).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + resources := make([]Resource, 0) + for rows.Next() { + var res Resource + err := db.ScanRows(rows, &res) + if err != nil { + return nil, err + } + resources = append(resources, res) + } + return resources, nil +} diff --git a/internal/db/resource_test.go b/internal/db/resource_test.go new file mode 100644 index 000000000..c42ccde1d --- /dev/null +++ b/internal/db/resource_test.go @@ -0,0 +1,95 @@ +// Copyright 2024 Canonical. +package db_test + +import ( + "context" + "database/sql" + + qt "github.com/frankban/quicktest" + "github.com/juju/juju/state" + + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/dbmodel" +) + +func SetupDB(c *qt.C, database *db.Database) (dbmodel.Model, dbmodel.Controller, dbmodel.Cloud) { + u, err := dbmodel.NewIdentity("bob@canonical.com") + c.Assert(err, qt.IsNil) + c.Assert(database.DB.Create(&u).Error, qt.IsNil) + + cloud := dbmodel.Cloud{ + Name: "test-cloud", + Type: "test-provider", + Regions: []dbmodel.CloudRegion{{ + Name: "test-region", + }}, + } + c.Assert(database.DB.Create(&cloud).Error, qt.IsNil) + + cred := dbmodel.CloudCredential{ + Name: "test-cred", + Cloud: cloud, + Owner: *u, + AuthType: "empty", + } + c.Assert(database.DB.Create(&cred).Error, qt.IsNil) + + controller := dbmodel.Controller{ + Name: "test-controller", + UUID: "00000000-0000-0000-0000-0000-0000000000001", + CloudName: "test-cloud", + CloudRegion: "test-region", + } + err = database.AddController(context.Background(), &controller) + c.Assert(err, qt.Equals, nil) + + model := dbmodel.Model{ + Name: "test-model-1", + UUID: sql.NullString{ + String: "00000001-0000-0000-0000-0000-000000000001", + Valid: true, + }, + OwnerIdentityName: u.Name, + ControllerID: controller.ID, + CloudRegionID: cloud.Regions[0].ID, + CloudCredentialID: cred.ID, + Type: "iaas", + DefaultSeries: "warty", + Life: state.Alive.String(), + Status: dbmodel.Status{ + Status: "available", + Since: db.Now(), + }, + SLA: dbmodel.SLA{ + Level: "unsupported", + }, + } + err = database.AddModel(context.Background(), &model) + c.Assert(err, qt.Equals, nil) + return model, controller, cloud +} + +func (s *dbSuite) TestGetResources(c *qt.C) { + ctx := context.Background() + err := s.Database.Migrate(context.Background(), true) + c.Assert(err, qt.Equals, nil) + res, err := s.Database.ListResources(ctx, 10, 0) + c.Assert(err, qt.Equals, nil) + c.Assert(res, qt.HasLen, 0) + // create one model, one controller, one cloud + model, controller, cloud := SetupDB(c, s.Database) + res, err = s.Database.ListResources(ctx, 10, 0) + c.Assert(err, qt.Equals, nil) + c.Assert(res, qt.HasLen, 3) + for _, r := range res { + switch r.Type { + case "model": + c.Assert(r.ID.String, qt.Equals, model.UUID.String) + c.Assert(r.ParentId.String, qt.Equals, controller.UUID) + case "controller": + c.Assert(r.ID.String, qt.Equals, controller.UUID) + case "cloud": + c.Assert(r.ID.String, qt.Equals, cloud.Name) + } + } +} diff --git a/internal/jimm/access.go b/internal/jimm/access.go index fb2978350..bf3d20940 100644 --- a/internal/jimm/access.go +++ b/internal/jimm/access.go @@ -17,6 +17,7 @@ import ( "github.com/juju/zaputil/zapctx" "go.uber.org/zap" + "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" @@ -662,15 +663,19 @@ func resolveTag(jimmUUID string, db *db.Database, tag string) (*ofganames.Tag, e return nil, errors.E(errors.CodeBadRequest, fmt.Sprintf("failed to map tag, unknown kind: %s", tagKind)) } -// ParseTag attempts to parse the provided key into a tag whilst additionally +// parseAndValidateTag attempts to parse the provided key into a tag whilst additionally // ensuring the resource exists for said tag. // // This key may be in the form of either a JIMM tag string or Juju tag string. -func (j *JIMM) ParseTag(ctx context.Context, key string) (*ofganames.Tag, error) { - op := errors.Op("jimm.ParseTag") +func (j *JIMM) parseAndValidateTag(ctx context.Context, key string) (*ofganames.Tag, error) { + op := errors.Op("jimm.parseAndValidateTag") tupleKeySplit := strings.SplitN(key, "-", 2) - if len(tupleKeySplit) < 2 { - return nil, errors.E(op, errors.CodeFailedToParseTupleKey, "tag does not have tuple key delimiter") + if len(tupleKeySplit) == 1 { + tag, err := ofganames.BlankKindTag(tupleKeySplit[0]) + if err != nil { + return nil, errors.E(op, errors.CodeFailedToParseTupleKey, err) + } + return tag, nil } tagString := key tag, err := resolveTag(j.UUID, &j.Database, tagString) @@ -698,6 +703,34 @@ func (j *JIMM) AddGroup(ctx context.Context, user *openfga.User, name string) (* return ge, nil } +// CountGroups returns the number of groups that exist. +func (j *JIMM) CountGroups(ctx context.Context, user *openfga.User) (int, error) { + const op = errors.Op("jimm.CountGroups") + + if !user.JimmAdmin { + return 0, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + count, err := j.Database.CountGroups(ctx) + if err != nil { + return 0, errors.E(op, err) + } + return count, nil +} + +// GetGroup returns a group based on the provided UUID. +func (j *JIMM) GetGroupByID(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.GroupEntry, error) { + const op = errors.Op("jimm.AddGroup") + + if !user.JimmAdmin { + return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + group := dbmodel.GroupEntry{UUID: uuid} + if err := j.Database.GetGroup(ctx, &group); err != nil { + return nil, errors.E(op, err) + } + return &group, nil +} + // RenameGroup renames a group in JIMM's DB. func (j *JIMM) RenameGroup(ctx context.Context, user *openfga.User, oldName, newName string) error { const op = errors.Op("jimm.RenameGroup") @@ -748,7 +781,7 @@ func (j *JIMM) RemoveGroup(ctx context.Context, user *openfga.User, name string) } // ListGroups returns a list of groups known to JIMM. -func (j *JIMM) ListGroups(ctx context.Context, user *openfga.User) ([]dbmodel.GroupEntry, error) { +func (j *JIMM) ListGroups(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]dbmodel.GroupEntry, error) { const op = errors.Op("jimm.ListGroups") if !user.JimmAdmin { @@ -756,7 +789,7 @@ func (j *JIMM) ListGroups(ctx context.Context, user *openfga.User) ([]dbmodel.Gr } var groups []dbmodel.GroupEntry - err := j.Database.ForEachGroup(ctx, func(ge *dbmodel.GroupEntry) error { + err := j.Database.ForEachGroup(ctx, filter.Limit(), filter.Offset(), func(ge *dbmodel.GroupEntry) error { groups = append(groups, *ge) return nil }) diff --git a/internal/jimm/access_test.go b/internal/jimm/access_test.go index 58292071e..509a452ad 100644 --- a/internal/jimm/access_test.go +++ b/internal/jimm/access_test.go @@ -18,6 +18,7 @@ import ( "github.com/juju/juju/state" "github.com/juju/names/v5" + "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" @@ -428,7 +429,7 @@ func TestJWTGeneratorMakeToken(t *testing.T) { } } -func TestParseTag(t *testing.T) { +func TestParseAndValidateTag(t *testing.T) { c := qt.New(t) ctx := context.Background() @@ -452,7 +453,7 @@ func TestParseTag(t *testing.T) { jimmTag := "model-" + user.Name + "/" + model.Name + "#administrator" // JIMM tag syntax for models - tag, err := j.ParseTag(ctx, jimmTag) + tag, err := j.ParseAndValidateTag(ctx, jimmTag) c.Assert(err, qt.IsNil) c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) c.Assert(tag.ID, qt.Equals, model.UUID.String) @@ -461,11 +462,22 @@ func TestParseTag(t *testing.T) { jujuTag := "model-" + model.UUID.String + "#administrator" // Juju tag syntax for models - tag, err = j.ParseTag(ctx, jujuTag) + tag, err = j.ParseAndValidateTag(ctx, jujuTag) c.Assert(err, qt.IsNil) c.Assert(tag.ID, qt.Equals, model.UUID.String) c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) c.Assert(tag.Relation.String(), qt.Equals, "administrator") + + // JIMM tag only kind + kindTag := "model" + tag, err = j.ParseAndValidateTag(ctx, kindTag) + c.Assert(err, qt.IsNil) + c.Assert(tag.ID, qt.Equals, "") + c.Assert(tag.Kind.String(), qt.Equals, names.ModelTagKind) + + // JIMM tag not valid + _, err = j.ParseAndValidateTag(ctx, "") + c.Assert(err, qt.ErrorMatches, "unknown tag kind") } func TestResolveTags(t *testing.T) { @@ -746,8 +758,44 @@ func TestAddGroup(t *testing.T) { err = j.Database.Migrate(ctx, false) c.Assert(err, qt.IsNil) - user, _, _, _, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) - u := openfga.NewUser(&user, ofgaClient) + dbU, err := dbmodel.NewIdentity(petname.Generate(2, "-"+"canonical.com")) + c.Assert(err, qt.IsNil) + u := openfga.NewUser(dbU, ofgaClient) + u.JimmAdmin = true + + g, err := j.AddGroup(ctx, u, "test-group-1") + c.Assert(err, qt.IsNil) + c.Assert(g.UUID, qt.Not(qt.Equals), "") + c.Assert(g.Name, qt.Equals, "test-group-1") + + g, err = j.AddGroup(ctx, u, "test-group-2") + c.Assert(err, qt.IsNil) + c.Assert(g.UUID, qt.Not(qt.Equals), "") + c.Assert(g.Name, qt.Equals, "test-group-2") +} + +func TestCountGroups(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + dbU, err := dbmodel.NewIdentity(petname.Generate(2, "-"+"canonical.com")) + c.Assert(err, qt.IsNil) + u := openfga.NewUser(dbU, ofgaClient) u.JimmAdmin = true groupEntry, err := j.AddGroup(ctx, u, "test-group-1") @@ -758,6 +806,39 @@ func TestAddGroup(t *testing.T) { c.Assert(errors.ErrorCode(err), qt.Equals, errors.CodeAlreadyExists) } +func TestGetGroupByID(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + dbU, err := dbmodel.NewIdentity(petname.Generate(2, "-"+"canonical.com")) + c.Assert(err, qt.IsNil) + u := openfga.NewUser(dbU, ofgaClient) + u.JimmAdmin = true + + groupEntry, err := j.AddGroup(ctx, u, "test-group-1") + c.Assert(err, qt.IsNil) + c.Assert(groupEntry.UUID, qt.Not(qt.Equals), "") + + gotGroup, err := j.GetGroupByID(ctx, u, groupEntry.UUID) + c.Assert(err, qt.IsNil) + c.Assert(gotGroup, qt.DeepEquals, groupEntry) +} + func TestRemoveGroup(t *testing.T) { c := qt.New(t) ctx := context.Background() @@ -988,7 +1069,8 @@ func TestListGroups(t *testing.T) { u := openfga.NewUser(&user, ofgaClient) u.JimmAdmin = true - groups, err := j.ListGroups(ctx, u) + filter := pagination.NewOffsetFilter(10, 0) + groups, err := j.ListGroups(ctx, u, filter) c.Assert(err, qt.IsNil) c.Assert(groups, qt.DeepEquals, []dbmodel.GroupEntry{group}) @@ -1003,13 +1085,14 @@ func TestListGroups(t *testing.T) { _, err := j.AddGroup(ctx, u, name) c.Assert(err, qt.IsNil) } - - groups, err = j.ListGroups(ctx, u) + groups, err = j.ListGroups(ctx, u, filter) c.Assert(err, qt.IsNil) sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name }) c.Assert(groups, qt.HasLen, 5) + // Check that the UUID is not empty + c.Assert(groups[0].UUID, qt.Not(qt.Equals), "") // groups should be returned in ascending order of name c.Assert(groups[0].Name, qt.Equals, "aaaFinalGroup") c.Assert(groups[1].Name, qt.Equals, group.Name) diff --git a/internal/jimm/admin.go b/internal/jimm/admin.go index 844761918..c714e35fd 100644 --- a/internal/jimm/admin.go +++ b/internal/jimm/admin.go @@ -4,6 +4,7 @@ package jimm import ( "context" + "net/http" "golang.org/x/oauth2" @@ -22,6 +23,11 @@ func (j *JIMM) LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, 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") diff --git a/internal/jimm/export_test.go b/internal/jimm/export_test.go index 9fd272ff7..77d5dbfeb 100644 --- a/internal/jimm/export_test.go +++ b/internal/jimm/export_test.go @@ -11,6 +11,7 @@ import ( "github.com/canonical/jimm/v3/internal/db" "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" ) var ( @@ -49,6 +50,10 @@ func (j *JIMM) ListApplicationOfferUsers(ctx context.Context, offer names.Applic return j.listApplicationOfferUsers(ctx, offer, user, accessLevel) } +func (j *JIMM) ParseAndValidateTag(ctx context.Context, key string) (*ofganames.Tag, error) { + return j.parseAndValidateTag(ctx, key) +} + func (j *JIMM) GetUser(ctx context.Context, identifier string) (*openfga.User, error) { return j.getUser(ctx, identifier) } diff --git a/internal/jimm/identity.go b/internal/jimm/identity.go new file mode 100644 index 000000000..740f28353 --- /dev/null +++ b/internal/jimm/identity.go @@ -0,0 +1,65 @@ +// 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. +func (j *JIMM) ListIdentities(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) { + const op = errors.Op("jimm.ListIdentities") + + if !user.JimmAdmin { + return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + var identities []openfga.User + err := j.Database.ForEachIdentity(ctx, filter.Limit(), filter.Offset(), func(ge *dbmodel.Identity) error { + u := openfga.NewUser(ge, j.OpenFGAClient) + identities = append(identities, *u) + return nil + }) + if err != nil { + return nil, errors.E(op, err) + } + return identities, 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_test.go b/internal/jimm/identity_test.go new file mode 100644 index 000000000..e189dc594 --- /dev/null +++ b/internal/jimm/identity_test.go @@ -0,0 +1,162 @@ +// Copyright 2024 Canonical. + +package jimm_test + +import ( + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/google/uuid" + + "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" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/openfga" +) + +func TestFetchIdentity(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + user, _, _, _, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + u, err := j.FetchIdentity(ctx, user.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") +} + +func TestListIdentities(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, ofgaClient) + u.JimmAdmin = true + + filter := pagination.NewOffsetFilter(10, 0) + users, err := j.ListIdentities(ctx, u, filter) + c.Assert(err, qt.IsNil) + c.Assert(len(users), qt.Equals, 0) + + userNames := []string{ + "bob1@canonical.com", + "bob3@canonical.com", + "bob5@canonical.com", + "bob4@canonical.com", + } + // add users + for _, name := range userNames { + _, err := j.GetUser(ctx, name) + c.Assert(err, qt.IsNil) + } + + testCases := []struct { + desc string + limit int + offset int + identities []string + }{ + { + desc: "test with first ids", + limit: 3, + offset: 0, + identities: []string{userNames[0], userNames[1], userNames[3]}, + }, + { + desc: "test with remianing ids", + limit: 3, + offset: 3, + identities: []string{userNames[2]}, + }, + { + desc: "test out of range", + limit: 3, + offset: 6, + identities: []string{}, + }, + } + for _, t := range testCases { + c.Run(t.desc, func(c *qt.C) { + filter = pagination.NewOffsetFilter(t.limit, t.offset) + identities, err := j.ListIdentities(ctx, u, filter) + c.Assert(err, qt.IsNil) + c.Assert(identities, qt.HasLen, len(t.identities)) + for i := range len(t.identities) { + c.Assert(identities[i].Name, qt.Equals, t.identities[i]) + } + }) + } +} + +func TestCountIdentities(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, ofgaClient) + u.JimmAdmin = true + + userNames := []string{ + "bob1@canonical.com", + "bob3@canonical.com", + "bob5@canonical.com", + "bob4@canonical.com", + } + // add users + for _, name := range userNames { + _, err := j.GetUser(ctx, name) + c.Assert(err, qt.IsNil) + } + count, err := j.CountIdentities(ctx, u) + c.Assert(err, qt.IsNil) + c.Assert(count, qt.Equals, 4) +} diff --git a/internal/jimm/jimm.go b/internal/jimm/jimm.go index bcde7e929..871036dd0 100644 --- a/internal/jimm/jimm.go +++ b/internal/jimm/jimm.go @@ -447,6 +447,18 @@ func (j *JIMM) FindAuditEvents(ctx context.Context, user *openfga.User, filter d return entries, nil } +// ControllerInfo returns info about a controller connected to JIMM. +func (j *JIMM) ControllerInfo(ctx context.Context, name string) (*dbmodel.Controller, error) { + const op = errors.Op("jimm.ListControllers") + ctl := dbmodel.Controller{ + Name: name, + } + if err := j.Database.GetController(ctx, &ctl); err != nil { + return nil, errors.E(op, err) + } + return &ctl, nil +} + // ListControllers returns a list of controllers the user has access to. func (j *JIMM) ListControllers(ctx context.Context, user *openfga.User) ([]dbmodel.Controller, error) { const op = errors.Op("jimm.ListControllers") diff --git a/internal/jimm/jimm_test.go b/internal/jimm/jimm_test.go index 867cbb9b9..177b7d6df 100644 --- a/internal/jimm/jimm_test.go +++ b/internal/jimm/jimm_test.go @@ -172,7 +172,7 @@ func TestFindAuditEvents(t *testing.T) { } } -const testListCoControllersEnv = `clouds: +const testControllersEnv = `clouds: - name: test type: test regions: @@ -207,6 +207,31 @@ users: controller-access: "no-access" ` +func TestControllerInfo(t *testing.T) { + c := qt.New(t) + + ctx := context.Background() + now := time.Now().UTC().Round(time.Millisecond) + + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + } + err := j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + env := jimmtest.ParseEnvironment(c, testControllersEnv) + env.PopulateDB(c, j.Database) + + ctl, err := j.ControllerInfo(ctx, "test1") + c.Assert(err, qt.IsNil) + c.Assert(ctl.Name, qt.Equals, "test1") + + _, err = j.ControllerInfo(ctx, "does-not-exist") + c.Assert(err, qt.ErrorMatches, "controller not found") +} + func TestListControllers(t *testing.T) { c := qt.New(t) @@ -227,7 +252,7 @@ func TestListControllers(t *testing.T) { err = j.Database.Migrate(ctx, false) c.Assert(err, qt.IsNil) - env := jimmtest.ParseEnvironment(c, testListCoControllersEnv) + env := jimmtest.ParseEnvironment(c, testControllersEnv) env.PopulateDBAndPermissions(c, j.ResourceTag(), j.Database, client) tests := []struct { diff --git a/internal/jimm/model_status_parser.go b/internal/jimm/model_status_parser.go index fdd25bb0d..dfc10255b 100644 --- a/internal/jimm/model_status_parser.go +++ b/internal/jimm/model_status_parser.go @@ -25,7 +25,7 @@ import ( // If a result is erroneous, for example, bad data type parsing, the resulting struct field // Errors will contain a map from model UUID -> []error. Otherwise, the Results field // will contain model UUID -> []Jq result. -func (j *JIMM) QueryModelsJq(ctx context.Context, models []dbmodel.Model, jqQuery string) (params.CrossModelQueryResponse, error) { +func (j *JIMM) QueryModelsJq(ctx context.Context, modelUUIDs []string, jqQuery string) (params.CrossModelQueryResponse, error) { op := errors.Op("QueryModels") results := params.CrossModelQueryResponse{ Results: make(map[string][]any), @@ -41,6 +41,11 @@ func (j *JIMM) QueryModelsJq(ctx context.Context, models []dbmodel.Model, jqQuer // of each facade call and type conversion. retriever := newFormatterParamsRetriever(j) + models, err := j.Database.GetModelsByUUID(ctx, modelUUIDs) + if err != nil { + return results, errors.E(op, "failed to get models for user") + } + for _, model := range models { modelUUID := model.UUID.String params, err := retriever.GetParams(ctx, model) diff --git a/internal/jimm/model_status_parser_test.go b/internal/jimm/model_status_parser_test.go index af10c096f..24f884fa4 100644 --- a/internal/jimm/model_status_parser_test.go +++ b/internal/jimm/model_status_parser_test.go @@ -447,12 +447,8 @@ func TestQueryModelsJq(t *testing.T) { // Tests: - // Fetch all models and reuse throughout the test. - models, err := j.Database.GetModelsByUUID(ctx, modelUUIDs) - c.Assert(err, qt.IsNil) - // Query for all models only. - res, err := j.QueryModelsJq(ctx, models, ".model") + res, err := j.QueryModelsJq(ctx, modelUUIDs, ".model") c.Assert(err, qt.IsNil) c.Assert(` { @@ -512,7 +508,7 @@ func TestQueryModelsJq(t *testing.T) { `, qt.JSONEquals, res) // Query all applications across all models. - res, err = j.QueryModelsJq(ctx, models, ".applications") + res, err = j.QueryModelsJq(ctx, modelUUIDs, ".applications") c.Assert(err, qt.IsNil) c.Assert(` { @@ -660,7 +656,7 @@ func TestQueryModelsJq(t *testing.T) { `, qt.JSONEquals, res) // Query specifically for models including the app "nginx-ingress-integrator" - res, err = j.QueryModelsJq(ctx, models, ".applications | with_entries(select(.key==\"nginx-ingress-integrator\"))") + res, err = j.QueryModelsJq(ctx, modelUUIDs, ".applications | with_entries(select(.key==\"nginx-ingress-integrator\"))") c.Assert(err, qt.IsNil) c.Assert(` { @@ -722,7 +718,7 @@ func TestQueryModelsJq(t *testing.T) { `, qt.JSONEquals, res) // Query specifically for storage on this model. - res, err = j.QueryModelsJq(ctx, models, ".storage") + res, err = j.QueryModelsJq(ctx, modelUUIDs, ".storage") c.Assert(err, qt.IsNil) // Not the cleanest thing in the world, but this field needs ignoring, diff --git a/internal/jimm/relation.go b/internal/jimm/relation.go new file mode 100644 index 000000000..20952e31c --- /dev/null +++ b/internal/jimm/relation.go @@ -0,0 +1,190 @@ +// Copyright 2024 Canonical. + +package jimm + +import ( + "context" + "fmt" + + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + apiparams "github.com/canonical/jimm/v3/pkg/api/params" +) + +// AddRelation checks user permission and add given relations tuples. +// At the moment user is required be admin. +func (j *JIMM) AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { + const op = errors.Op("jimm.AddRelation") + if !user.JimmAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + parsedTuples, err := j.parseTuples(ctx, tuples) + if err != nil { + return errors.E(err) + } + err = j.OpenFGAClient.AddRelation(ctx, parsedTuples...) + if err != nil { + return errors.E(op, errors.CodeOpenFGARequestFailed, err) + } + return nil +} + +// RemoveRelation checks user permission and remove given relations tuples. +// At the moment user is required be admin. +func (j *JIMM) RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { + const op = errors.Op("jimm.RemoveRelation") + if !user.JimmAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + parsedTuples, err := j.parseTuples(ctx, tuples) + if err != nil { + return errors.E(op, err) + } + err = j.OpenFGAClient.RemoveRelation(ctx, parsedTuples...) + if err != nil { + return errors.E(op, errors.CodeOpenFGARequestFailed, err) + } + return nil +} + +// CheckRelation checks user permission and return true if the given tuple exists. +// At the moment user is required be admin or checking its own relations +func (j *JIMM) CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) { + const op = errors.Op("jimm.CheckRelation") + allowed := false + parsedTuple, err := j.parseTuple(ctx, tuple) + if err != nil { + return false, errors.E(op, err) + } + userCheckingSelf := parsedTuple.Object.Kind == openfga.UserType && parsedTuple.Object.ID == user.Name + // Admins can check any relation, non-admins can only check their own. + if !(user.JimmAdmin || userCheckingSelf) { + return allowed, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + allowed, err = j.OpenFGAClient.CheckRelation(ctx, *parsedTuple, trace) + if err != nil { + return allowed, errors.E(op, errors.CodeOpenFGARequestFailed, err) + } + return allowed, nil +} + +// ListRelationshipTuples checks user permission and lists relationship tuples based of tuple struct with pagination. +// Listing filters can be relaxed: optionally exclude tuple.Relation or tuple.Object or specify only tuple.TargetObject.Kind. +func (j *JIMM) ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { + const op = errors.Op("jimm.ListRelationshipTuples") + if !user.JimmAdmin { + return nil, "", errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + // if targetObject is not specified returns all tuples. + parsedTuple := &openfga.Tuple{} + var err error + if tuple.TargetObject != "" { + parsedTuple, err = j.parseTuple(ctx, tuple) + if err != nil { + return nil, "", errors.E(op, err) + } + } else if tuple.Object != "" { + return nil, "", errors.E(op, errors.CodeBadRequest, "it is invalid to pass an object without a target object.") + } + + responseTuples, ct, err := j.OpenFGAClient.ReadRelatedObjects(ctx, *parsedTuple, pageSize, continuationToken) + if err != nil { + return nil, "", errors.E(op, err) + } + return responseTuples, ct, nil +} + +// ListObjectRelations lists all the tuples that an object has a direct relation with. +// Useful for listing all the resources that a group or user have access to. +// +// This functions provides a slightly higher-level abstraction in favor of ListRelationshipTuples. +func (j *JIMM) ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { + const op = errors.Op("jimm.ListObjectRelations") + var e pagination.EntitlementToken + if !user.JimmAdmin { + return nil, e, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + continuationToken, kind, err := pagination.DecodeEntitlementToken(entitlementToken) + if err != nil { + return nil, e, err + } + tuple := &openfga.Tuple{} + tuple.Object, err = j.parseAndValidateTag(ctx, object) + if err != nil { + return nil, e, err + } + tuple.Target, err = j.parseAndValidateTag(ctx, kind.String()) + if err != nil { + return nil, e, err + } + + responseTuples, nextContinuationToken, err := j.OpenFGAClient.ReadRelatedObjects(ctx, *tuple, pageSize, continuationToken) + if err != nil { + return nil, e, errors.E(op, err) + } + nextEntitlementToken, err := pagination.NextEntitlementToken(kind, nextContinuationToken) + if err != nil { + return nil, e, err + } + return responseTuples, nextEntitlementToken, nil +} + +// parseTuples translate the api request struct containing tuples to a slice of openfga tuple keys. +// This method utilises the parseTuple method which does all the heavy lifting. +func (j *JIMM) parseTuples(ctx context.Context, tuples []apiparams.RelationshipTuple) ([]openfga.Tuple, error) { + keys := make([]openfga.Tuple, 0, len(tuples)) + for _, tuple := range tuples { + key, err := j.parseTuple(ctx, tuple) + if err != nil { + return nil, errors.E(err) + } + keys = append(keys, *key) + } + return keys, nil +} + +// parseTuple takes the initial tuple from a relational request and ensures that +// whatever format, be it JAAS or Juju tag, is resolved to the correct identifier +// to be persisted within OpenFGA. +func (j *JIMM) parseTuple(ctx context.Context, tuple apiparams.RelationshipTuple) (*openfga.Tuple, error) { + const op = errors.Op("jujuapi.parseTuple") + + relation, err := ofganames.ParseRelation(tuple.Relation) + if err != nil { + return nil, errors.E(op, err, errors.CodeBadRequest) + } + t := openfga.Tuple{ + Relation: relation, + } + + // Wraps the general error that will be sent for both + // the object and target object, but changing the message and key + // to be specific to the erroneous offender. + parseTagError := func(msg string, key string, err error) error { + zapctx.Debug(ctx, msg, zap.String("key", key), zap.Error(err)) + return errors.E(op, errors.CodeFailedToParseTupleKey, fmt.Sprintf("%s %s: %s", msg, key, err.Error())) + } + + if tuple.TargetObject == "" { + return nil, errors.E(op, errors.CodeBadRequest, "target object not specified") + } + t.Target, err = j.parseAndValidateTag(ctx, tuple.TargetObject) + if err != nil { + return nil, parseTagError("failed to parse tuple target object key", tuple.TargetObject, err) + } + if tuple.Object != "" { + objectTag, err := j.parseAndValidateTag(ctx, tuple.Object) + if err != nil { + return nil, parseTagError("failed to parse tuple object key", tuple.Object, err) + } + t.Object = objectTag + } + + return &t, nil +} diff --git a/internal/jimm/relation_test.go b/internal/jimm/relation_test.go new file mode 100644 index 000000000..a506cdc4f --- /dev/null +++ b/internal/jimm/relation_test.go @@ -0,0 +1,269 @@ +// Copyright 2024 Canonical. + +package jimm_test + +import ( + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/google/uuid" + + "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" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/openfga" + "github.com/canonical/jimm/v3/internal/openfga/names" + apiparams "github.com/canonical/jimm/v3/pkg/api/params" +) + +func TestListRelationshipTuples(t *testing.T) { + // setup + c := qt.New(t) + ctx := context.Background() + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, ofgaClient) + u.JimmAdmin = true + + user, _, controller, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + c.Assert(err, qt.IsNil) + + err = j.AddRelation(ctx, u, []apiparams.RelationshipTuple{ + { + Object: user.Tag().String(), + Relation: names.ReaderRelation.String(), + TargetObject: model.ResourceTag().String(), + }, + { + Object: user.Tag().String(), + Relation: names.WriterRelation.String(), + TargetObject: model.ResourceTag().String(), + }, + { + Object: user.Tag().String(), + Relation: names.AuditLogViewerRelation.String(), + TargetObject: controller.ResourceTag().String(), + }, + }) + c.Assert(err, qt.IsNil) + type ExpectedTuple struct { + expectedRelation string + expectedTargetId string + } + // test + testCases := []struct { + description string + object string + relation string + targetObject string + expectedError error + expectedLength int + expectedTuples []ExpectedTuple + }{ + { + description: "test listing all relations of all entities", + object: "", + relation: "", + targetObject: "", + expectedError: nil, + expectedLength: 3, + }, + { + description: "test listing a specific relation", + object: user.Tag().String(), + relation: names.ReaderRelation.String(), + targetObject: model.ResourceTag().String(), + expectedError: nil, + expectedLength: 1, + expectedTuples: []ExpectedTuple{ + { + + expectedRelation: names.ReaderRelation.String(), + expectedTargetId: model.Tag().Id(), + }, + }, + }, + { + description: "test listing all relations between two entities leaving relation empty", + object: user.Tag().String(), + relation: "", + targetObject: model.ResourceTag().String(), + expectedError: nil, + expectedLength: 2, + expectedTuples: []ExpectedTuple{ + { + expectedRelation: names.ReaderRelation.String(), + expectedTargetId: model.Tag().Id(), + }, + { + expectedRelation: names.WriterRelation.String(), + expectedTargetId: model.Tag().Id(), + }, + }, + }, + { + description: "test listing all relations of a specific target entity", + object: "", + relation: "", + targetObject: model.ResourceTag().String(), + expectedError: nil, + expectedLength: 2, + expectedTuples: []ExpectedTuple{ + { + expectedRelation: names.ReaderRelation.String(), + expectedTargetId: model.Tag().Id(), + }, + { + expectedRelation: names.WriterRelation.String(), + expectedTargetId: model.Tag().Id(), + }, + }, + }, + { + description: "test listing all relations of specific object entity", + object: user.ResourceTag().String(), + relation: names.ReaderRelation.String(), + targetObject: "model", + expectedError: nil, + expectedLength: 1, + expectedTuples: []ExpectedTuple{ + { + expectedRelation: names.ReaderRelation.String(), + expectedTargetId: model.Tag().Id(), + }, + }, + }, + } + + for _, t := range testCases { + c.Run(t.description, func(c *qt.C) { + tuples, _, err := j.ListRelationshipTuples(ctx, u, apiparams.RelationshipTuple{ + Object: t.object, + Relation: t.relation, + TargetObject: t.targetObject, + }, 10, "") + c.Assert(err, qt.Equals, t.expectedError) + c.Assert(tuples, qt.HasLen, t.expectedLength) + for i, expectedTuple := range t.expectedTuples { + c.Assert(tuples[i].Relation.String(), qt.Equals, expectedTuple.expectedRelation) + c.Assert(tuples[i].Target.ID, qt.Equals, expectedTuple.expectedTargetId) + } + }) + } +} + +func TestListObjectRelations(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + + u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, ofgaClient) + u.JimmAdmin = true + + user, _, controller, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + c.Assert(err, qt.IsNil) + + err = j.AddRelation(ctx, u, []apiparams.RelationshipTuple{ + { + Object: user.Tag().String(), + Relation: names.ReaderRelation.String(), + TargetObject: model.ResourceTag().String(), + }, + { + Object: user.Tag().String(), + Relation: names.WriterRelation.String(), + TargetObject: model.ResourceTag().String(), + }, + { + Object: user.Tag().String(), + Relation: names.AuditLogViewerRelation.String(), + TargetObject: controller.ResourceTag().String(), + }, + }) + c.Assert(err, qt.IsNil) + type ExpectedTuple struct { + expectedRelation string + expectedTargetId string + } + + testCases := []struct { + description string + object string + initialToken pagination.EntitlementToken + expectedError string + expectedLength int + expectedTuples []ExpectedTuple + }{ + { + description: "test listing all relations", + object: user.Tag().String(), + expectedLength: 3, + }, + { + description: "invalid initial token", + initialToken: pagination.NewEntitlementToken("bar"), + expectedError: "failed to decode pagination token.*", + }, + { + description: "invalid user tag token", + object: "foo" + user.Tag().String(), + expectedError: "failed to map tag, unknown kind: foouser", + }, + } + + for _, t := range testCases { + c.Run(t.description, func(c *qt.C) { + token := t.initialToken + tuples := []openfga.Tuple{} + for { + res, nextToken, err := j.ListObjectRelations(ctx, u, t.object, 10, token) + if t.expectedError != "" { + c.Assert(err, qt.ErrorMatches, t.expectedError) + break + } + tuples = append(tuples, res...) + if nextToken.String() == "" { + break + } + token = nextToken + } + c.Assert(tuples, qt.HasLen, t.expectedLength) + for i, expectedTuple := range t.expectedTuples { + c.Assert(tuples[i].Relation.String(), qt.Equals, expectedTuple.expectedRelation) + c.Assert(tuples[i].Target.ID, qt.Equals, expectedTuple.expectedTargetId) + } + }) + } +} diff --git a/internal/jimm/resource.go b/internal/jimm/resource.go new file mode 100644 index 000000000..134ba56ed --- /dev/null +++ b/internal/jimm/resource.go @@ -0,0 +1,22 @@ +// Copyright 2024 Canonical. +package jimm + +import ( + "context" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" +) + +// ListResources returns a list of resources known to JIMM with a pagination filter. +func (j *JIMM) ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]db.Resource, error) { + const op = errors.Op("jimm.ListResources") + + if !user.JimmAdmin { + return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + return j.Database.ListResources(ctx, filter.Limit(), filter.Offset()) +} diff --git a/internal/jimm/resource_test.go b/internal/jimm/resource_test.go new file mode 100644 index 000000000..a6c78d2ea --- /dev/null +++ b/internal/jimm/resource_test.go @@ -0,0 +1,80 @@ +// Copyright 2024 Canonical. +package jimm_test + +import ( + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/google/uuid" + + "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" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/openfga" +) + +func TestGetResources(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name()) + c.Assert(err, qt.IsNil) + + now := time.Now().UTC().Round(time.Millisecond) + j := &jimm.JIMM{ + UUID: uuid.NewString(), + Database: db.Database{ + DB: jimmtest.PostgresDB(c, func() time.Time { return now }), + }, + OpenFGAClient: ofgaClient, + } + + err = j.Database.Migrate(ctx, false) + c.Assert(err, qt.IsNil) + _, _, controller, model, applicationOffer, cloud, _ := createTestControllerEnvironment(ctx, c, j.Database) + + ids := []string{applicationOffer.UUID, cloud.Name, controller.UUID, model.UUID.String} + + u := openfga.NewUser(&dbmodel.Identity{Name: "admin@canonical.com"}, ofgaClient) + u.JimmAdmin = true + + testCases := []struct { + desc string + limit int + offset int + identities []string + }{ + { + desc: "test with first resources", + limit: 3, + offset: 0, + identities: []string{ids[0], ids[1], ids[2]}, + }, + { + desc: "test with remianing ids", + limit: 3, + offset: 3, + identities: []string{ids[3]}, + }, + { + desc: "test out of range", + limit: 3, + offset: 6, + identities: []string{}, + }, + } + for _, t := range testCases { + c.Run(t.desc, func(c *qt.C) { + filter := pagination.NewOffsetFilter(t.limit, t.offset) + resources, err := j.ListResources(ctx, u, filter) + c.Assert(err, qt.IsNil) + c.Assert(resources, qt.HasLen, len(t.identities)) + for i := range len(t.identities) { + c.Assert(resources[i].ID.String, qt.Equals, t.identities[i]) + } + }) + } +} diff --git a/internal/jimm/service_account.go b/internal/jimm/service_account.go index 2f179dbca..c4f5bbdeb 100644 --- a/internal/jimm/service_account.go +++ b/internal/jimm/service_account.go @@ -98,7 +98,7 @@ func (j *JIMM) GrantServiceAccountAccess(ctx context.Context, u *openfga.User, s tags := make([]*ofganames.Tag, 0, len(entities)) // Validate tags for _, val := range entities { - tag, err := j.ParseTag(ctx, val) + tag, err := j.parseAndValidateTag(ctx, val) if err != nil { return errors.E(op, err) } @@ -120,7 +120,7 @@ func (j *JIMM) GrantServiceAccountAccess(ctx context.Context, u *openfga.User, s } tuples = append(tuples, tuple) } - err := j.AuthorizationClient().AddRelation(ctx, tuples...) + err := j.OpenFGAClient.AddRelation(ctx, tuples...) if err != nil { zapctx.Error(ctx, "failed to add tuple(s)", zap.NamedError("add-relation-error", err)) return errors.E(op, errors.CodeOpenFGARequestFailed, err) diff --git a/internal/jimm/service_account_test.go b/internal/jimm/service_account_test.go index 6c3c4aaf9..12268a399 100644 --- a/internal/jimm/service_account_test.go +++ b/internal/jimm/service_account_test.go @@ -260,14 +260,14 @@ func TestGrantServiceAccountAccess(t *testing.T) { if test.expectedError == "" { c.Assert(err, qt.IsNil) for _, tag := range test.tags { - parsedTag, err := jimm.ParseTag(context.Background(), tag) + parsedTag, err := jimm.ParseAndValidateTag(context.Background(), tag) c.Assert(err, qt.IsNil) tuple := openfga.Tuple{ Object: parsedTag, Relation: ofganames.AdministratorRelation, Target: ofganames.ConvertTag(jimmnames.NewServiceAccountTag(test.clientID)), } - ok, err := jimm.AuthorizationClient().CheckRelation(context.Background(), tuple, false) + ok, err := jimm.OpenFGAClient.CheckRelation(context.Background(), tuple, false) c.Assert(err, qt.IsNil) c.Assert(ok, qt.IsTrue) } diff --git a/internal/jimmhttp/auth_handler.go b/internal/jimmhttp/auth_handler.go index d8590122a..285093a84 100644 --- a/internal/jimmhttp/auth_handler.go +++ b/internal/jimmhttp/auth_handler.go @@ -34,7 +34,6 @@ type OAuthHandler struct { Router *chi.Mux authenticator BrowserOAuthAuthenticator dashboardFinalRedirectURL string - secureCookies bool } // OAuthHandlerParams holds the parameters to configure the OAuthHandler. @@ -45,10 +44,6 @@ type OAuthHandlerParams struct { // DashboardFinalRedirectURL is the final redirection URL to send users to // upon completing the authorisation code flow. DashboardFinalRedirectURL string - - // SessionCookies determines if HTTPS must be enabled in order for JIMM - // to set cookies when creating browser based sessions. - SecureCookies bool } // BrowserOAuthAuthenticator handles authorisation code authentication within JIMM @@ -63,7 +58,6 @@ type BrowserOAuthAuthenticator interface { ctx context.Context, w http.ResponseWriter, r *http.Request, - secureCookies bool, email string, ) error Logout(ctx context.Context, w http.ResponseWriter, req *http.Request) error @@ -83,7 +77,6 @@ func NewOAuthHandler(p OAuthHandlerParams) (*OAuthHandler, error) { Router: chi.NewRouter(), authenticator: p.Authenticator, dashboardFinalRedirectURL: p.DashboardFinalRedirectURL, - secureCookies: p.SecureCookies, }, nil } @@ -173,7 +166,6 @@ func (oah *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) { ctx, w, r, - oah.secureCookies, email, ); err != nil { writeError(ctx, w, http.StatusInternalServerError, err, "failed to setup session") diff --git a/internal/jimmtest/auth.go b/internal/jimmtest/auth.go index 2eb24d476..5eccb20f1 100644 --- a/internal/jimmtest/auth.go +++ b/internal/jimmtest/auth.go @@ -164,7 +164,7 @@ func (m *mockOAuthAuthenticator) MintSessionToken(email string) (string, error) return newSessionToken(m.c, email, ""), nil } -// AuthenticateBrowserSession always returns an error. +// AuthenticateBrowserSession unless overridden by the `AuthenticateBrowserSession_` field, it will return an authentication failure error. func (m *mockOAuthAuthenticator) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { return ctx, errors.New("authentication failed") } @@ -239,6 +239,7 @@ func SetupTestDashboardCallbackHandler(browserURL string, db *db.Database, sessi SessionStore: sessionStore, SessionCookieMaxAge: 60, JWTSessionKey: "test-secret", + SecureCookies: false, }) if err != nil { return nil, err @@ -247,7 +248,6 @@ func SetupTestDashboardCallbackHandler(browserURL string, db *db.Database, sessi h, err := jimmhttp.NewOAuthHandler(jimmhttp.OAuthHandlerParams{ Authenticator: authSvc, DashboardFinalRedirectURL: browserURL, - SecureCookies: false, }) if err != nil { return nil, err diff --git a/internal/jimmtest/env.go b/internal/jimmtest/env.go index 229ac7552..228058cc0 100644 --- a/internal/jimmtest/env.go +++ b/internal/jimmtest/env.go @@ -38,11 +38,13 @@ type Environment struct { UserDefaults []UserDefaults `json:"user-defaults"` } -func ParseEnvironment(c *qt.C, env string) *Environment { +func ParseEnvironment(c Tester, env string) *Environment { var e Environment err := yaml.Unmarshal([]byte(env), &e) - c.Assert(err, qt.IsNil) + if err != nil { + c.Fatalf("err is not nil: %s", err) + } return &e } @@ -220,7 +222,7 @@ func (e *Environment) PopulateDBAndPermissions(c *qt.C, jimmTag names.Controller e.addJIMMRelations(c, jimmTag, db, client) } -func (e *Environment) PopulateDB(c *qt.C, db db.Database) { +func (e *Environment) PopulateDB(c Tester, db db.Database) { for i := range e.Users { e.Users[i].env = e e.Users[i].DBObject(c, db) @@ -261,7 +263,7 @@ type UserDefaults struct { dbo dbmodel.IdentityModelDefaults } -func (cd *UserDefaults) DBObject(c *qt.C, db db.Database) dbmodel.IdentityModelDefaults { +func (cd *UserDefaults) DBObject(c Tester, db db.Database) dbmodel.IdentityModelDefaults { if cd.dbo.ID != 0 { return cd.dbo } @@ -270,7 +272,10 @@ func (cd *UserDefaults) DBObject(c *qt.C, db db.Database) dbmodel.IdentityModelD cd.dbo.Defaults = cd.Defaults err := db.SetIdentityModelDefaults(context.Background(), &cd.dbo) - c.Assert(err, qt.IsNil) + if err != nil { + c.Fatalf("err is not nil: %s", err) + } + return cd.dbo } @@ -285,7 +290,7 @@ type CloudDefaults struct { dbo dbmodel.CloudDefaults } -func (cd *CloudDefaults) DBObject(c *qt.C, db db.Database) dbmodel.CloudDefaults { +func (cd *CloudDefaults) DBObject(c Tester, db db.Database) dbmodel.CloudDefaults { if cd.dbo.ID != 0 { return cd.dbo } @@ -296,7 +301,10 @@ func (cd *CloudDefaults) DBObject(c *qt.C, db db.Database) dbmodel.CloudDefaults cd.dbo.Defaults = cd.Defaults err := db.SetCloudDefaults(context.Background(), &cd.dbo) - c.Assert(err, qt.IsNil) + if err != nil { + c.Fatalf("err is not nil: %s", err) + } + return cd.dbo } @@ -320,7 +328,7 @@ type CloudRegion struct { // DBObject returns a database object for the specified cloud, suitable // for adding to the database. -func (cl *Cloud) DBObject(c *qt.C, db db.Database) dbmodel.Cloud { +func (cl *Cloud) DBObject(c Tester, db db.Database) dbmodel.Cloud { if cl.dbo.ID != 0 { return cl.dbo } @@ -335,7 +343,10 @@ func (cl *Cloud) DBObject(c *qt.C, db db.Database) dbmodel.Cloud { } err := db.AddCloud(context.Background(), &cl.dbo) - c.Assert(err, qt.IsNil) + if err != nil { + c.Fatalf("err is not nil: %s", err) + } + return cl.dbo } @@ -352,7 +363,7 @@ type CloudCredential struct { dbo dbmodel.CloudCredential } -func (cc *CloudCredential) DBObject(c *qt.C, db db.Database) dbmodel.CloudCredential { +func (cc *CloudCredential) DBObject(c Tester, db db.Database) dbmodel.CloudCredential { if cc.dbo.ID != 0 { return cc.dbo } @@ -365,7 +376,10 @@ func (cc *CloudCredential) DBObject(c *qt.C, db db.Database) dbmodel.CloudCreden cc.dbo.Attributes = cc.Attributes err := db.SetCloudCredential(context.Background(), &cc.dbo) - c.Assert(err, qt.IsNil) + if err != nil { + c.Fatalf("err is not nil: %s", err) + } + return cc.dbo } @@ -385,7 +399,7 @@ type Controller struct { dbo dbmodel.Controller } -func (ctl *Controller) DBObject(c *qt.C, db db.Database) dbmodel.Controller { +func (ctl *Controller) DBObject(c Tester, db db.Database) dbmodel.Controller { if ctl.dbo.ID != 0 { return ctl.dbo } @@ -406,7 +420,9 @@ func (ctl *Controller) DBObject(c *qt.C, db db.Database) dbmodel.Controller { } err := db.AddController(context.Background(), &ctl.dbo) - c.Assert(err, qt.IsNil) + if err != nil { + c.Fatalf("err is not nil: %s", err) + } return ctl.dbo } @@ -445,7 +461,7 @@ type Model struct { dbo dbmodel.Model } -func (m *Model) DBObject(c *qt.C, db db.Database) dbmodel.Model { +func (m *Model) DBObject(c Tester, db db.Database) dbmodel.Model { if m.dbo.ID != 0 { return m.dbo } @@ -480,7 +496,10 @@ func (m *Model) DBObject(c *qt.C, db db.Database) dbmodel.Model { m.dbo.Units = m.Units err := db.AddModel(context.Background(), &m.dbo) - c.Assert(err, qt.IsNil) + if err != nil { + c.Fatalf("err is not nil: %s", err) + } + return m.dbo } @@ -493,15 +512,18 @@ type User struct { dbo dbmodel.Identity } -func (u *User) DBObject(c *qt.C, db db.Database) dbmodel.Identity { +func (u *User) DBObject(c Tester, db db.Database) dbmodel.Identity { if u.dbo.ID != 0 { return u.dbo } u.dbo.Name = u.Username u.dbo.DisplayName = u.DisplayName - err := db.UpdateIdentity(context.Background(), &u.dbo) - c.Assert(err, qt.IsNil) + err := db.GetIdentity(context.Background(), &u.dbo) + if err != nil { + c.Fatalf("err is not nil: %s", err) + } + return u.dbo } diff --git a/internal/jimmtest/jimm_mock.go b/internal/jimmtest/jimm_mock.go index c51a7febc..de846f8f0 100644 --- a/internal/jimmtest/jimm_mock.go +++ b/internal/jimmtest/jimm_mock.go @@ -10,8 +10,8 @@ import ( "github.com/google/uuid" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/juju/version" + "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" @@ -29,21 +29,19 @@ import ( // will delegate to the requested funcion or if the funcion is nil return // a NotImplemented error. type JIMM struct { + mocks.RelationService + mocks.GroupService + 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 - AddController_ func(ctx context.Context, u *openfga.User, ctl *dbmodel.Controller) error - AddGroup_ func(ctx context.Context, user *openfga.User, name string) (*dbmodel.GroupEntry, error) AddHostedCloud_ func(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error AddServiceAccount_ func(ctx context.Context, u *openfga.User, clientId string) error Authenticate_ func(ctx context.Context, req *jujuparams.LoginRequest) (*openfga.User, error) - AuthorizationClient_ func() *openfga.OFGAClient 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) - DB_ func() *db.Database DestroyOffer_ func(ctx context.Context, user *openfga.User, offerURL string, force bool) error - EarliestControllerVersion_ func(ctx context.Context) (version.Number, 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 @@ -54,9 +52,11 @@ type JIMM struct { GetCloud_ func(ctx context.Context, u *openfga.User, tag names.CloudTag) (dbmodel.Cloud, error) GetCloudCredential_ func(ctx context.Context, user *openfga.User, tag names.CloudCredentialTag) (*dbmodel.CloudCredential, error) GetCloudCredentialAttributes_ func(ctx context.Context, u *openfga.User, cred *dbmodel.CloudCredential, hidden bool) (attrs map[string]string, redacted []string, err error) - GetControllerConfig_ func(ctx context.Context, u *dbmodel.Identity) (*dbmodel.ControllerConfig, error) GetCredentialStore_ func() jimmcreds.CredentialStore GetJimmControllerAccess_ func(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) + FetchIdentity_ func(ctx context.Context, username string) (*openfga.User, error) + CountIdentities_ func(ctx context.Context, user *openfga.User) (int, error) + ListIdentities_ func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) GetUserCloudAccess_ func(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) GetUserControllerAccess_ func(ctx context.Context, user *openfga.User, controller names.ControllerTag) (string, error) GetUserModelAccess_ func(ctx context.Context, user *openfga.User, model names.ModelTag) (string, error) @@ -68,26 +68,18 @@ type JIMM struct { InitiateMigration_ func(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec) (jujuparams.InitiateMigrationResult, error) InitiateInternalMigration_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetController string) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers_ func(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) - ListControllers_ func(ctx context.Context, user *openfga.User) ([]dbmodel.Controller, error) - ListGroups_ func(ctx context.Context, user *openfga.User) ([]dbmodel.GroupEntry, error) + ListResources_ func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]db.Resource, error) Offer_ func(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error - OAuthAuthenticationService_ func() jimm.OAuthAuthenticator - ParseTag_ func(ctx context.Context, key string) (*ofganames.Tag, error) PubSubHub_ func() *pubsub.Hub PurgeLogs_ func(ctx context.Context, user *openfga.User, before time.Time) (int64, error) RemoveCloud_ func(ctx context.Context, u *openfga.User, ct names.CloudTag) error RemoveCloudFromController_ func(ctx context.Context, u *openfga.User, controllerName string, ct names.CloudTag) error - RemoveController_ func(ctx context.Context, user *openfga.User, controllerName string, force bool) error - RemoveGroup_ func(ctx context.Context, user *openfga.User, name string) error - RenameGroup_ func(ctx context.Context, user *openfga.User, oldName, newName string) error ResourceTag_ func() names.ControllerTag RevokeAuditLogAccess_ func(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error RevokeCloudAccess_ func(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error RevokeCloudCredential_ func(ctx context.Context, user *dbmodel.Identity, tag names.CloudCredentialTag, force bool) error 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) - SetControllerConfig_ func(ctx context.Context, u *openfga.User, args jujuparams.ControllerConfigSet) error - SetControllerDeprecated_ func(ctx context.Context, user *openfga.User, controllerName string, deprecated bool) error 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 @@ -108,18 +100,6 @@ func (j *JIMM) AddCloudToController(ctx context.Context, user *openfga.User, con } return j.AddCloudToController_(ctx, user, controllerName, tag, cloud, force) } -func (j *JIMM) AddController(ctx context.Context, u *openfga.User, ctl *dbmodel.Controller) error { - if j.AddController_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.AddController_(ctx, u, ctl) -} -func (j *JIMM) AddGroup(ctx context.Context, u *openfga.User, name string) (*dbmodel.GroupEntry, error) { - if j.AddGroup_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.AddGroup_(ctx, u, name) -} func (j *JIMM) AddHostedCloud(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error { if j.AddHostedCloud_ == nil { return errors.E(errors.CodeNotImplemented) @@ -147,12 +127,6 @@ func (j *JIMM) Authenticate(ctx context.Context, req *jujuparams.LoginRequest) ( } return j.Authenticate_(ctx, req) } -func (j *JIMM) AuthorizationClient() *openfga.OFGAClient { - if j.AuthorizationClient_ == nil { - return nil - } - return j.AuthorizationClient_() -} func (j *JIMM) CheckPermission(ctx context.Context, user *openfga.User, cachedPerms map[string]string, desiredPerms map[string]interface{}) (map[string]string, error) { if j.CheckPermission_ == nil { @@ -160,25 +134,12 @@ func (j *JIMM) CheckPermission(ctx context.Context, user *openfga.User, cachedPe } return j.CheckPermission_(ctx, user, cachedPerms, desiredPerms) } -func (j *JIMM) DB() *db.Database { - if j.DB_ == nil { - panic("not implemented") - } - return j.DB_() -} func (j *JIMM) DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error { if j.DestroyOffer_ == nil { return errors.E(errors.CodeNotImplemented) } return j.DestroyOffer_(ctx, user, offerURL, force) } - -func (j *JIMM) EarliestControllerVersion(ctx context.Context) (version.Number, error) { - if j.EarliestControllerVersion_ == nil { - return version.Number{}, errors.E(errors.CodeNotImplemented) - } - return j.EarliestControllerVersion_(ctx) -} func (j *JIMM) FindApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetailsV5, error) { if j.FindApplicationOffers_ == nil { return nil, errors.E(errors.CodeNotImplemented) @@ -241,12 +202,7 @@ func (j *JIMM) GetCloudCredentialAttributes(ctx context.Context, u *openfga.User } return j.GetCloudCredentialAttributes_(ctx, u, cred, hidden) } -func (j *JIMM) GetControllerConfig(ctx context.Context, u *dbmodel.Identity) (*dbmodel.ControllerConfig, error) { - if j.GetControllerConfig_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.GetControllerConfig_(ctx, u) -} + func (j *JIMM) GetCredentialStore() jimmcreds.CredentialStore { if j.GetCredentialStore_ == nil { return nil @@ -259,6 +215,24 @@ func (j *JIMM) GetJimmControllerAccess(ctx context.Context, user *openfga.User, } 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) + } + return j.FetchIdentity_(ctx, username) +} +func (j *JIMM) CountIdentities(ctx context.Context, user *openfga.User) (int, error) { + if j.CountIdentities_ == nil { + return 0, errors.E(errors.CodeNotImplemented) + } + return j.CountIdentities_(ctx, user) +} +func (j *JIMM) ListIdentities(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) { + if j.ListIdentities_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.ListIdentities_(ctx, user, filter) +} func (j *JIMM) GetUserCloudAccess(ctx context.Context, user *openfga.User, cloud names.CloudTag) (string, error) { if j.GetUserCloudAccess_ == nil { return "", errors.E(errors.CodeNotImplemented) @@ -327,37 +301,18 @@ func (j *JIMM) ListApplicationOffers(ctx context.Context, user *openfga.User, fi } return j.ListApplicationOffers_(ctx, user, filters...) } -func (j *JIMM) ListControllers(ctx context.Context, user *openfga.User) ([]dbmodel.Controller, error) { - if j.ListControllers_ == nil { +func (j *JIMM) ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]db.Resource, error) { + if j.ListResources_ == nil { return nil, errors.E(errors.CodeNotImplemented) } - return j.ListControllers_(ctx, user) + return j.ListResources_(ctx, user, filter) } -func (j *JIMM) ListGroups(ctx context.Context, user *openfga.User) ([]dbmodel.GroupEntry, error) { - if j.ListGroups_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.ListGroups_(ctx, user) -} - func (j *JIMM) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error { if j.Offer_ == nil { return errors.E(errors.CodeNotImplemented) } return j.Offer_(ctx, user, offer) } -func (j *JIMM) OAuthAuthenticationService() jimm.OAuthAuthenticator { - if j.OAuthAuthenticationService_ == nil { - panic("not implemented") - } - return j.OAuthAuthenticationService_() -} -func (j *JIMM) ParseTag(ctx context.Context, key string) (*ofganames.Tag, error) { - if j.ParseTag_ == nil { - return nil, errors.E(errors.CodeNotImplemented) - } - return j.ParseTag_(ctx, key) -} func (j *JIMM) PubSubHub() *pubsub.Hub { if j.PubSubHub_ == nil { panic("not implemented") @@ -370,7 +325,6 @@ func (j *JIMM) PurgeLogs(ctx context.Context, user *openfga.User, before time.Ti } return j.PurgeLogs_(ctx, user, before) } - func (j *JIMM) RemoveCloud(ctx context.Context, u *openfga.User, ct names.CloudTag) error { if j.RemoveCloud_ == nil { return errors.E(errors.CodeNotImplemented) @@ -383,24 +337,6 @@ func (j *JIMM) RemoveCloudFromController(ctx context.Context, u *openfga.User, c } return j.RemoveCloudFromController_(ctx, u, controllerName, ct) } -func (j *JIMM) RemoveController(ctx context.Context, user *openfga.User, controllerName string, force bool) error { - if j.RemoveController_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.RemoveController_(ctx, user, controllerName, force) -} -func (j *JIMM) RemoveGroup(ctx context.Context, user *openfga.User, name string) error { - if j.RemoveGroup_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.RemoveGroup_(ctx, user, name) -} -func (j *JIMM) RenameGroup(ctx context.Context, user *openfga.User, oldName, newName string) error { - if j.RenameGroup_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.RenameGroup_(ctx, user, oldName, newName) -} func (j *JIMM) ResourceTag() names.ControllerTag { if j.ResourceTag_ == nil { return names.NewControllerTag(uuid.NewString()) @@ -437,19 +373,6 @@ func (j *JIMM) RevokeOfferAccess(ctx context.Context, user *openfga.User, offerU } return j.RevokeOfferAccess_(ctx, user, offerURL, ut, access) } -func (j *JIMM) SetControllerConfig(ctx context.Context, u *openfga.User, args jujuparams.ControllerConfigSet) error { - if j.SetControllerConfig_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.SetControllerConfig_(ctx, u, args) -} -func (j *JIMM) SetControllerDeprecated(ctx context.Context, user *openfga.User, controllerName string, deprecated bool) error { - if j.SetControllerDeprecated_ == nil { - return errors.E(errors.CodeNotImplemented) - } - return j.SetControllerDeprecated_(ctx, user, controllerName, deprecated) -} - func (j *JIMM) SetIdentityModelDefaults(ctx context.Context, user *dbmodel.Identity, configs map[string]interface{}) error { if j.SetIdentityModelDefaults_ == nil { return errors.E(errors.CodeNotImplemented) @@ -481,7 +404,6 @@ func (j *JIMM) UpdateCloudCredential(ctx context.Context, u *openfga.User, args } return j.UpdateCloudCredential_(ctx, u, args) } - func (j *JIMM) UserLogin(ctx context.Context, identityName string) (*openfga.User, error) { if j.UserLogin_ == nil { return nil, errors.E(errors.CodeNotImplemented) diff --git a/internal/jimmtest/mocks/jimm_controller_mock.go b/internal/jimmtest/mocks/jimm_controller_mock.go new file mode 100644 index 000000000..a0f2aead6 --- /dev/null +++ b/internal/jimmtest/mocks/jimm_controller_mock.go @@ -0,0 +1,81 @@ +// Copyright 2024 Canonical. +package mocks + +import ( + "context" + + jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/version" + + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/openfga" +) + +// ControllerService is an implementation of the jujuapi.ControllerService interface. +type ControllerService struct { + AddController_ func(ctx context.Context, u *openfga.User, ctl *dbmodel.Controller) error + ControllerInfo_ func(ctx context.Context, name string) (*dbmodel.Controller, error) + GetControllerConfig_ func(ctx context.Context, u *dbmodel.Identity) (*dbmodel.ControllerConfig, error) + EarliestControllerVersion_ func(ctx context.Context) (version.Number, error) + ListControllers_ func(ctx context.Context, user *openfga.User) ([]dbmodel.Controller, error) + RemoveController_ func(ctx context.Context, user *openfga.User, controllerName string, force bool) error + SetControllerConfig_ func(ctx context.Context, u *openfga.User, args jujuparams.ControllerConfigSet) error + SetControllerDeprecated_ func(ctx context.Context, user *openfga.User, controllerName string, deprecated bool) error +} + +func (j *ControllerService) AddController(ctx context.Context, u *openfga.User, ctl *dbmodel.Controller) error { + if j.AddController_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.AddController_(ctx, u, ctl) +} + +func (j *ControllerService) ControllerInfo(ctx context.Context, name string) (*dbmodel.Controller, error) { + if j.ControllerInfo_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.ControllerInfo_(ctx, name) +} + +func (j *ControllerService) EarliestControllerVersion(ctx context.Context) (version.Number, error) { + if j.EarliestControllerVersion_ == nil { + return version.Number{}, errors.E(errors.CodeNotImplemented) + } + return j.EarliestControllerVersion_(ctx) +} + +func (j *ControllerService) GetControllerConfig(ctx context.Context, u *dbmodel.Identity) (*dbmodel.ControllerConfig, error) { + if j.GetControllerConfig_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.GetControllerConfig_(ctx, u) +} + +func (j *ControllerService) ListControllers(ctx context.Context, user *openfga.User) ([]dbmodel.Controller, error) { + if j.ListControllers_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.ListControllers_(ctx, user) +} + +func (j *ControllerService) RemoveController(ctx context.Context, user *openfga.User, controllerName string, force bool) error { + if j.RemoveController_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.RemoveController_(ctx, user, controllerName, force) +} + +func (j *ControllerService) SetControllerConfig(ctx context.Context, u *openfga.User, args jujuparams.ControllerConfigSet) error { + if j.SetControllerConfig_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.SetControllerConfig_(ctx, u, args) +} + +func (j *ControllerService) SetControllerDeprecated(ctx context.Context, user *openfga.User, controllerName string, deprecated bool) error { + if j.SetControllerDeprecated_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.SetControllerDeprecated_(ctx, user, controllerName, deprecated) +} diff --git a/internal/jimmtest/mocks/jimm_group_mock.go b/internal/jimmtest/mocks/jimm_group_mock.go new file mode 100644 index 000000000..8065635d5 --- /dev/null +++ b/internal/jimmtest/mocks/jimm_group_mock.go @@ -0,0 +1,69 @@ +// Copyright 2024 Canonical. + +// This package contains mocks for each JIMM service. +// Each file contains a struct providing tests with the ability to mock +// JIMM services on test-by-test basis. Each struct has a corresponding +// function field. Whenever the method is called it will delegate to the +// requested funcion or if the funcion is nil return a NotImplemented error. +package mocks + +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" +) + +// GroupService is an implementation of the jujuapi.GroupService interface. +type GroupService struct { + AddGroup_ func(ctx context.Context, user *openfga.User, name string) (*dbmodel.GroupEntry, error) + CountGroups_ func(ctx context.Context, user *openfga.User) (int, error) + GetGroupByID_ func(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.GroupEntry, error) + ListGroups_ func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]dbmodel.GroupEntry, error) + RenameGroup_ func(ctx context.Context, user *openfga.User, oldName, newName string) error + RemoveGroup_ func(ctx context.Context, user *openfga.User, name string) error +} + +func (j *GroupService) AddGroup(ctx context.Context, u *openfga.User, name string) (*dbmodel.GroupEntry, error) { + if j.AddGroup_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.AddGroup_(ctx, u, name) +} + +func (j *GroupService) CountGroups(ctx context.Context, user *openfga.User) (int, error) { + if j.CountGroups_ == nil { + return 0, errors.E(errors.CodeNotImplemented) + } + return j.CountGroups_(ctx, user) +} + +func (j *GroupService) GetGroupByID(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.GroupEntry, error) { + if j.GetGroupByID_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.GetGroupByID_(ctx, user, uuid) +} + +func (j *GroupService) ListGroups(ctx context.Context, user *openfga.User, filters pagination.LimitOffsetPagination) ([]dbmodel.GroupEntry, error) { + if j.ListGroups_ == nil { + return nil, errors.E(errors.CodeNotImplemented) + } + return j.ListGroups_(ctx, user, filters) +} + +func (j *GroupService) RemoveGroup(ctx context.Context, user *openfga.User, name string) error { + if j.RemoveGroup_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.RemoveGroup_(ctx, user, name) +} + +func (j *GroupService) RenameGroup(ctx context.Context, user *openfga.User, oldName, newName string) error { + if j.RenameGroup_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.RenameGroup_(ctx, user, oldName, newName) +} diff --git a/internal/jimmtest/mocks/jimm_relation_mock.go b/internal/jimmtest/mocks/jimm_relation_mock.go new file mode 100644 index 000000000..06fb1652b --- /dev/null +++ b/internal/jimmtest/mocks/jimm_relation_mock.go @@ -0,0 +1,56 @@ +// 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" + apiparams "github.com/canonical/jimm/v3/pkg/api/params" +) + +// RelationService is an implementation of the jujuapi.RelationService interface. +type RelationService struct { + AddRelation_ func(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error + RemoveRelation_ func(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error + CheckRelation_ func(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) + ListRelationshipTuples_ func(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) + ListObjectRelations_ func(ctx context.Context, user *openfga.User, object string, pageSize int32, continuationToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) +} + +func (j *RelationService) AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { + if j.AddRelation_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.AddRelation_(ctx, user, tuples) +} + +func (j *RelationService) RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error { + if j.RemoveRelation_ == nil { + return errors.E(errors.CodeNotImplemented) + } + return j.RemoveRelation_(ctx, user, tuples) +} + +func (j *RelationService) CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) { + if j.CheckRelation_ == nil { + return false, errors.E(errors.CodeNotImplemented) + } + return j.CheckRelation_(ctx, user, tuple, trace) +} + +func (j *RelationService) ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) { + if j.ListRelationshipTuples_ == nil { + return []openfga.Tuple{}, "", errors.E(errors.CodeNotImplemented) + } + return j.ListRelationshipTuples_(ctx, user, tuple, pageSize, continuationToken) +} + +func (j *RelationService) ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { + if j.ListObjectRelations_ == nil { + return []openfga.Tuple{}, pagination.EntitlementToken{}, errors.E(errors.CodeNotImplemented) + } + return j.ListObjectRelations_(ctx, user, object, pageSize, entitlementToken) +} diff --git a/internal/jimmtest/mocks/login.go b/internal/jimmtest/mocks/login.go index 0fa6fdf88..48e54779f 100644 --- a/internal/jimmtest/mocks/login.go +++ b/internal/jimmtest/mocks/login.go @@ -3,6 +3,7 @@ package mocks import ( "context" + "net/http" "golang.org/x/oauth2" @@ -11,11 +12,19 @@ import ( ) type LoginService struct { - 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) + 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) +} + +func (j *LoginService) 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) { diff --git a/internal/jimmtest/mocks/model.go b/internal/jimmtest/mocks/model.go index 37b991873..0b0ff2536 100644 --- a/internal/jimmtest/mocks/model.go +++ b/internal/jimmtest/mocks/model.go @@ -30,7 +30,7 @@ type ModelManager struct { ModelDefaultsForCloud_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag) (jujuparams.ModelDefaultsResult, error) ModelInfo_ func(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelInfo, error) ModelStatus_ func(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelStatus, error) - QueryModelsJq_ func(ctx context.Context, models []dbmodel.Model, jqQuery string) (params.CrossModelQueryResponse, error) + QueryModelsJq_ func(ctx context.Context, models []string, jqQuery string) (params.CrossModelQueryResponse, error) SetModelDefaults_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, configs map[string]interface{}) error UnsetModelDefaults_ func(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, keys []string) error UpdateMigratedModel_ func(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetControllerName string) error @@ -120,7 +120,7 @@ func (j *ModelManager) ModelStatus(ctx context.Context, u *openfga.User, mt name return j.ModelStatus_(ctx, u, mt) } -func (j *ModelManager) QueryModelsJq(ctx context.Context, models []dbmodel.Model, jqQuery string) (params.CrossModelQueryResponse, error) { +func (j *ModelManager) QueryModelsJq(ctx context.Context, models []string, jqQuery string) (params.CrossModelQueryResponse, error) { if j.QueryModelsJq_ == nil { return params.CrossModelQueryResponse{}, errors.E(errors.CodeNotImplemented) } diff --git a/internal/jimmtest/openfga.go b/internal/jimmtest/openfga.go index cefc262d5..a2ddd5d0a 100644 --- a/internal/jimmtest/openfga.go +++ b/internal/jimmtest/openfga.go @@ -37,7 +37,7 @@ type testSetup struct { func getAuthModelDefinition() (*sdk.AuthorizationModel, error) { authModel := sdk.AuthorizationModel{} - err := json.Unmarshal(auth_model.AuthModelFile, &authModel) + err := json.Unmarshal(auth_model.AuthModelJSON, &authModel) if err != nil { return nil, err } diff --git a/internal/jimmtest/suite.go b/internal/jimmtest/suite.go index b57a50016..925acd114 100644 --- a/internal/jimmtest/suite.go +++ b/internal/jimmtest/suite.go @@ -30,6 +30,7 @@ import ( ofganames "github.com/canonical/jimm/v3/internal/openfga/names" "github.com/canonical/jimm/v3/internal/pubsub" "github.com/canonical/jimm/v3/internal/wellknownapi" + jimmnames "github.com/canonical/jimm/v3/pkg/names" ) // ControllerUUID is the UUID of the JIMM controller used in tests. @@ -56,7 +57,7 @@ type JIMMSuite struct { // Authenticator configured. JIMM *jimm.JIMM - AdminUser *dbmodel.Identity + AdminUser *openfga.User OFGAClient *openfga.OFGAClient COFGAClient *cofga.Client COFGAParams *cofga.OpenFGAParams @@ -99,13 +100,13 @@ func (s *JIMMSuite) SetUpTest(c *gc.C) { alice, err := dbmodel.NewIdentity("alice@canonical.com") c.Assert(err, gc.IsNil) alice.LastLogin = db.Now() - s.AdminUser = alice - err = s.JIMM.Database.GetIdentity(ctx, s.AdminUser) + err = s.JIMM.Database.GetIdentity(ctx, alice) c.Assert(err, gc.Equals, nil) - adminUser := openfga.NewUser(s.AdminUser, s.OFGAClient) - err = adminUser.SetControllerAccess(ctx, s.JIMM.ResourceTag(), ofganames.AdministratorRelation) + s.AdminUser = openfga.NewUser(alice, s.OFGAClient) + s.AdminUser.JimmAdmin = true + err = s.AdminUser.SetControllerAccess(ctx, s.JIMM.ResourceTag(), ofganames.AdministratorRelation) c.Assert(err, gc.Equals, nil) // add jimmtest.DefaultControllerUUID as a controller to JIMM @@ -192,6 +193,14 @@ func (s *JIMMSuite) AddAdminUser(c *gc.C, email string) { c.Assert(err, gc.IsNil) } +func (s *JIMMSuite) AddUser(c *gc.C, email string) { + identity, err := dbmodel.NewIdentity(email) + c.Assert(err, gc.IsNil) + + err = s.JIMM.Database.GetIdentity(context.Background(), identity) + c.Assert(err, gc.IsNil) +} + func (s *JIMMSuite) NewUser(u *dbmodel.Identity) *openfga.User { return openfga.NewUser(u, s.OFGAClient) } @@ -214,9 +223,7 @@ func (s *JIMMSuite) AddController(c *gc.C, name string, info *api.Info) { Port: hp.Port(), }}) } - adminUser := s.NewUser(s.AdminUser) - adminUser.JimmAdmin = true - err := s.JIMM.AddController(context.Background(), adminUser, ctl) + err := s.JIMM.AddController(context.Background(), s.AdminUser, ctl) c.Assert(err, gc.Equals, nil) } @@ -260,6 +267,13 @@ func (s *JIMMSuite) AddModel(c *gc.C, owner names.UserTag, name string, cloud na return names.NewModelTag(mi.UUID) } +func (s *JIMMSuite) AddGroup(c *gc.C, groupName string) jimmnames.GroupTag { + ctx := context.Background() + group, err := s.JIMM.AddGroup(ctx, s.AdminUser, groupName) + c.Assert(err, gc.Equals, nil) + return group.ResourceTag() +} + // EnableDeviceFlow allows a test to use the device flow. // Call this non-blocking function before login to ensure the device flow won't block. // diff --git a/internal/jujuapi/access_control.go b/internal/jujuapi/access_control.go index 403359cc4..b4de5f247 100644 --- a/internal/jujuapi/access_control.go +++ b/internal/jujuapi/access_control.go @@ -4,7 +4,6 @@ package jujuapi import ( "context" - "fmt" "strconv" "time" @@ -12,9 +11,10 @@ import ( "github.com/juju/zaputil/zapctx" "go.uber.org/zap" + "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" - ofganames "github.com/canonical/jimm/v3/internal/openfga/names" apiparams "github.com/canonical/jimm/v3/pkg/api/params" jimmnames "github.com/canonical/jimm/v3/pkg/names" ) @@ -25,6 +25,15 @@ const ( jimmControllerName = "jimm" ) +type GroupService interface { + AddGroup(ctx context.Context, user *openfga.User, name string) (*dbmodel.GroupEntry, error) + CountGroups(ctx context.Context, user *openfga.User) (int, error) + GetGroupByID(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.GroupEntry, error) + ListGroups(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]dbmodel.GroupEntry, error) + RenameGroup(ctx context.Context, user *openfga.User, oldName, newName string) error + RemoveGroup(ctx context.Context, user *openfga.User, name string) error +} + // AddGroup creates a group within JIMMs DB for reference by OpenFGA. func (r *controllerRoot) AddGroup(ctx context.Context, req apiparams.AddGroupRequest) (apiparams.AddGroupResponse, error) { const op = errors.Op("jujuapi.AddGroup") @@ -76,10 +85,11 @@ func (r *controllerRoot) RemoveGroup(ctx context.Context, req apiparams.RemoveGr } // ListGroup lists relational access control groups within JIMMs DB. -func (r *controllerRoot) ListGroups(ctx context.Context) (apiparams.ListGroupResponse, error) { +func (r *controllerRoot) ListGroups(ctx context.Context, req apiparams.ListGroupsRequest) (apiparams.ListGroupResponse, error) { const op = errors.Op("jujuapi.ListGroups") - groups, err := r.jimm.ListGroups(ctx, r.user) + filter := pagination.NewOffsetFilter(req.Limit, req.Offset) + groups, err := r.jimm.ListGroups(ctx, r.user, filter) if err != nil { return apiparams.ListGroupResponse{}, errors.E(op, err) } @@ -101,17 +111,9 @@ func (r *controllerRoot) ListGroups(ctx context.Context) (apiparams.ListGroupRes func (r *controllerRoot) AddRelation(ctx context.Context, req apiparams.AddRelationRequest) error { const op = errors.Op("jujuapi.AddRelation") - if !r.user.JimmAdmin { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - keys, err := r.parseTuples(ctx, req.Tuples) - if err != nil { - return errors.E(err) - } - err = r.jimm.AuthorizationClient().AddRelation(ctx, keys...) - if err != nil { - zapctx.Error(ctx, "failed to add tuple(s)", zap.NamedError("add-relation-error", err)) - return errors.E(op, errors.CodeOpenFGARequestFailed, err) + if err := r.jimm.AddRelation(ctx, r.user, req.Tuples); err != nil { + zapctx.Error(ctx, "failed to add relation", zaputil.Error(err)) + return errors.E(op, err) } return nil } @@ -121,14 +123,7 @@ func (r *controllerRoot) AddRelation(ctx context.Context, req apiparams.AddRelat func (r *controllerRoot) RemoveRelation(ctx context.Context, req apiparams.RemoveRelationRequest) error { const op = errors.Op("jujuapi.RemoveRelation") - if !r.user.JimmAdmin { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - keys, err := r.parseTuples(ctx, req.Tuples) - if err != nil { - return errors.E(op, err) - } - err = r.jimm.AuthorizationClient().RemoveRelation(ctx, keys...) + err := r.jimm.RemoveRelation(ctx, r.user, req.Tuples) if err != nil { zapctx.Error(ctx, "failed to delete tuple(s)", zap.NamedError("remove-relation-error", err)) return errors.E(op, err) @@ -143,103 +138,21 @@ func (r *controllerRoot) CheckRelation(ctx context.Context, req apiparams.CheckR const op = errors.Op("jujuapi.CheckRelation") checkResp := apiparams.CheckRelationResponse{Allowed: false} - parsedTuple, err := r.parseTuple(ctx, req.Tuple) - if err != nil { - return checkResp, errors.E(op, errors.CodeFailedToParseTupleKey, err) - } - - userCheckingSelf := parsedTuple.Object.Kind == openfga.UserType && parsedTuple.Object.ID == r.user.Name - // Admins can check any relation, non-admins can only check their own. - if !(r.user.JimmAdmin || userCheckingSelf) { - return checkResp, errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - allowed, err := r.jimm.AuthorizationClient().CheckRelation(ctx, *parsedTuple, false) + allowed, err := r.jimm.CheckRelation(ctx, r.user, req.Tuple, false) if err != nil { zapctx.Error(ctx, "failed to check relation", zap.NamedError("check-relation-error", err)) - return checkResp, errors.E(op, errors.CodeOpenFGARequestFailed, err) - } - if allowed { - checkResp.Allowed = allowed + return checkResp, errors.E(op, err) } + checkResp.Allowed = allowed zapctx.Debug(ctx, "check request", zap.String("allowed", strconv.FormatBool(allowed))) return checkResp, nil } -// parseTuples translate the api request struct containing tuples to a slice of openfga tuple keys. -// This method utilises the parseTuple method which does all the heavy lifting. -func (r *controllerRoot) parseTuples(ctx context.Context, tuples []apiparams.RelationshipTuple) ([]openfga.Tuple, error) { - keys := make([]openfga.Tuple, 0, len(tuples)) - for _, tuple := range tuples { - key, err := r.parseTuple(ctx, tuple) - if err != nil { - return nil, errors.E(err) - } - keys = append(keys, *key) - } - return keys, nil -} - -// parseTuple takes the initial tuple from a relational request and ensures that -// whatever format, be it JAAS or Juju tag, is resolved to the correct identifier -// to be persisted within OpenFGA. -func (r *controllerRoot) parseTuple(ctx context.Context, tuple apiparams.RelationshipTuple) (*openfga.Tuple, error) { - const op = errors.Op("jujuapi.parseTuple") - - relation, err := ofganames.ParseRelation(tuple.Relation) - if err != nil { - return nil, errors.E(op, err, errors.CodeBadRequest) - } - t := openfga.Tuple{ - Relation: relation, - } - - // Wraps the general error that will be sent for both - // the object and target object, but changing the message and key - // to be specific to the erroneous offender. - parseTagError := func(msg string, key string, err error) error { - zapctx.Debug(ctx, msg, zap.String("key", key), zap.Error(err)) - return errors.E(op, errors.CodeFailedToParseTupleKey, fmt.Errorf("%s, key %s: %w", msg, key, err)) - } - - if tuple.TargetObject == "" { - return nil, errors.E(op, errors.CodeBadRequest, "target object not specified") - } - if tuple.TargetObject != "" { - targetTag, err := r.jimm.ParseTag(ctx, tuple.TargetObject) - if err != nil { - return nil, parseTagError("failed to parse tuple target", tuple.TargetObject, err) - } - t.Target = targetTag - } - if tuple.Object != "" { - objectTag, err := r.jimm.ParseTag(ctx, tuple.Object) - if err != nil { - return nil, parseTagError("failed to parse tuple object", tuple.Object, err) - } - t.Object = objectTag - } - - return &t, nil -} - // ListRelationshipTuples returns a list of tuples matching the specified filter. func (r *controllerRoot) ListRelationshipTuples(ctx context.Context, req apiparams.ListRelationshipTuplesRequest) (apiparams.ListRelationshipTuplesResponse, error) { const op = errors.Op("jujuapi.ListRelationshipTuples") - if !r.user.JimmAdmin { - return apiparams.ListRelationshipTuplesResponse{}, errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - key := &openfga.Tuple{} - var err error - if req.Tuple.TargetObject != "" { - key, err = r.parseTuple(ctx, req.Tuple) - if err != nil { - return apiparams.ListRelationshipTuplesResponse{}, errors.E(op, err) - } - } - responseTuples, ct, err := r.jimm.AuthorizationClient().ReadRelatedObjects(ctx, *key, req.PageSize, req.ContinuationToken) + responseTuples, ct, err := r.jimm.ListRelationshipTuples(ctx, r.user, req.Tuple, req.PageSize, req.ContinuationToken) if err != nil { return apiparams.ListRelationshipTuplesResponse{}, errors.E(op, err) } diff --git a/internal/jujuapi/access_control_test.go b/internal/jujuapi/access_control_test.go index 533d89dcf..e1230590b 100644 --- a/internal/jujuapi/access_control_test.go +++ b/internal/jujuapi/access_control_test.go @@ -197,10 +197,12 @@ func (s *accessControlSuite) TestListGroups(c *gc.C) { _, err := client.AddGroup(&apiparams.AddGroupRequest{Name: name}) c.Assert(err, jc.ErrorIsNil) } - - groups, err := client.ListGroups() + req := apiparams.ListGroupsRequest{Limit: 10, Offset: 0} + groups, err := client.ListGroups(&req) c.Assert(err, jc.ErrorIsNil) c.Assert(groups, gc.HasLen, 4) + // Verify the UUID is not empty. + c.Assert(groups[0].UUID, gc.Not(gc.Equals), "") // groups should be returned in ascending order of name c.Assert(groups[0].Name, gc.Equals, "aaaFinalGroup") c.Assert(groups[1].Name, gc.Equals, "test-group0") @@ -943,7 +945,7 @@ func (s *accessControlSuite) TestListRelationshipTuples(c *gc.C) { }, ResolveUUIDs: true, }) - c.Assert(err, gc.ErrorMatches, "failed to parse tuple target, key applicationoffer-fake-offer: application offer not found.*") + c.Assert(err, gc.ErrorMatches, "failed to parse tuple target object key applicationoffer-fake-offer: application offer not found.*") } func (s *accessControlSuite) TestListRelationshipTuplesNoUUIDResolution(c *gc.C) { @@ -1055,9 +1057,9 @@ func (s *accessControlSuite) TestListRelationshipTuplesWithMissingGroups(c *gc.C // Delete a group without going through the API. group := &dbmodel.GroupEntry{Name: "yellow"} - err = s.JIMM.DB().GetGroup(ctx, group) + err = s.JIMM.Database.GetGroup(ctx, group) c.Assert(err, jc.ErrorIsNil) - err = s.JIMM.DB().RemoveGroup(ctx, group) + err = s.JIMM.Database.RemoveGroup(ctx, group) c.Assert(err, jc.ErrorIsNil) response, err := client.ListRelationshipTuples(&apiparams.ListRelationshipTuplesRequest{ResolveUUIDs: true}) diff --git a/internal/jujuapi/admin.go b/internal/jujuapi/admin.go index 246feb3bb..dd9be25ab 100644 --- a/internal/jujuapi/admin.go +++ b/internal/jujuapi/admin.go @@ -4,6 +4,7 @@ package jujuapi import ( "context" + "net/http" "sort" "github.com/juju/juju/rpc" @@ -18,6 +19,8 @@ import ( // LoginService defines the set of methods used for login to JIMM. type LoginService interface { + // AuthenticateBrowserSession authenticates a session cookie is valid. + AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) // LoginDevice is step 1 in the device flow and returns the OIDC server that the client should use for login. LoginDevice(ctx context.Context) (*oauth2.DeviceAuthResponse, error) // GetDeviceSessionToken polls the OIDC server waiting for the client to login and return a user scoped session token. diff --git a/internal/jujuapi/admin_test.go b/internal/jujuapi/admin_test.go index 94e2db196..3c4fb80e3 100644 --- a/internal/jujuapi/admin_test.go +++ b/internal/jujuapi/admin_test.go @@ -62,6 +62,7 @@ func (s *adminSuite) SetUpTest(c *gc.C) { SessionStore: sessionStore, SessionCookieMaxAge: 60, JWTSessionKey: "test-secret", + SecureCookies: false, }) c.Assert(err, gc.Equals, nil) s.JIMM.OAuthAuthenticator = authSvc @@ -115,7 +116,7 @@ func (s *adminSuite) TestBrowserLoginWithUnsafeEmail(c *gc.C) { func testBrowserLogin(c *gc.C, s *adminSuite, username, password, expectedEmail, expectedDisplayName string) { // The setup runs a browser login with callback, ultimately retrieving // a logged in user by cookie. - sqldb, err := s.JIMM.DB().DB.DB() + sqldb, err := s.JIMM.Database.DB.DB() c.Assert(err, gc.IsNil) sessionStore, err := pgstore.NewPGStoreFromPool(sqldb, []byte("secretsecretdigletts")) @@ -123,7 +124,7 @@ func testBrowserLogin(c *gc.C, s *adminSuite, username, password, expectedEmail, defer sessionStore.Close() cookie, err := jimmtest.RunBrowserLogin( - s.JIMM.DB(), + &s.JIMM.Database, sessionStore, username, password, @@ -273,7 +274,7 @@ func (s *adminSuite) TestDeviceLogin(c *gc.C) { updatedUser, err := dbmodel.NewIdentity(user.Email) c.Assert(err, gc.IsNil) - c.Assert(s.JIMM.DB().GetIdentity(context.Background(), updatedUser), gc.IsNil) + c.Assert(s.JIMM.Database.GetIdentity(context.Background(), updatedUser), gc.IsNil) // TODO(ale8k): Do we need to validate the token again for the test? // It has just been through a verifier etc and was returned directly // from the device grant? diff --git a/internal/jujuapi/controller.go b/internal/jujuapi/controller.go index 2b3b329ad..2549beaa5 100644 --- a/internal/jujuapi/controller.go +++ b/internal/jujuapi/controller.go @@ -8,6 +8,7 @@ import ( jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" + "github.com/juju/version" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" @@ -50,6 +51,18 @@ func init() { } } +// ControllerService defines the methods used to manage controllers. +type ControllerService interface { + AddController(ctx context.Context, user *openfga.User, ctl *dbmodel.Controller) error + ControllerInfo(ctx context.Context, name string) (*dbmodel.Controller, error) + EarliestControllerVersion(ctx context.Context) (version.Number, error) + ListControllers(ctx context.Context, user *openfga.User) ([]dbmodel.Controller, error) + GetControllerConfig(ctx context.Context, user *dbmodel.Identity) (*dbmodel.ControllerConfig, error) + SetControllerConfig(ctx context.Context, user *openfga.User, args jujuparams.ControllerConfigSet) error + RemoveController(ctx context.Context, user *openfga.User, controllerName string, force bool) error + SetControllerDeprecated(ctx context.Context, user *openfga.User, controllerName string, deprecated bool) error +} + // ConfigSet changes the value of specified controller configuration // settings. Only some settings can be changed after bootstrap. // JIMM does not support changing settings via ConfigSet. diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index f682edf9a..9406570b6 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -11,10 +11,10 @@ import ( "github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/juju/version" "github.com/rogpeppe/fastuuid" "golang.org/x/oauth2" + "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" @@ -28,19 +28,18 @@ import ( ) type JIMM interface { + GroupService + 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 - AddController(ctx context.Context, u *openfga.User, ctl *dbmodel.Controller) error AddHostedCloud(ctx context.Context, user *openfga.User, tag names.CloudTag, cloud jujuparams.Cloud, force bool) error - AddGroup(ctx context.Context, user *openfga.User, name string) (*dbmodel.GroupEntry, error) AddServiceAccount(ctx context.Context, u *openfga.User, clientId string) error - AuthorizationClient() *openfga.OFGAClient CopyServiceAccountCredential(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) - DB() *db.Database + CountIdentities(ctx context.Context, user *openfga.User) (int, error) DestroyOffer(ctx context.Context, user *openfga.User, offerURL string, force bool) error - EarliestControllerVersion(ctx context.Context) (version.Number, 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) ForEachCloud(ctx context.Context, user *openfga.User, f func(*dbmodel.Cloud) error) error @@ -51,9 +50,10 @@ type JIMM interface { GetCloud(ctx context.Context, u *openfga.User, tag names.CloudTag) (dbmodel.Cloud, error) GetCloudCredential(ctx context.Context, user *openfga.User, tag names.CloudCredentialTag) (*dbmodel.CloudCredential, error) GetCloudCredentialAttributes(ctx context.Context, u *openfga.User, cred *dbmodel.CloudCredential, hidden bool) (attrs map[string]string, redacted []string, err error) - GetControllerConfig(ctx context.Context, u *dbmodel.Identity) (*dbmodel.ControllerConfig, error) GetCredentialStore() credentials.CredentialStore 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) @@ -65,24 +65,20 @@ type JIMM interface { InitiateInternalMigration(ctx context.Context, user *openfga.User, modelTag names.ModelTag, 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) - ListGroups(ctx context.Context, user *openfga.User) ([]dbmodel.GroupEntry, error) + ListIdentities(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) + ListResources(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]db.Resource, error) Offer(ctx context.Context, user *openfga.User, offer jimm.AddApplicationOfferParams) error - ParseTag(ctx context.Context, key string) (*ofganames.Tag, error) PubSubHub() *pubsub.Hub PurgeLogs(ctx context.Context, user *openfga.User, before time.Time) (int64, error) - RenameGroup(ctx context.Context, user *openfga.User, oldName, newName string) error RemoveCloud(ctx context.Context, u *openfga.User, ct names.CloudTag) error RemoveCloudFromController(ctx context.Context, u *openfga.User, controllerName string, ct names.CloudTag) error RemoveController(ctx context.Context, user *openfga.User, controllerName string, force bool) error - RemoveGroup(ctx context.Context, user *openfga.User, name string) error ResourceTag() names.ControllerTag RevokeAuditLogAccess(ctx context.Context, user *openfga.User, targetUserTag names.UserTag) error RevokeCloudAccess(ctx context.Context, user *openfga.User, ct names.CloudTag, ut names.UserTag, access string) error RevokeCloudCredential(ctx context.Context, user *dbmodel.Identity, tag names.CloudCredentialTag, force bool) error RevokeModelAccess(ctx context.Context, user *openfga.User, mt names.ModelTag, ut names.UserTag, access jujuparams.UserAccessPermission) error RevokeOfferAccess(ctx context.Context, user *openfga.User, offerURL string, ut names.UserTag, access jujuparams.OfferAccessPermission) (err error) - SetControllerConfig(ctx context.Context, u *openfga.User, args jujuparams.ControllerConfigSet) error - SetControllerDeprecated(ctx context.Context, user *openfga.User, controllerName string, deprecated bool) error ToJAASTag(ctx context.Context, tag *ofganames.Tag, resolveUUIDs bool) (string, error) UpdateApplicationOffer(ctx context.Context, controller *dbmodel.Controller, offerUUID string, removed bool) error UpdateCloud(ctx context.Context, u *openfga.User, ct names.CloudTag, cloud jujuparams.Cloud) error diff --git a/internal/jujuapi/jimm.go b/internal/jujuapi/jimm.go index ce029c6ba..39768247d 100644 --- a/internal/jujuapi/jimm.go +++ b/internal/jujuapi/jimm.go @@ -20,6 +20,7 @@ import ( "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jujuapi/rpc" ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/pkg/api/params" apiparams "github.com/canonical/jimm/v3/pkg/api/params" ) @@ -206,6 +207,8 @@ func (r *controllerRoot) AddController(ctx context.Context, req apiparams.AddCon // ListControllers returns the list of juju controllers hosting models // as part of this JAAS system. +// If the user is not an admin, they will only receive information about +// JIMM itself - note that the controller name returned is "jaas". func (r *controllerRoot) ListControllers(ctx context.Context) (apiparams.ListControllersResponse, error) { const op = errors.Op("jujuapi.ListControllersV3") @@ -216,29 +219,28 @@ func (r *controllerRoot) ListControllers(ctx context.Context) (apiparams.ListCon if err != nil { return apiparams.ListControllersResponse{}, errors.E(op, err) } - return apiparams.ListControllersResponse{ - Controllers: []apiparams.ControllerInfo{{ - Name: "jaas", // TODO(mhilton) make this configurable. - UUID: r.params.ControllerUUID, - // TODO(mhilton)enable setting the public address. - AgentVersion: srvVersion.String(), - Status: jujuparams.EntityStatus{ - Status: "available", - }, - }}, - }, nil - } - - var controllers []apiparams.ControllerInfo - err := r.jimm.DB().ForEachController(ctx, func(ctl *dbmodel.Controller) error { - controllers = append(controllers, ctl.ToAPIControllerInfo()) - return nil - }) + jimmCtl := params.ControllerInfo{ + Name: "jaas", + UUID: r.params.ControllerUUID, + // TODO(mhilton)enable setting the public address. + AgentVersion: srvVersion.String(), + Status: jujuparams.EntityStatus{ + Status: "available", + }, + } + controllers := []apiparams.ControllerInfo{jimmCtl} + return apiparams.ListControllersResponse{Controllers: controllers}, nil + } + dbControllers, err := r.jimm.ListControllers(ctx, r.user) if err != nil { return apiparams.ListControllersResponse{}, errors.E(op, err) } + controllersInfo := make([]apiparams.ControllerInfo, 0, len(dbControllers)) + for _, ctl := range dbControllers { + controllersInfo = append(controllersInfo, ctl.ToAPIControllerInfo()) + } return apiparams.ListControllersResponse{ - Controllers: controllers, + Controllers: controllersInfo, }, nil } @@ -246,10 +248,8 @@ func (r *controllerRoot) ListControllers(ctx context.Context) (apiparams.ListCon func (r *controllerRoot) RemoveController(ctx context.Context, req apiparams.RemoveControllerRequest) (apiparams.ControllerInfo, error) { const op = errors.Op("jujuapi.RemoveController") - ctl := dbmodel.Controller{ - Name: req.Name, - } - if err := r.jimm.DB().GetController(ctx, &ctl); err != nil { + ctl, err := r.jimm.ControllerInfo(ctx, req.Name) + if err != nil { return apiparams.ControllerInfo{}, errors.E(op, err) } @@ -266,10 +266,8 @@ func (r *controllerRoot) SetControllerDeprecated(ctx context.Context, req apipar if err := r.jimm.SetControllerDeprecated(ctx, r.user, req.Name, req.Deprecated); err != nil { return apiparams.ControllerInfo{}, errors.E(op, err) } - ctl := dbmodel.Controller{ - Name: req.Name, - } - if err := r.jimm.DB().GetController(ctx, &ctl); err != nil { + ctl, err := r.jimm.ControllerInfo(ctx, req.Name) + if err != nil { return apiparams.ControllerInfo{}, errors.E(op, err) } return ctl.ToAPIControllerInfo(), nil @@ -457,14 +455,10 @@ func (r *controllerRoot) CrossModelQuery(ctx context.Context, req apiparams.Cros if err != nil { return apiparams.CrossModelQueryResponse{}, errors.E(op, errors.Code("failed to list user's model access")) } - models, err := r.jimm.DB().GetModelsByUUID(ctx, modelUUIDs) - if err != nil { - return apiparams.CrossModelQueryResponse{}, errors.E(op, errors.Code("failed to get models for user")) - } switch strings.TrimSpace(strings.ToLower(req.Type)) { case "jq": - return r.jimm.QueryModelsJq(ctx, models, req.Query) + return r.jimm.QueryModelsJq(ctx, modelUUIDs, req.Query) case "jimmsql": return apiparams.CrossModelQueryResponse{}, errors.E(op, errors.CodeNotImplemented) default: diff --git a/internal/jujuapi/jimm_relation.go b/internal/jujuapi/jimm_relation.go new file mode 100644 index 000000000..2e54225d6 --- /dev/null +++ b/internal/jujuapi/jimm_relation.go @@ -0,0 +1,20 @@ +// Copyright 2024 Canonical. + +package jujuapi + +import ( + "context" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/openfga" + apiparams "github.com/canonical/jimm/v3/pkg/api/params" +) + +// RelationService defines an interface used to manage relations in the authorization model. +type RelationService interface { + AddRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error + RemoveRelation(ctx context.Context, user *openfga.User, tuples []apiparams.RelationshipTuple) error + CheckRelation(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, trace bool) (_ bool, err error) + ListRelationshipTuples(ctx context.Context, user *openfga.User, tuple apiparams.RelationshipTuple, pageSize int32, continuationToken string) ([]openfga.Tuple, string, error) + ListObjectRelations(ctx context.Context, user *openfga.User, object string, pageSize int32, entitlementToken pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) +} diff --git a/internal/jujuapi/jimm_test.go b/internal/jujuapi/jimm_test.go index 370fa9f03..140c97705 100644 --- a/internal/jujuapi/jimm_test.go +++ b/internal/jujuapi/jimm_test.go @@ -91,7 +91,7 @@ func (s *jimmSuite) TestListControllersUnauthorized(c *gc.C) { c.Assert(err, gc.Equals, nil) c.Check(cis, jc.DeepEquals, []apiparams.ControllerInfo{{ Name: "jaas", - UUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + UUID: jimmtest.ControllerUUID, AgentVersion: s.Model.Controller.AgentVersion, Status: jujuparams.EntityStatus{ Status: "available", diff --git a/internal/jujuapi/modelmanager.go b/internal/jujuapi/modelmanager.go index fec14a500..cc0e67f82 100644 --- a/internal/jujuapi/modelmanager.go +++ b/internal/jujuapi/modelmanager.go @@ -70,7 +70,7 @@ type ModelManager interface { ModelDefaultsForCloud(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag) (jujuparams.ModelDefaultsResult, error) ModelInfo(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelInfo, error) ModelStatus(ctx context.Context, u *openfga.User, mt names.ModelTag) (*jujuparams.ModelStatus, error) - QueryModelsJq(ctx context.Context, models []dbmodel.Model, jqQuery string) (params.CrossModelQueryResponse, error) + QueryModelsJq(ctx context.Context, models []string, jqQuery string) (params.CrossModelQueryResponse, error) SetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, configs map[string]interface{}) error UnsetModelDefaults(ctx context.Context, user *dbmodel.Identity, cloudTag names.CloudTag, region string, keys []string) error UpdateMigratedModel(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetControllerName string) error diff --git a/internal/jujuapi/modelmanager_test.go b/internal/jujuapi/modelmanager_test.go index 402489578..31162d17e 100644 --- a/internal/jujuapi/modelmanager_test.go +++ b/internal/jujuapi/modelmanager_test.go @@ -64,7 +64,7 @@ func (s *modelManagerSuite) TestListModelSummaries(c *gc.C) { ), []base.UserModelSummary{{ Name: "model-1", UUID: s.Model.UUID.String, - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: jimmtest.TestProviderType, DefaultSeries: "jammy", Cloud: jimmtest.TestCloudName, @@ -95,7 +95,7 @@ func (s *modelManagerSuite) TestListModelSummaries(c *gc.C) { }, { Name: "model-3", UUID: s.Model3.UUID.String, - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: jimmtest.TestProviderType, DefaultSeries: "jammy", Cloud: jimmtest.TestCloudName, @@ -330,7 +330,7 @@ func (s *modelManagerSuite) TestModelInfo(c *gc.C) { Name: "model-1", UUID: s.Model.UUID.String, DefaultBase: "ubuntu@22.04/stable", - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: jimmtest.TestProviderType, CloudTag: names.NewCloudTag(jimmtest.TestCloudName).String(), CloudRegion: jimmtest.TestCloudRegionName, @@ -369,7 +369,7 @@ func (s *modelManagerSuite) TestModelInfo(c *gc.C) { Name: "model-3", UUID: s.Model3.UUID.String, DefaultBase: "ubuntu@22.04/stable", - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: jimmtest.TestProviderType, CloudTag: names.NewCloudTag(jimmtest.TestCloudName).String(), CloudRegion: jimmtest.TestCloudRegionName, @@ -400,7 +400,7 @@ func (s *modelManagerSuite) TestModelInfo(c *gc.C) { Name: "model-4", UUID: mt4.Id(), DefaultBase: "ubuntu@22.04/stable", - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: jimmtest.TestProviderType, CloudTag: names.NewCloudTag(jimmtest.TestCloudName).String(), CloudRegion: jimmtest.TestCloudRegionName, @@ -452,7 +452,7 @@ func (s *modelManagerSuite) TestModelInfo(c *gc.C) { Name: "model-5", UUID: mt5.Id(), DefaultBase: "ubuntu@22.04/stable", - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: jimmtest.TestProviderType, CloudTag: names.NewCloudTag(jimmtest.TestCloudName).String(), CloudRegion: jimmtest.TestCloudRegionName, @@ -896,7 +896,7 @@ func (s *modelManagerSuite) TestCreateModel(c *gc.C) { c.Assert(mi.Name, gc.Equals, test.name) c.Assert(mi.UUID, gc.Not(gc.Equals), "") c.Assert(mi.OwnerTag, gc.Equals, test.ownerTag) - c.Assert(mi.ControllerUUID, gc.Equals, "914487b5-60e7-42bb-bd63-1adc3fd3a388") + c.Assert(mi.ControllerUUID, gc.Equals, jimmtest.ControllerUUID) c.Assert(mi.Users, gc.Not(gc.HasLen), 0) if test.credentialTag == "" { c.Assert(mi.CloudCredentialTag, gc.Not(gc.Equals), "") @@ -1413,7 +1413,7 @@ func (s *caasModelManagerSuite) TestListCAASModelSummaries(c *gc.C) { ), []base.UserModelSummary{{ Name: "k8s-model-1", UUID: mi.UUID, - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: "kubernetes", DefaultSeries: "jammy", Cloud: "bob-cloud", @@ -1445,7 +1445,7 @@ func (s *caasModelManagerSuite) TestListCAASModelSummaries(c *gc.C) { Name: "model-1", UUID: s.Model.UUID.String, Type: "iaas", - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: jimmtest.TestProviderType, DefaultSeries: "jammy", Cloud: jimmtest.TestCloudName, @@ -1467,7 +1467,7 @@ func (s *caasModelManagerSuite) TestListCAASModelSummaries(c *gc.C) { Name: "model-3", UUID: s.Model3.UUID.String, Type: "iaas", - ControllerUUID: "914487b5-60e7-42bb-bd63-1adc3fd3a388", + ControllerUUID: jimmtest.ControllerUUID, ProviderType: jimmtest.TestProviderType, DefaultSeries: "jammy", Cloud: jimmtest.TestCloudName, diff --git a/internal/jujuapi/service_account.go b/internal/jujuapi/service_account.go index 7f4c18316..2e0b5dc85 100644 --- a/internal/jujuapi/service_account.go +++ b/internal/jujuapi/service_account.go @@ -9,7 +9,6 @@ import ( jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" - "github.com/canonical/jimm/v3/internal/dbmodel" "github.com/canonical/jimm/v3/internal/errors" "github.com/canonical/jimm/v3/internal/jimm" "github.com/canonical/jimm/v3/internal/openfga" @@ -73,12 +72,7 @@ func (r *controllerRoot) getServiceAccount(ctx context.Context, clientID string) return nil, errors.E(errors.CodeUnauthorized, "unauthorized") } - var targetIdentityModel dbmodel.Identity - targetIdentityModel.SetTag(names.NewUserTag(clientIdWithDomain)) - if err := r.jimm.DB().GetIdentity(ctx, &targetIdentityModel); err != nil { - return nil, errors.E(err) - } - return openfga.NewUser(&targetIdentityModel, r.jimm.AuthorizationClient()), nil + return r.jimm.UserLogin(ctx, clientIdWithDomain) } // UpdateServiceAccountCredentialsCheckModels updates a set of cloud credentials' content. diff --git a/internal/jujuapi/service_account_test.go b/internal/jujuapi/service_account_test.go index b6399b9ae..b6d14a222 100644 --- a/internal/jujuapi/service_account_test.go +++ b/internal/jujuapi/service_account_test.go @@ -167,8 +167,6 @@ func TestCopyServiceAccountCredential(t *testing.T) { clientIdWithDomain, err := jimmnames.EnsureValidServiceAccountId(test.args.ClientID) c.Assert(err, qt.IsNil) jimm := &jimmtest.JIMM{ - AuthorizationClient_: func() *openfga.OFGAClient { return ofgaClient }, - DB_: func() *db.Database { return &pgDb }, CopyServiceAccountCredential_: func(ctx context.Context, u, svcAcc *openfga.User, cloudCredentialTag names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error) { c.Assert(cloudCredentialTag.Cloud().Id(), qt.Equals, test.args.CloudCredentialArg.CloudName) c.Assert(cloudCredentialTag.Owner().Id(), qt.Equals, u.Name) @@ -177,6 +175,11 @@ func TestCopyServiceAccountCredential(t *testing.T) { newCredTag := names.NewCloudCredentialTag(fmt.Sprintf("%s/%s/%s", test.args.CloudName, svcAcc.Name, test.args.CredentialName)) return newCredTag, nil, nil }, + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { + var u dbmodel.Identity + u.SetTag(names.NewUserTag(email)) + return openfga.NewUser(&u, ofgaClient), nil + }, } var u dbmodel.Identity u.SetTag(names.NewUserTag(test.username)) @@ -256,8 +259,11 @@ func TestGetServiceAccount(t *testing.T) { err = pgDb.Migrate(context.Background(), false) c.Assert(err, qt.IsNil) jimm := &jimmtest.JIMM{ - AuthorizationClient_: func() *openfga.OFGAClient { return ofgaClient }, - DB_: func() *db.Database { return &pgDb }, + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { + var u dbmodel.Identity + u.SetTag(names.NewUserTag(email)) + return openfga.NewUser(&u, ofgaClient), nil + }, } var u dbmodel.Identity u.SetTag(names.NewUserTag(test.username)) @@ -446,9 +452,8 @@ func TestUpdateServiceAccountCredentials(t *testing.T) { err = pgDb.Migrate(context.Background(), false) c.Assert(err, qt.IsNil) jimm := &jimmtest.JIMM{ - AuthorizationClient_: func() *openfga.OFGAClient { return ofgaClient }, UpdateCloudCredential_: test.updateCloudCredential, - DB_: func() *db.Database { return &pgDb }, + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { return nil, nil }, } var u dbmodel.Identity u.SetTag(names.NewUserTag(test.username)) @@ -578,11 +583,14 @@ func TestListServiceAccountCredentials(t *testing.T) { err = pgDb.Migrate(context.Background(), false) c.Assert(err, qt.IsNil) jimm := &jimmtest.JIMM{ - AuthorizationClient_: func() *openfga.OFGAClient { return ofgaClient }, GetCloudCredential_: test.getCloudCredential, GetCloudCredentialAttributes_: test.getCloudCredentialAttributes, ForEachUserCloudCredential_: test.ForEachUserCloudCredential, - DB_: func() *db.Database { return &pgDb }, + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { + var u dbmodel.Identity + u.SetTag(names.NewUserTag(email)) + return openfga.NewUser(&u, ofgaClient), nil + }, } var u dbmodel.Identity u.SetTag(names.NewUserTag(test.username)) @@ -694,9 +702,8 @@ func TestGrantServiceAccountAccess(t *testing.T) { err = pgDb.Migrate(context.Background(), false) c.Assert(err, qt.IsNil) jimm := &jimmtest.JIMM{ - AuthorizationClient_: func() *openfga.OFGAClient { return ofgaClient }, + UserLogin_: func(ctx context.Context, email string) (*openfga.User, error) { return nil, nil }, GrantServiceAccountAccess_: test.grantServiceAccountAccess, - DB_: func() *db.Database { return &pgDb }, } var u dbmodel.Identity u.SetTag(names.NewUserTag(test.username)) diff --git a/internal/jujuapi/websocket_test.go b/internal/jujuapi/websocket_test.go index 34a9f9b57..88adc7b7d 100644 --- a/internal/jujuapi/websocket_test.go +++ b/internal/jujuapi/websocket_test.go @@ -47,7 +47,7 @@ func (s *websocketSuite) SetUpTest(c *gc.C) { s.BootstrapSuite.SetUpTest(c) - s.Params.ControllerUUID = "914487b5-60e7-42bb-bd63-1adc3fd3a388" + s.Params.ControllerUUID = jimmtest.ControllerUUID mux := http.NewServeMux() mux.Handle("/api", jujuapi.APIHandler(ctx, s.JIMM, s.Params)) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 000000000..44120ffbf --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,59 @@ +// Copyright 2024 Canonical. + +package middleware + +import ( + "net/http" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/auth" + "github.com/canonical/jimm/v3/internal/jujuapi" +) + +// AuthenticateViaCookie performs browser session authentication and puts an identity in the request's context +func AuthenticateViaCookie(next http.Handler, jimm jujuapi.JIMM) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, err := jimm.AuthenticateBrowserSession(r.Context(), w, r) + if err != nil { + zapctx.Error(ctx, "failed to authenticate", zap.Error(err)) + http.Error(w, "failed to authenticate", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// AuthenticateRebac is a layer on top of AuthenticateViaCookie +// It places the OpenFGA user for the session identity inside the request's context +// and verifies that the user is a JIMM admin. +func AuthenticateRebac(next http.Handler, jimm jujuapi.JIMM) http.Handler { + return AuthenticateViaCookie(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + identity := auth.SessionIdentityFromContext(ctx) + if identity == "" { + zapctx.Error(ctx, "no identity found in session") + http.Error(w, "internal authentication error", http.StatusInternalServerError) + return + } + + user, err := jimm.UserLogin(ctx, identity) + if err != nil { + zapctx.Error(ctx, "failed to get openfga user", zap.Error(err)) + http.Error(w, "internal authentication error", http.StatusInternalServerError) + return + } + if !user.JimmAdmin { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("user is not an admin")) + return + } + + ctx = rebac_handlers.ContextWithIdentity(ctx, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }), jimm) +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go new file mode 100644 index 000000000..a622f542b --- /dev/null +++ b/internal/middleware/auth_test.go @@ -0,0 +1,98 @@ +// Copyright 2024 Canonical. + +package middleware_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + qt "github.com/frankban/quicktest" + + "github.com/canonical/jimm/v3/internal/auth" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/jimmtest/mocks" + "github.com/canonical/jimm/v3/internal/middleware" + "github.com/canonical/jimm/v3/internal/openfga" +) + +// Checks if the authenticator responsible for access control to rebac admin handlers works correctly. +func TestAuthenticateRebac(t *testing.T) { + testUser := "test-user@canonical.com" + tests := []struct { + name string + mockAuthBrowserSession func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) + jimmAdmin bool + expectedStatus int + }{ + { + name: "success", + mockAuthBrowserSession: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return auth.ContextWithSessionIdentity(ctx, testUser), nil + }, + jimmAdmin: true, + expectedStatus: http.StatusOK, + }, + { + name: "failure", + mockAuthBrowserSession: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return ctx, errors.New("some error") + }, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "no identity", + mockAuthBrowserSession: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return ctx, nil + }, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "not a jimm admin", + mockAuthBrowserSession: func(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) { + return auth.ContextWithSessionIdentity(ctx, testUser), nil + }, + jimmAdmin: false, + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := qt.New(t) + + 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) + }, + }, + UserLogin_: func(ctx context.Context, username string) (*openfga.User, error) { + user := dbmodel.Identity{Name: username} + return &openfga.User{Identity: &user, JimmAdmin: tt.jimmAdmin}, nil + }, + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + identity, err := rebac_handlers.GetIdentityFromContext(r.Context()) + c.Assert(err, qt.IsNil) + + user, ok := identity.(*openfga.User) + c.Assert(ok, qt.IsTrue) + c.Assert(user.Name, qt.Equals, testUser) + + w.WriteHeader(http.StatusOK) + }) + middleware := middleware.AuthenticateRebac(handler, &j) + middleware.ServeHTTP(w, req) + + c.Assert(w.Code, qt.Equals, tt.expectedStatus) + }) + } +} diff --git a/internal/openfga/names/common.go b/internal/openfga/names/common.go new file mode 100644 index 000000000..199333cc4 --- /dev/null +++ b/internal/openfga/names/common.go @@ -0,0 +1,13 @@ +// Copyright 2024 Canonical. + +package names + +import ( + "github.com/canonical/jimm/v3/pkg/names" +) + +// WithMemberRelation is a convenience function for group tags to return the tag's string +// with a member relation, commonly used when assigning group relations. +func WithMemberRelation(groupTag names.GroupTag) string { + return groupTag.String() + "#" + MemberRelation.String() +} diff --git a/internal/rebac_admin/backend.go b/internal/rebac_admin/backend.go new file mode 100644 index 000000000..b021ff9ce --- /dev/null +++ b/internal/rebac_admin/backend.go @@ -0,0 +1,32 @@ +// Copyright 2024 Canonical. + +package rebac_admin + +import ( + "context" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jujuapi" +) + +func SetupBackend(ctx context.Context, jimm jujuapi.JIMM) (*rebac_handlers.ReBACAdminBackend, error) { + const op = errors.Op("rebac_admin.SetupBackend") + + rebacBackend, err := rebac_handlers.NewReBACAdminBackend(rebac_handlers.ReBACAdminBackendParams{ + Authenticator: nil, // Authentication is handled by internal middleware. + Entitlements: newEntitlementService(), + Groups: newGroupService(jimm), + Identities: newidentitiesService(jimm), + Resources: newResourcesService(jimm), + }) + if err != nil { + zapctx.Error(ctx, "failed to create rebac admin backend", zap.Error(err)) + return nil, errors.E(op, err, "failed to create rebac admin backend") + } + + return rebacBackend, nil +} diff --git a/internal/rebac_admin/entitlements.go b/internal/rebac_admin/entitlements.go new file mode 100644 index 000000000..9f0d930a6 --- /dev/null +++ b/internal/rebac_admin/entitlements.go @@ -0,0 +1,80 @@ +// Copyright 2024 Canonical. + +package rebac_admin + +import ( + "context" + + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + + openfgastatic "github.com/canonical/jimm/v3/openfga" +) + +// For rebac v1 this list is kept manually. +// The reason behind that is we want to decide what relations to expose to rebac admin ui. +var EntitlementsList = []resources.EntitlementSchema{ + // applicationoffer + {Entitlement: "administrator", ReceiverType: "user", EntityType: "applicationoffer"}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "applicationoffer"}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "applicationoffer"}, + {Entitlement: "consumer", ReceiverType: "user", EntityType: "applicationoffer"}, + {Entitlement: "consumer", ReceiverType: "user:*", EntityType: "applicationoffer"}, + {Entitlement: "consumer", ReceiverType: "group#member", EntityType: "applicationoffer"}, + {Entitlement: "reader", ReceiverType: "user", EntityType: "applicationoffer"}, + {Entitlement: "reader", ReceiverType: "user:*", EntityType: "applicationoffer"}, + {Entitlement: "reader", ReceiverType: "group#member", EntityType: "applicationoffer"}, + + // cloud + {Entitlement: "administrator", ReceiverType: "user", EntityType: "cloud"}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "cloud"}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "cloud"}, + {Entitlement: "can_addmodel", ReceiverType: "user", EntityType: "cloud"}, + {Entitlement: "can_addmodel", ReceiverType: "user:*", EntityType: "cloud"}, + {Entitlement: "can_addmodel", ReceiverType: "group#member", EntityType: "cloud"}, + + // controller + {Entitlement: "administrator", ReceiverType: "user", EntityType: "controller"}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "controller"}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "controller"}, + {Entitlement: "audit_log_viewer", ReceiverType: "user", EntityType: "controller"}, + {Entitlement: "audit_log_viewer", ReceiverType: "user:*", EntityType: "controller"}, + {Entitlement: "audit_log_viewer", ReceiverType: "group#member", EntityType: "controller"}, + + // group + {Entitlement: "member", ReceiverType: "user", EntityType: "group"}, + {Entitlement: "member", ReceiverType: "user:*", EntityType: "group"}, + {Entitlement: "member", ReceiverType: "group#member", EntityType: "group"}, + + // model + {Entitlement: "administrator", ReceiverType: "user", EntityType: "model"}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "model"}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "model"}, + {Entitlement: "reader", ReceiverType: "user", EntityType: "model"}, + {Entitlement: "reader", ReceiverType: "user:*", EntityType: "model"}, + {Entitlement: "reader", ReceiverType: "group#member", EntityType: "model"}, + {Entitlement: "writer", ReceiverType: "user", EntityType: "model"}, + {Entitlement: "writer", ReceiverType: "user:*", EntityType: "model"}, + {Entitlement: "writer", ReceiverType: "group#member", EntityType: "model"}, + + // serviceaccount + {Entitlement: "administrator", ReceiverType: "user", EntityType: "serviceaccount"}, + {Entitlement: "administrator", ReceiverType: "user:*", EntityType: "serviceaccount"}, + {Entitlement: "administrator", ReceiverType: "group#member", EntityType: "serviceaccount"}, +} + +// entitlementsService implements the `entitlementsService` interface from rebac-admin-ui-handlers library +type entitlementsService struct{} + +func newEntitlementService() *entitlementsService { + return &entitlementsService{} +} + +// ListEntitlements returns the list of entitlements in JSON format. +func (s *entitlementsService) ListEntitlements(ctx context.Context, params *resources.GetEntitlementsParams) ([]resources.EntitlementSchema, error) { + return EntitlementsList, nil +} + +// RawEntitlements returns the list of entitlements as raw text. +func (s *entitlementsService) RawEntitlements(ctx context.Context) (string, error) { + return string(openfgastatic.AuthModelDSL), nil +} diff --git a/internal/rebac_admin/export_test.go b/internal/rebac_admin/export_test.go new file mode 100644 index 000000000..b59df4943 --- /dev/null +++ b/internal/rebac_admin/export_test.go @@ -0,0 +1,10 @@ +// Copyright 2024 Canonical. +package rebac_admin + +var ( + NewGroupService = newGroupService + NewidentitiesService = newidentitiesService + NewResourcesService = newResourcesService +) + +type GroupsService = groupsService diff --git a/internal/rebac_admin/groups.go b/internal/rebac_admin/groups.go new file mode 100644 index 000000000..135a00cdc --- /dev/null +++ b/internal/rebac_admin/groups.go @@ -0,0 +1,330 @@ +// Copyright 2024 Canonical. + +package rebac_admin + +import ( + "context" + "fmt" + + v1 "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/errors" + "github.com/canonical/jimm/v3/internal/jujuapi" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/rebac_admin/utils" + apiparams "github.com/canonical/jimm/v3/pkg/api/params" + jimmnames "github.com/canonical/jimm/v3/pkg/names" +) + +// groupsService implements the `GroupsService` interface. +type groupsService struct { + jimm jujuapi.JIMM +} + +func newGroupService(jimm jujuapi.JIMM) *groupsService { + return &groupsService{ + jimm, + } +} + +// ListGroups returns a page of Group objects of at least `size` elements if available. +func (s *groupsService) ListGroups(ctx context.Context, params *resources.GetGroupsParams) (*resources.PaginatedResponse[resources.Group], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + count, err := s.jimm.CountGroups(ctx, user) + if err != nil { + return nil, err + } + page, nextPage, pagination := pagination.CreatePagination(params.Size, params.Page, count) + groups, err := s.jimm.ListGroups(ctx, user, pagination) + if err != nil { + return nil, err + } + + data := make([]resources.Group, 0, len(groups)) + for _, group := range groups { + data = append(data, resources.Group{Id: &group.UUID, Name: group.Name}) + } + resp := resources.PaginatedResponse[resources.Group]{ + Data: data, + Meta: resources.ResponseMeta{ + Page: &page, + Size: len(groups), + Total: &count, + }, + Next: resources.Next{Page: nextPage}, + } + return &resp, nil +} + +// CreateGroup creates a single Group. +func (s *groupsService) CreateGroup(ctx context.Context, group *resources.Group) (*resources.Group, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + groupInfo, err := s.jimm.AddGroup(ctx, user, group.Name) + if err != nil { + return nil, err + } + return &resources.Group{Id: &groupInfo.UUID, Name: groupInfo.Name}, nil +} + +// GetGroup returns a single Group identified by `groupId`. +func (s *groupsService) GetGroup(ctx context.Context, groupId string) (*resources.Group, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + group, err := s.jimm.GetGroupByID(ctx, user, groupId) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + return nil, v1.NewNotFoundError("failed to find group") + } + return nil, err + } + return &resources.Group{Id: &group.UUID, Name: group.Name}, nil +} + +// UpdateGroup updates a Group. +func (s *groupsService) UpdateGroup(ctx context.Context, group *resources.Group) (*resources.Group, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + if group.Id == nil { + return nil, v1.NewValidationError("missing group ID") + } + existingGroup, err := s.jimm.GetGroupByID(ctx, user, *group.Id) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + return nil, v1.NewNotFoundError("failed to find group") + } + return nil, err + } + err = s.jimm.RenameGroup(ctx, user, existingGroup.Name, group.Name) + if err != nil { + return nil, err + } + return &resources.Group{Id: &existingGroup.UUID, Name: group.Name}, nil +} + +// DeleteGroup deletes a Group identified by `groupId`. +// returns (true, nil) in case the group was successfully deleted. +// returns (false, error) in case something went wrong. +// implementors may want to return (false, nil) for idempotency cases. +func (s *groupsService) DeleteGroup(ctx context.Context, groupId string) (bool, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return false, err + } + existingGroup, err := s.jimm.GetGroupByID(ctx, user, groupId) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + return false, nil + } + return false, err + } + err = s.jimm.RemoveGroup(ctx, user, existingGroup.Name) + if err != nil { + return false, err + } + return true, nil +} + +// GetGroupIdentities returns a page of identities in a Group identified by `groupId`. +func (s *groupsService) GetGroupIdentities(ctx context.Context, groupId string, params *resources.GetGroupsItemIdentitiesParams) (*resources.PaginatedResponse[resources.Identity], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + if !jimmnames.IsValidGroupId(groupId) { + return nil, v1.NewValidationError("invalid group ID") + } + filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) + groupTag := jimmnames.NewGroupTag(groupId) + _, err = s.jimm.GetGroupByID(ctx, user, groupId) + if err != nil { + if errors.ErrorCode(err) == errors.CodeNotFound { + return nil, v1.NewNotFoundError("group not found") + } + return nil, err + } + tuple := apiparams.RelationshipTuple{ + Relation: ofganames.MemberRelation.String(), + TargetObject: groupTag.String(), + } + identities, nextToken, err := s.jimm.ListRelationshipTuples(ctx, user, tuple, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion + if err != nil { + return nil, err + } + data := make([]resources.Identity, 0, len(identities)) + for _, identity := range identities { + identifier := identity.Object.ID + data = append(data, resources.Identity{Email: identifier}) + } + originalToken := filter.Token() + resp := resources.PaginatedResponse[resources.Identity]{ + Meta: resources.ResponseMeta{ + Size: len(data), + PageToken: &originalToken, + }, + Data: data, + } + if nextToken != "" { + resp.Next = resources.Next{ + PageToken: &nextToken, + } + } + return &resp, nil +} + +// PatchGroupIdentities performs addition or removal of identities to/from a Group identified by `groupId`. +func (s *groupsService) PatchGroupIdentities(ctx context.Context, groupId string, identityPatches []resources.GroupIdentitiesPatchItem) (bool, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return false, err + } + if !jimmnames.IsValidGroupId(groupId) { + return false, v1.NewValidationError("invalid group ID") + } + groupTag := jimmnames.NewGroupTag(groupId) + tuple := apiparams.RelationshipTuple{ + Relation: ofganames.MemberRelation.String(), + TargetObject: groupTag.String(), + } + var toRemove []apiparams.RelationshipTuple + var toAdd []apiparams.RelationshipTuple + for _, identityPatch := range identityPatches { + if !names.IsValidUser(identityPatch.Identity) { + return false, v1.NewValidationError(fmt.Sprintf("invalid identity: %s", identityPatch.Identity)) + } + identity := names.NewUserTag(identityPatch.Identity) + if identityPatch.Op == resources.GroupIdentitiesPatchItemOpAdd { + t := tuple + t.Object = identity.String() + toAdd = append(toAdd, t) + } else { + t := tuple + t.Object = identity.String() + toRemove = append(toRemove, t) + } + } + if toAdd != nil { + err := s.jimm.AddRelation(ctx, user, toAdd) + if err != nil { + return false, err + } + } + if toRemove != nil { + err := s.jimm.RemoveRelation(ctx, user, toRemove) + if err != nil { + return false, err + } + } + return true, nil +} + +// GetGroupRoles returns a page of Roles for Group `groupId`. +func (s *groupsService) GetGroupRoles(ctx context.Context, groupId string, params *resources.GetGroupsItemRolesParams) (*resources.PaginatedResponse[resources.Role], error) { + return nil, v1.NewNotImplementedError("get group roles not implemented") +} + +// PatchGroupRoles performs addition or removal of a Role to/from a Group identified by `groupId`. +func (s *groupsService) PatchGroupRoles(ctx context.Context, groupId string, rolePatches []resources.GroupRolesPatchItem) (bool, error) { + return false, v1.NewNotImplementedError("patch group roles not implemented") +} + +// GetGroupEntitlements returns a page of Entitlements for Group `groupId`. +func (s *groupsService) GetGroupEntitlements(ctx context.Context, groupId string, params *resources.GetGroupsItemEntitlementsParams) (*resources.PaginatedResponse[resources.EntityEntitlement], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + ok := jimmnames.IsValidGroupId(groupId) + if !ok { + return nil, v1.NewValidationError("invalid group ID") + } + filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) + group := ofganames.WithMemberRelation(jimmnames.NewGroupTag(groupId)) + entitlementToken := pagination.NewEntitlementToken(filter.Token()) + // nolint:gosec accept integer conversion + tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, group, int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion + if err != nil { + return nil, err + } + originalToken := filter.Token() + resp := resources.PaginatedResponse[resources.EntityEntitlement]{ + Meta: resources.ResponseMeta{ + Size: len(tuples), + PageToken: &originalToken, + }, + Data: utils.ToEntityEntitlements(tuples), + } + if nextEntitlmentToken.String() != "" { + nextToken := nextEntitlmentToken.String() + resp.Next = resources.Next{ + PageToken: &nextToken, + } + } + return &resp, nil +} + +// PatchGroupEntitlements performs addition or removal of an Entitlement to/from a Group identified by `groupId`. +func (s *groupsService) PatchGroupEntitlements(ctx context.Context, groupId string, entitlementPatches []resources.GroupEntitlementsPatchItem) (bool, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return false, err + } + if !jimmnames.IsValidGroupId(groupId) { + return false, v1.NewValidationError("invalid group ID") + } + groupTag := jimmnames.NewGroupTag(groupId) + var toRemove []apiparams.RelationshipTuple + var toAdd []apiparams.RelationshipTuple + var errList utils.MultiErr + toTargetTag := func(entitlementPatch resources.GroupEntitlementsPatchItem) (names.Tag, error) { + return utils.ValidateDecomposedTag( + entitlementPatch.Entitlement.EntityType, + entitlementPatch.Entitlement.EntityId, + ) + } + for _, entitlementPatch := range entitlementPatches { + tag, err := toTargetTag(entitlementPatch) + if err != nil { + errList.AppendError(err) + continue + } + t := apiparams.RelationshipTuple{ + Object: ofganames.WithMemberRelation(groupTag), + Relation: entitlementPatch.Entitlement.Entitlement, + TargetObject: tag.String(), + } + if entitlementPatch.Op == resources.GroupEntitlementsPatchItemOpAdd { + toAdd = append(toAdd, t) + } else { + toRemove = append(toRemove, t) + } + } + if err := errList.Error(); err != nil { + return false, err + } + if toAdd != nil { + err := s.jimm.AddRelation(ctx, user, toAdd) + if err != nil { + return false, err + } + } + if toRemove != nil { + err := s.jimm.RemoveRelation(ctx, user, toRemove) + if err != nil { + return false, err + } + } + return true, nil +} diff --git a/internal/rebac_admin/groups_integration_test.go b/internal/rebac_admin/groups_integration_test.go new file mode 100644 index 000000000..b14b6c86f --- /dev/null +++ b/internal/rebac_admin/groups_integration_test.go @@ -0,0 +1,299 @@ +// Copyright 2024 Canonical. + +package rebac_admin_test + +import ( + "context" + "fmt" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" + gc "gopkg.in/check.v1" + + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/rebac_admin" + jimmnames "github.com/canonical/jimm/v3/pkg/names" +) + +type rebacAdminSuite struct { + jimmtest.JIMMSuite + groupSvc *rebac_admin.GroupsService +} + +func (s *rebacAdminSuite) SetUpTest(c *gc.C) { + s.JIMMSuite.SetUpTest(c) + s.groupSvc = rebac_admin.NewGroupService(s.JIMM) +} + +var _ = gc.Suite(&rebacAdminSuite{}) + +func (s rebacAdminSuite) TestGetGroupIdentitiesIntegration(c *gc.C) { + ctx := context.Background() + group, err := s.JIMM.AddGroup(ctx, s.AdminUser, "test-group") + c.Assert(err, gc.IsNil) + tuple := openfga.Tuple{ + Relation: ofganames.MemberRelation, + Target: ofganames.ConvertTag(jimmnames.NewGroupTag(group.UUID)), + } + var tuples []openfga.Tuple + for i := range 10 { + t := tuple + t.Object = ofganames.ConvertTag(names.NewUserTag(fmt.Sprintf("foo%d@canonical.com", i))) + tuples = append(tuples, t) + } + err = s.JIMM.OpenFGAClient.AddRelation(ctx, tuples...) + c.Assert(err, gc.IsNil) + // Request Subset of items + pageSize := 5 + params := &resources.GetGroupsItemIdentitiesParams{Size: &pageSize} + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + res, err := s.groupSvc.GetGroupIdentities(ctx, group.UUID, params) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + c.Assert(res.Meta.Size, gc.Equals, 5) + c.Assert(*res.Meta.PageToken, gc.Equals, "") + c.Assert(*res.Next.PageToken, gc.Not(gc.Equals), "") + c.Assert(res.Data, gc.HasLen, 5) + c.Assert(res.Data[0].Email, gc.Equals, "foo0@canonical.com") + + // Request next page + params.NextPageToken = res.Next.PageToken + res, err = s.groupSvc.GetGroupIdentities(ctx, group.UUID, params) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + c.Assert(res.Meta.Size, gc.Equals, 5) + c.Assert(*res.Meta.PageToken, gc.Equals, *params.NextPageToken) + c.Assert(res.Next.PageToken, gc.IsNil) + c.Assert(res.Data, gc.HasLen, 5) + c.Assert(res.Data[0].Email, gc.Equals, "foo5@canonical.com") + + // Request all items, no next page. + allItems := &resources.GetGroupsItemIdentitiesParams{} + res, err = s.groupSvc.GetGroupIdentities(ctx, group.UUID, allItems) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + c.Assert(res.Next.PageToken, gc.IsNil) +} + +func (s rebacAdminSuite) TestPatchGroupIdentitiesIntegration(c *gc.C) { + ctx := context.Background() + group, err := s.JIMM.AddGroup(ctx, s.AdminUser, "test-group") + c.Assert(err, gc.IsNil) + tuple := openfga.Tuple{ + Relation: ofganames.MemberRelation, + Target: ofganames.ConvertTag(jimmnames.NewGroupTag(group.UUID)), + } + var tuples []openfga.Tuple + for i := range 2 { + t := tuple + t.Object = ofganames.ConvertTag(names.NewUserTag(fmt.Sprintf("foo%d@canonical.com", i))) + tuples = append(tuples, t) + } + err = s.JIMM.OpenFGAClient.AddRelation(ctx, tuples...) + c.Assert(err, gc.IsNil) + allowed, err := s.JIMM.OpenFGAClient.CheckRelation(ctx, tuples[0], false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, true) + // Above we have added 2 users to the group, below, we will request those 2 users to be removed + // and add 2 different users to the group, in the same request. + entitlementPatches := []resources.GroupIdentitiesPatchItem{ + {Identity: "foo0@canonical.com", Op: resources.GroupIdentitiesPatchItemOpRemove}, + {Identity: "foo1@canonical.com", Op: resources.GroupIdentitiesPatchItemOpRemove}, + {Identity: "foo2@canonical.com", Op: resources.GroupIdentitiesPatchItemOpAdd}, + {Identity: "foo3@canonical.com", Op: resources.GroupIdentitiesPatchItemOpAdd}, + } + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + res, err := s.groupSvc.PatchGroupIdentities(ctx, group.UUID, entitlementPatches) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Equals, true) + + allowed, err = s.JIMM.OpenFGAClient.CheckRelation(ctx, tuples[0], false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, false) + newTuple := tuples[0] + newTuple.Object = ofganames.ConvertTag(names.NewUserTag("foo2@canonical.com")) + allowed, err = s.JIMM.OpenFGAClient.CheckRelation(ctx, newTuple, false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, true) +} + +func (s rebacAdminSuite) TestGetGroupEntitlementsIntegration(c *gc.C) { + ctx := context.Background() + group, err := s.JIMM.AddGroup(ctx, s.AdminUser, "test-group") + c.Assert(err, gc.IsNil) + tuple := openfga.Tuple{ + Object: ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation), + Relation: ofganames.AdministratorRelation, + } + var tuples []openfga.Tuple + for i := range 3 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewModelTag(fmt.Sprintf("test-model-%d", i))) + tuples = append(tuples, t) + } + for i := range 3 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewControllerTag(fmt.Sprintf("test-controller-%d", i))) + tuples = append(tuples, t) + } + err = s.JIMM.OpenFGAClient.AddRelation(ctx, tuples...) + c.Assert(err, gc.IsNil) + + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + emptyPageToken := "" + req := resources.GetGroupsItemEntitlementsParams{NextPageToken: &emptyPageToken} + var entitlements []resources.EntityEntitlement + for { + res, err := s.groupSvc.GetGroupEntitlements(ctx, group.UUID, &req) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + entitlements = append(entitlements, res.Data...) + if res.Next.PageToken == nil { + break + } + c.Assert(*res.Meta.PageToken, gc.Equals, *req.NextPageToken) + c.Assert(*res.Next.PageToken, gc.Not(gc.Equals), "") + req.NextPageToken = res.Next.PageToken + } + c.Assert(entitlements, gc.HasLen, 6) + modelEntitlementCount := 0 + controllerEntitlementCount := 0 + for _, entitlement := range entitlements { + c.Assert(entitlement.Entitlement, gc.Equals, ofganames.AdministratorRelation.String()) + c.Assert(entitlement.EntityId, gc.Matches, `test-(model|controller)-\d`) + switch entitlement.EntityType { + case openfga.ModelType.String(): + modelEntitlementCount++ + case openfga.ControllerType.String(): + controllerEntitlementCount++ + default: + c.Logf("Unexpected entitlement found of type %s", entitlement.EntityType) + c.FailNow() + } + } + c.Assert(modelEntitlementCount, gc.Equals, 3) + c.Assert(controllerEntitlementCount, gc.Equals, 3) +} + +// patchGroupEntitlementTestEnv is used to create entries in JIMM's database. +// The rebacAdminSuite does not spin up a Juju controller so we cannot use +// regular JIMM methods to create resources. It is also necessary to have resources +// present in the database in order for ListRelationshipTuples to work correctly. +const patchGroupEntitlementTestEnv = `clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-cloud-region +cloud-credentials: +- owner: alice@canonical.com + name: cred-1 + cloud: test-cloud +controllers: +- name: controller-1 + uuid: 00000001-0000-0000-0000-000000000001 + cloud: test-cloud + region: test-cloud-region +models: +- name: model-1 + uuid: 00000002-0000-0000-0000-000000000001 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-2 + uuid: 00000002-0000-0000-0000-000000000002 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-3 + uuid: 00000003-0000-0000-0000-000000000003 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-4 + uuid: 00000004-0000-0000-0000-000000000004 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +` + +// TestPatchGroupEntitlementsIntegration creates 4 models and verifies that relations from a group to these models can be added/removed. +func (s rebacAdminSuite) TestPatchGroupEntitlementsIntegration(c *gc.C) { + ctx := context.Background() + tester := jimmtest.GocheckTester{C: c} + env := jimmtest.ParseEnvironment(tester, patchGroupEntitlementTestEnv) + env.PopulateDB(tester, s.JIMM.Database) + oldModels := []string{env.Models[0].UUID, env.Models[1].UUID} + newModels := []string{env.Models[2].UUID, env.Models[3].UUID} + + group, err := s.JIMM.AddGroup(ctx, s.AdminUser, "test-group") + c.Assert(err, gc.IsNil) + tuple := openfga.Tuple{ + Object: ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(group.UUID), ofganames.MemberRelation), + Relation: ofganames.AdministratorRelation, + } + + var tuples []openfga.Tuple + for i := range 2 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewModelTag(oldModels[i])) + tuples = append(tuples, t) + } + err = s.JIMM.OpenFGAClient.AddRelation(ctx, tuples...) + c.Assert(err, gc.IsNil) + allowed, err := s.JIMM.OpenFGAClient.CheckRelation(ctx, tuples[0], false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, true) + // Above we have added granted the group with administrator permission to 2 models. + // Below, we will request those 2 relations to be removed and add 2 different relations. + + entitlementPatches := []resources.GroupEntitlementsPatchItem{ + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: newModels[0], + EntityType: openfga.ModelType.String(), + }, Op: resources.GroupEntitlementsPatchItemOpAdd}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: newModels[1], + EntityType: openfga.ModelType.String(), + }, Op: resources.GroupEntitlementsPatchItemOpAdd}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: oldModels[0], + EntityType: openfga.ModelType.String(), + }, Op: resources.GroupEntitlementsPatchItemOpRemove}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: oldModels[1], + EntityType: openfga.ModelType.String(), + }, Op: resources.GroupEntitlementsPatchItemOpRemove}, + } + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + res, err := s.groupSvc.PatchGroupEntitlements(ctx, group.UUID, entitlementPatches) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Equals, true) + + for i := range 2 { + allowed, err = s.JIMM.OpenFGAClient.CheckRelation(ctx, tuples[i], false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, false) + } + for i := range 2 { + newTuple := tuples[0] + newTuple.Target = ofganames.ConvertTag(names.NewModelTag(newModels[i])) + allowed, err = s.JIMM.OpenFGAClient.CheckRelation(ctx, newTuple, false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, true) + } +} diff --git a/internal/rebac_admin/groups_test.go b/internal/rebac_admin/groups_test.go new file mode 100644 index 000000000..3fc964bbb --- /dev/null +++ b/internal/rebac_admin/groups_test.go @@ -0,0 +1,344 @@ +// Copyright 2024 Canonical. + +package rebac_admin_test + +import ( + "context" + "errors" + "testing" + + "github.com/canonical/ofga" + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + qt "github.com/frankban/quicktest" + "github.com/google/uuid" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/jimmtest/mocks" + "github.com/canonical/jimm/v3/internal/openfga" + "github.com/canonical/jimm/v3/internal/rebac_admin" + "github.com/canonical/jimm/v3/pkg/api/params" +) + +func TestCreateGroup(t *testing.T) { + c := qt.New(t) + var addErr error + jimm := jimmtest.JIMM{ + GroupService: mocks.GroupService{ + AddGroup_: func(ctx context.Context, user *openfga.User, name string) (*dbmodel.GroupEntry, error) { + return &dbmodel.GroupEntry{UUID: "test-uuid", Name: name}, addErr + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + groupSvc := rebac_admin.NewGroupService(&jimm) + resp, err := groupSvc.CreateGroup(ctx, &resources.Group{Name: "new-group"}) + c.Assert(err, qt.IsNil) + c.Assert(*resp.Id, qt.Equals, "test-uuid") + c.Assert(resp.Name, qt.Equals, "new-group") + addErr = errors.New("foo") + _, err = groupSvc.CreateGroup(ctx, &resources.Group{Name: "new-group"}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestUpdateGroup(t *testing.T) { + c := qt.New(t) + groupID := "group-id" + var renameErr error + jimm := jimmtest.JIMM{ + GroupService: mocks.GroupService{ + GetGroupByID_: func(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.GroupEntry, error) { + return &dbmodel.GroupEntry{UUID: groupID, Name: "test-group"}, nil + }, + RenameGroup_: func(ctx context.Context, user *openfga.User, oldName, newName string) error { + if oldName != "test-group" { + return errors.New("invalid old group name") + } + return renameErr + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + groupSvc := rebac_admin.NewGroupService(&jimm) + _, err := groupSvc.UpdateGroup(ctx, &resources.Group{Name: "new-group"}) + c.Assert(err, qt.ErrorMatches, ".*missing group ID") + resp, err := groupSvc.UpdateGroup(ctx, &resources.Group{Id: &groupID, Name: "new-group"}) + c.Assert(err, qt.IsNil) + c.Assert(resp, qt.DeepEquals, &resources.Group{Id: &groupID, Name: "new-group"}) + renameErr = errors.New("foo") + _, err = groupSvc.UpdateGroup(ctx, &resources.Group{Id: &groupID, Name: "new-group"}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestListGroups(t *testing.T) { + c := qt.New(t) + var listErr error + returnedGroups := []dbmodel.GroupEntry{ + {Name: "group-1"}, + {Name: "group-2"}, + {Name: "group-3"}, + } + jimm := jimmtest.JIMM{ + GroupService: mocks.GroupService{ + ListGroups_: func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]dbmodel.GroupEntry, error) { + return returnedGroups, listErr + }, + CountGroups_: func(ctx context.Context, user *openfga.User) (int, error) { + return 10, nil + }, + }, + } + expected := []resources.Group{} + id := "" + for _, group := range returnedGroups { + expected = append(expected, resources.Group{Name: group.Name, Id: &id}) + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + groupSvc := rebac_admin.NewGroupService(&jimm) + resp, err := groupSvc.ListGroups(ctx, &resources.GetGroupsParams{}) + c.Assert(err, qt.IsNil) + c.Assert(resp.Data, qt.DeepEquals, expected) + c.Assert(*resp.Meta.Page, qt.Equals, 0) + c.Assert(resp.Meta.Size, qt.Equals, len(expected)) + c.Assert(*resp.Meta.Total, qt.Equals, 10) + c.Assert(*resp.Next.Page, qt.Equals, 1) + listErr = errors.New("foo") + _, err = groupSvc.ListGroups(ctx, &resources.GetGroupsParams{}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestDeleteGroup(t *testing.T) { + c := qt.New(t) + var deleteErr error + jimm := jimmtest.JIMM{ + GroupService: mocks.GroupService{ + GetGroupByID_: func(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.GroupEntry, error) { + return &dbmodel.GroupEntry{UUID: uuid, Name: "test-group"}, nil + }, + RemoveGroup_: func(ctx context.Context, user *openfga.User, name string) error { + if name != "test-group" { + return errors.New("invalid name provided") + } + return deleteErr + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + groupSvc := rebac_admin.NewGroupService(&jimm) + res, err := groupSvc.DeleteGroup(ctx, "group-id") + c.Assert(res, qt.IsTrue) + c.Assert(err, qt.IsNil) + deleteErr = errors.New("foo") + _, err = groupSvc.DeleteGroup(ctx, "group-id") + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestGetGroupIdentities(t *testing.T) { + c := qt.New(t) + var listTuplesErr error + var getGroupErr error + var continuationToken string + testTuple := openfga.Tuple{ + Object: &ofga.Entity{Kind: "user", ID: "foo"}, + Relation: ofga.Relation("member"), + Target: &ofga.Entity{Kind: "group", ID: "my-group"}, + } + jimm := jimmtest.JIMM{ + GroupService: mocks.GroupService{ + GetGroupByID_: func(ctx context.Context, user *openfga.User, uuid string) (*dbmodel.GroupEntry, error) { + return nil, getGroupErr + }, + }, + RelationService: mocks.RelationService{ + ListRelationshipTuples_: func(ctx context.Context, user *openfga.User, tuple params.RelationshipTuple, pageSize int32, ct string) ([]openfga.Tuple, string, error) { + return []openfga.Tuple{testTuple}, continuationToken, listTuplesErr + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + groupSvc := rebac_admin.NewGroupService(&jimm) + + _, err := groupSvc.GetGroupIdentities(ctx, "invalid-group-id", &resources.GetGroupsItemIdentitiesParams{}) + c.Assert(err, qt.ErrorMatches, ".*invalid group ID") + + newUUID := uuid.New() + getGroupErr = errors.New("group doesn't exist") + _, err = groupSvc.GetGroupIdentities(ctx, newUUID.String(), &resources.GetGroupsItemIdentitiesParams{}) + c.Assert(err, qt.ErrorMatches, ".*group doesn't exist") + getGroupErr = nil + + continuationToken = "continuation-token" + res, err := groupSvc.GetGroupIdentities(ctx, newUUID.String(), &resources.GetGroupsItemIdentitiesParams{}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + c.Assert(res.Data, qt.HasLen, 1) + c.Assert(*res.Next.PageToken, qt.Equals, "continuation-token") + + continuationToken = "" + res, err = groupSvc.GetGroupIdentities(ctx, newUUID.String(), &resources.GetGroupsItemIdentitiesParams{}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + c.Assert(res.Next.PageToken, qt.IsNil) + + listTuplesErr = errors.New("foo") + _, err = groupSvc.GetGroupIdentities(ctx, newUUID.String(), &resources.GetGroupsItemIdentitiesParams{}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestPatchGroupIdentities(t *testing.T) { + c := qt.New(t) + var patchTuplesErr error + jimm := jimmtest.JIMM{ + RelationService: mocks.RelationService{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + groupSvc := rebac_admin.NewGroupService(&jimm) + + _, err := groupSvc.PatchGroupIdentities(ctx, "invalid-group-id", nil) + c.Assert(err, qt.ErrorMatches, ".* invalid group ID") + + newUUID := uuid.New() + operations := []resources.GroupIdentitiesPatchItem{ + {Identity: "foo@canonical.com", Op: resources.GroupIdentitiesPatchItemOpAdd}, + {Identity: "bar@canonical.com", Op: resources.GroupIdentitiesPatchItemOpRemove}, + } + res, err := groupSvc.PatchGroupIdentities(ctx, newUUID.String(), operations) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsTrue) + + operationsWithInvalidIdentity := []resources.GroupIdentitiesPatchItem{ + {Identity: "foo_", Op: resources.GroupIdentitiesPatchItemOpAdd}, + } + _, err = groupSvc.PatchGroupIdentities(ctx, newUUID.String(), operationsWithInvalidIdentity) + c.Assert(err, qt.ErrorMatches, ".*invalid identity.*") + + patchTuplesErr = errors.New("foo") + _, err = groupSvc.PatchGroupIdentities(ctx, newUUID.String(), operations) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestGetGroupEntitlements(t *testing.T) { + c := qt.New(t) + var listRelationsErr error + var continuationToken string + testTuple := openfga.Tuple{ + Object: &ofga.Entity{Kind: "user", ID: "foo"}, + Relation: ofga.Relation("member"), + Target: &ofga.Entity{Kind: "group", ID: "my-group"}, + } + jimm := jimmtest.JIMM{ + RelationService: mocks.RelationService{ + ListObjectRelations_: func(ctx context.Context, user *openfga.User, object string, pageSize int32, ct pagination.EntitlementToken) ([]openfga.Tuple, pagination.EntitlementToken, error) { + return []openfga.Tuple{testTuple}, pagination.NewEntitlementToken(continuationToken), listRelationsErr + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + groupSvc := rebac_admin.NewGroupService(&jimm) + + _, err := groupSvc.GetGroupEntitlements(ctx, "invalid-group-id", nil) + c.Assert(err, qt.ErrorMatches, ".* invalid group ID") + + continuationToken = "random-token" + res, err := groupSvc.GetGroupEntitlements(ctx, uuid.New().String(), &resources.GetGroupsItemEntitlementsParams{}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + c.Assert(res.Data, qt.HasLen, 1) + c.Assert(*res.Next.PageToken, qt.Equals, "random-token") + + continuationToken = "" + res, err = groupSvc.GetGroupEntitlements(ctx, uuid.New().String(), &resources.GetGroupsItemEntitlementsParams{}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + c.Assert(res.Next.PageToken, qt.IsNil) + + nextToken := "some-token" + res, err = groupSvc.GetGroupEntitlements(ctx, uuid.New().String(), &resources.GetGroupsItemEntitlementsParams{NextToken: &nextToken}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + + listRelationsErr = errors.New("foo") + _, err = groupSvc.GetGroupEntitlements(ctx, uuid.New().String(), &resources.GetGroupsItemEntitlementsParams{}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +func TestPatchGroupEntitlements(t *testing.T) { + c := qt.New(t) + var patchTuplesErr error + jimm := jimmtest.JIMM{ + RelationService: mocks.RelationService{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + groupSvc := rebac_admin.NewGroupService(&jimm) + + _, err := groupSvc.PatchGroupEntitlements(ctx, "invalid-group-id", nil) + c.Assert(err, qt.ErrorMatches, ".* invalid group ID") + + newUUID := uuid.New() + operations := []resources.GroupEntitlementsPatchItem{ + {Entitlement: resources.EntityEntitlement{ + Entitlement: "administrator", + EntityId: newUUID.String(), + EntityType: "model", + }, Op: resources.GroupEntitlementsPatchItemOpAdd}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: "administrator", + EntityId: newUUID.String(), + EntityType: "model", + }, Op: resources.GroupEntitlementsPatchItemOpRemove}, + } + res, err := groupSvc.PatchGroupEntitlements(ctx, newUUID.String(), operations) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsTrue) + + operationsWithInvalidTag := []resources.GroupEntitlementsPatchItem{ + {Entitlement: resources.EntityEntitlement{ + Entitlement: "administrator", + EntityId: "foo", + EntityType: "invalidType", + }, Op: resources.GroupEntitlementsPatchItemOpAdd}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: "administrator", + EntityId: "foo1", + EntityType: "invalidType2", + }, Op: resources.GroupEntitlementsPatchItemOpAdd}, + } + _, err = groupSvc.PatchGroupEntitlements(ctx, newUUID.String(), operationsWithInvalidTag) + c.Assert(err, qt.ErrorMatches, `\"invalidType-foo\" is not a valid tag\n\"invalidType2-foo1\" is not a valid tag`) + + patchTuplesErr = errors.New("foo") + _, err = groupSvc.PatchGroupEntitlements(ctx, newUUID.String(), operations) + c.Assert(err, qt.ErrorMatches, "foo") +} diff --git a/internal/rebac_admin/identities.go b/internal/rebac_admin/identities.go new file mode 100644 index 000000000..7751a9d67 --- /dev/null +++ b/internal/rebac_admin/identities.go @@ -0,0 +1,271 @@ +// Copyright 2024 Canonical. + +package rebac_admin + +import ( + "context" + "fmt" + + v1 "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" + "github.com/juju/zaputil/zapctx" + "go.uber.org/zap" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/jujuapi" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/rebac_admin/utils" + apiparams "github.com/canonical/jimm/v3/pkg/api/params" +) + +type identitiesService struct { + jimm jujuapi.JIMM +} + +func newidentitiesService(jimm jujuapi.JIMM) *identitiesService { + return &identitiesService{ + jimm: jimm, + } +} + +// ListIdentities returns a page of Identity objects of at least `size` elements if available. +func (s *identitiesService) ListIdentities(ctx context.Context, params *resources.GetIdentitiesParams) (*resources.PaginatedResponse[resources.Identity], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + + count, err := s.jimm.CountIdentities(ctx, user) + if err != nil { + return nil, err + } + page, nextPage, pagination := pagination.CreatePagination(params.Size, params.Page, count) + + users, err := s.jimm.ListIdentities(ctx, user, pagination) + if err != nil { + return nil, err + } + rIdentities := make([]resources.Identity, len(users)) + for i, u := range users { + rIdentities[i] = utils.FromUserToIdentity(u) + } + + return &resources.PaginatedResponse[resources.Identity]{ + Data: rIdentities, + Meta: resources.ResponseMeta{ + Page: &page, + Size: len(rIdentities), + Total: &count, + }, + Next: resources.Next{ + Page: nextPage, + }, + }, nil +} + +// CreateIdentity creates a single Identity. +func (s *identitiesService) CreateIdentity(ctx context.Context, identity *resources.Identity) (*resources.Identity, error) { + return nil, v1.NewNotImplementedError("create identity not implemented") +} + +// GetIdentity returns a single Identity. +func (s *identitiesService) GetIdentity(ctx context.Context, identityId string) (*resources.Identity, error) { + user, err := s.jimm.FetchIdentity(ctx, identityId) + if err != nil { + return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) + } + identity := utils.FromUserToIdentity(*user) + return &identity, nil +} + +// UpdateIdentity updates an Identity. +func (s *identitiesService) UpdateIdentity(ctx context.Context, identity *resources.Identity) (*resources.Identity, error) { + return nil, v1.NewNotImplementedError("update identity not implemented") +} + +// // DeleteIdentity deletes an Identity. +func (s *identitiesService) DeleteIdentity(ctx context.Context, identityId string) (bool, error) { + return false, v1.NewNotImplementedError("delete identity not implemented") +} + +// // GetIdentityRoles returns a page of Roles for identity `identityId`. +func (s *identitiesService) GetIdentityRoles(ctx context.Context, identityId string, params *resources.GetIdentitiesItemRolesParams) (*resources.PaginatedResponse[resources.Role], error) { + return nil, v1.NewNotImplementedError("get identity roles not implemented") +} + +// // PatchIdentityRoles performs addition or removal of a Role to/from an Identity. +func (s *identitiesService) PatchIdentityRoles(ctx context.Context, identityId string, rolePatches []resources.IdentityRolesPatchItem) (bool, error) { + return false, v1.NewNotImplementedError("get identity roles not implemented") +} + +// GetIdentityGroups returns a page of Groups for identity `identityId`. +func (s *identitiesService) GetIdentityGroups(ctx context.Context, identityId string, params *resources.GetIdentitiesItemGroupsParams) (*resources.PaginatedResponse[resources.Group], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + objUser, err := s.jimm.FetchIdentity(ctx, identityId) + if err != nil { + return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) + } + filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) + tuples, cNextToken, err := s.jimm.ListRelationshipTuples(ctx, user, apiparams.RelationshipTuple{ + Object: objUser.ResourceTag().String(), + Relation: ofganames.MemberRelation.String(), + TargetObject: openfga.GroupType.String(), + }, int32(filter.Limit()), filter.Token()) // #nosec G115 accept integer conversion + + if err != nil { + return nil, err + } + groups := make([]resources.Group, len(tuples)) + for i, t := range tuples { + groups[i] = resources.Group{ + Id: &t.Target.ID, + Name: t.Target.ID, + } + } + originalToken := filter.Token() + return &resources.PaginatedResponse[resources.Group]{ + Data: groups, + Meta: resources.ResponseMeta{ + Size: len(groups), + PageToken: &originalToken, + }, + Next: resources.Next{ + PageToken: &cNextToken, + }, + }, nil +} + +// PatchIdentityGroups performs addition or removal of a Group to/from an Identity. +func (s *identitiesService) PatchIdentityGroups(ctx context.Context, identityId string, groupPatches []resources.IdentityGroupsPatchItem) (bool, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return false, err + } + + objUser, err := s.jimm.FetchIdentity(ctx, identityId) + if err != nil { + return false, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) + } + additions := make([]apiparams.RelationshipTuple, 0) + deletions := make([]apiparams.RelationshipTuple, 0) + for _, p := range groupPatches { + t := apiparams.RelationshipTuple{ + Object: objUser.ResourceTag().String(), + Relation: ofganames.MemberRelation.String(), + TargetObject: p.Group, + } + if p.Op == "add" { + additions = append(additions, t) + } else if p.Op == "remove" { + deletions = append(deletions, t) + } + } + if len(additions) > 0 { + err = s.jimm.AddRelation(ctx, user, additions) + if err != nil { + zapctx.Error(context.Background(), "cannot add relations", zap.Error(err)) + return false, v1.NewUnknownError(err.Error()) + } + } + if len(deletions) > 0 { + err = s.jimm.RemoveRelation(ctx, user, deletions) + if err != nil { + zapctx.Error(context.Background(), "cannot remove relations", zap.Error(err)) + return false, v1.NewUnknownError(err.Error()) + } + } + return true, nil +} + +// // GetIdentityEntitlements returns a page of Entitlements for identity `identityId`. +func (s *identitiesService) GetIdentityEntitlements(ctx context.Context, identityId string, params *resources.GetIdentitiesItemEntitlementsParams) (*resources.PaginatedResponse[resources.EntityEntitlement], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + objUser, err := s.jimm.FetchIdentity(ctx, identityId) + if err != nil { + return nil, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) + } + + filter := utils.CreateTokenPaginationFilter(params.Size, params.NextToken, params.NextPageToken) + entitlementToken := pagination.NewEntitlementToken(filter.Token()) + tuples, nextEntitlmentToken, err := s.jimm.ListObjectRelations(ctx, user, objUser.Tag().String(), int32(filter.Limit()), entitlementToken) // #nosec G115 accept integer conversion + if err != nil { + return nil, err + } + originalToken := filter.Token() + resp := resources.PaginatedResponse[resources.EntityEntitlement]{ + Meta: resources.ResponseMeta{ + Size: len(tuples), + PageToken: &originalToken, + }, + Data: utils.ToEntityEntitlements(tuples), + } + if nextEntitlmentToken.String() != "" { + nextToken := nextEntitlmentToken.String() + resp.Next = resources.Next{ + PageToken: &nextToken, + } + } + return &resp, nil +} + +// PatchIdentityEntitlements performs addition or removal of an Entitlement to/from an Identity. +func (s *identitiesService) PatchIdentityEntitlements(ctx context.Context, identityId string, entitlementPatches []resources.IdentityEntitlementsPatchItem) (bool, error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return false, err + } + objUser, err := s.jimm.FetchIdentity(ctx, identityId) + if err != nil { + return false, v1.NewNotFoundError(fmt.Sprintf("User with id %s not found", identityId)) + } + var toAdd []apiparams.RelationshipTuple + var toRemove []apiparams.RelationshipTuple + var errList utils.MultiErr + toTargetTag := func(entitlementPatch resources.IdentityEntitlementsPatchItem) (names.Tag, error) { + return utils.ValidateDecomposedTag( + entitlementPatch.Entitlement.EntityType, + entitlementPatch.Entitlement.EntityId, + ) + } + for _, entitlementPatch := range entitlementPatches { + targetTag, err := toTargetTag(entitlementPatch) + if err != nil { + errList.AppendError(err) + continue + } + t := apiparams.RelationshipTuple{ + Object: objUser.Tag().String(), + Relation: entitlementPatch.Entitlement.Entitlement, + TargetObject: targetTag.String(), + } + if entitlementPatch.Op == resources.IdentityEntitlementsPatchItemOpAdd { + toAdd = append(toAdd, t) + } else { + toRemove = append(toRemove, t) + } + } + if err := errList.Error(); err != nil { + return false, err + } + if toAdd != nil { + err := s.jimm.AddRelation(ctx, user, toAdd) + if err != nil { + return false, err + } + } + if toRemove != nil { + err := s.jimm.RemoveRelation(ctx, user, toRemove) + if err != nil { + return false, err + } + } + return true, nil +} diff --git a/internal/rebac_admin/identities_integration_test.go b/internal/rebac_admin/identities_integration_test.go new file mode 100644 index 000000000..c7b8b248c --- /dev/null +++ b/internal/rebac_admin/identities_integration_test.go @@ -0,0 +1,312 @@ +// Copyright 2024 Canonical. +package rebac_admin_test + +import ( + "context" + "fmt" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" + gc "gopkg.in/check.v1" + + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/openfga" + ofganames "github.com/canonical/jimm/v3/internal/openfga/names" + "github.com/canonical/jimm/v3/internal/rebac_admin" + "github.com/canonical/jimm/v3/pkg/api/params" + jimmnames "github.com/canonical/jimm/v3/pkg/names" +) + +type identitiesSuite struct { + jimmtest.JIMMSuite +} + +var _ = gc.Suite(&identitiesSuite{}) + +func (s *identitiesSuite) TestIdentityPatchGroups(c *gc.C) { + // initialization + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + identitySvc := rebac_admin.NewidentitiesService(s.JIMM) + groupName := "group-test1" + username := s.AdminUser.Name + groupTag := s.AddGroup(c, groupName) + + // test add identity group + changed, err := identitySvc.PatchIdentityGroups(ctx, username, []resources.IdentityGroupsPatchItem{{ + Group: groupTag.String(), + Op: resources.IdentityGroupsPatchItemOpAdd, + }}) + c.Assert(err, gc.IsNil) + c.Assert(changed, gc.Equals, true) + + // test user added to groups + objUser, err := s.JIMM.FetchIdentity(ctx, username) + c.Assert(err, gc.IsNil) + tuples, _, err := s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ + Object: objUser.ResourceTag().String(), + Relation: ofganames.MemberRelation.String(), + TargetObject: groupTag.String(), + }, 10, "") + c.Assert(err, gc.IsNil) + c.Assert(len(tuples), gc.Equals, 1) + c.Assert(groupTag.Id(), gc.Equals, tuples[0].Target.ID) + + // test user remove from group + changed, err = identitySvc.PatchIdentityGroups(ctx, username, []resources.IdentityGroupsPatchItem{{ + Group: groupTag.String(), + Op: resources.IdentityGroupsPatchItemOpRemove, + }}) + c.Assert(err, gc.IsNil) + c.Assert(changed, gc.Equals, true) + tuples, _, err = s.JIMM.ListRelationshipTuples(ctx, s.AdminUser, params.RelationshipTuple{ + Object: objUser.ResourceTag().String(), + Relation: ofganames.MemberRelation.String(), + TargetObject: groupTag.String(), + }, 10, "") + c.Assert(err, gc.IsNil) + c.Assert(len(tuples), gc.Equals, 0) +} + +func (s *identitiesSuite) TestIdentityGetGroups(c *gc.C) { + // initialization + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + identitySvc := rebac_admin.NewidentitiesService(s.JIMM) + username := s.AdminUser.Name + groupsSize := 10 + groupsToAdd := make([]resources.IdentityGroupsPatchItem, groupsSize) + groupTags := make([]jimmnames.GroupTag, groupsSize) + for i := range groupsSize { + groupName := fmt.Sprintf("group-test%d", i) + groupTag := s.AddGroup(c, groupName) + groupTags[i] = groupTag + groupsToAdd[i] = resources.IdentityGroupsPatchItem{ + Group: groupTag.String(), + Op: resources.IdentityGroupsPatchItemOpAdd, + } + + } + changed, err := identitySvc.PatchIdentityGroups(ctx, username, groupsToAdd) + c.Assert(err, gc.IsNil) + c.Assert(changed, gc.Equals, true) + + // test list identity's groups with token pagination + size := 3 + token := "" + for i := 0; ; i += size { + groups, err := identitySvc.GetIdentityGroups(ctx, username, &resources.GetIdentitiesItemGroupsParams{ + Size: &size, + NextToken: &token, + }) + c.Assert(err, gc.IsNil) + token = *groups.Next.PageToken + for j := 0; j < len(groups.Data); j++ { + c.Assert(groups.Data[j].Name, gc.Equals, groupTags[i+j].Id()) + } + if *groups.Next.PageToken == "" { + break + } + } +} + +// TestIdentityEntitlements tests the listing of entitlements for a specific identityId. +// Setup: add controllers, models to a user and add the user to a group. +func (s *identitiesSuite) TestIdentityEntitlements(c *gc.C) { + // initialization + ctx := context.Background() + identitySvc := rebac_admin.NewidentitiesService(s.JIMM) + groupTag := s.AddGroup(c, "test-group") + user := names.NewUserTag("test-user@canonical.com") + s.AddUser(c, user.Id()) + err := s.JIMM.OpenFGAClient.AddRelation(ctx, openfga.Tuple{ + Object: ofganames.ConvertTag(user), + Relation: ofganames.MemberRelation, + Target: ofganames.ConvertTag(groupTag), + }) + c.Assert(err, gc.IsNil) + tuple := openfga.Tuple{ + Object: ofganames.ConvertTag(user), + Relation: ofganames.AdministratorRelation, + } + var tuples []openfga.Tuple + for i := range 3 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewModelTag(fmt.Sprintf("test-model-%d", i))) + tuples = append(tuples, t) + } + for i := range 3 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewControllerTag(fmt.Sprintf("test-controller-%d", i))) + tuples = append(tuples, t) + } + err = s.JIMM.OpenFGAClient.AddRelation(ctx, tuples...) + c.Assert(err, gc.IsNil) + + // test + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + emptyPageToken := "" + req := resources.GetIdentitiesItemEntitlementsParams{NextPageToken: &emptyPageToken} + var entitlements []resources.EntityEntitlement + for { + res, err := identitySvc.GetIdentityEntitlements(ctx, user.Id(), &req) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Not(gc.IsNil)) + entitlements = append(entitlements, res.Data...) + if res.Next.PageToken == nil || *res.Next.PageToken == "" { + break + } + c.Assert(*res.Meta.PageToken, gc.Equals, *req.NextPageToken) + c.Assert(*res.Next.PageToken, gc.Not(gc.Equals), "") + req.NextPageToken = res.Next.PageToken + } + c.Assert(entitlements, gc.HasLen, 7) + modelEntitlementCount := 0 + controllerEntitlementCount := 0 + groupEntitlementCount := 0 + for _, entitlement := range entitlements { + switch entitlement.EntityType { + case openfga.ModelType.String(): + c.Assert(entitlement.EntityId, gc.Matches, `test-model-\d`) + c.Assert(entitlement.Entitlement, gc.Equals, ofganames.AdministratorRelation.String()) + modelEntitlementCount++ + case openfga.ControllerType.String(): + c.Assert(entitlement.EntityId, gc.Matches, `test-controller-\d`) + c.Assert(entitlement.Entitlement, gc.Equals, ofganames.AdministratorRelation.String()) + controllerEntitlementCount++ + case openfga.GroupType.String(): + c.Assert(entitlement.Entitlement, gc.Equals, ofganames.MemberRelation.String()) + groupEntitlementCount++ + default: + c.Logf("Unexpected entitlement found of type %s", entitlement.EntityType) + c.FailNow() + } + } + c.Assert(modelEntitlementCount, gc.Equals, 3) + c.Assert(controllerEntitlementCount, gc.Equals, 3) + c.Assert(groupEntitlementCount, gc.Equals, 1) +} + +// patchIdentitiesEntitlementTestEnv is used to create entries in JIMM's database. +// The rebacAdminSuite does not spin up a Juju controller so we cannot use +// regular JIMM methods to create resources. It is also necessary to have resources +// present in the database in order for ListRelationshipTuples to work correctly. +const patchIdentitiesEntitlementTestEnv = `clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-cloud-region +cloud-credentials: +- owner: alice@canonical.com + name: cred-1 + cloud: test-cloud +controllers: +- name: controller-1 + uuid: 00000001-0000-0000-0000-000000000001 + cloud: test-cloud + region: test-cloud-region +models: +- name: model-1 + uuid: 00000002-0000-0000-0000-000000000001 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-2 + uuid: 00000002-0000-0000-0000-000000000002 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-3 + uuid: 00000003-0000-0000-0000-000000000003 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-4 + uuid: 00000004-0000-0000-0000-000000000004 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +` + +// TestPatchIdentityEntitlements tests the patching of entitlements for a specific identityId, +// adding and removing relations after the setup. +// Setup: add user to a group, and add models to the user. +func (s *identitiesSuite) TestPatchIdentityEntitlements(c *gc.C) { + // initialization + ctx := context.Background() + identitySvc := rebac_admin.NewidentitiesService(s.JIMM) + tester := jimmtest.GocheckTester{C: c} + env := jimmtest.ParseEnvironment(tester, patchIdentitiesEntitlementTestEnv) + env.PopulateDB(tester, s.JIMM.Database) + oldModels := []string{env.Models[0].UUID, env.Models[1].UUID} + newModels := []string{env.Models[2].UUID, env.Models[3].UUID} + user := names.NewUserTag("test-user@canonical.com") + s.AddUser(c, user.Id()) + tuple := openfga.Tuple{ + Object: ofganames.ConvertTag(user), + Relation: ofganames.AdministratorRelation, + } + + var tuples []openfga.Tuple + for i := range 2 { + t := tuple + t.Target = ofganames.ConvertTag(names.NewModelTag(oldModels[i])) + tuples = append(tuples, t) + } + err := s.JIMM.OpenFGAClient.AddRelation(ctx, tuples...) + c.Assert(err, gc.IsNil) + allowed, err := s.JIMM.OpenFGAClient.CheckRelation(ctx, tuples[0], false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, true) + // Above we have added granted the user with administrator permission to 2 models. + // Below, we will request those 2 relations to be removed and add 2 different relations. + + entitlementPatches := []resources.IdentityEntitlementsPatchItem{ + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: newModels[0], + EntityType: openfga.ModelType.String(), + }, Op: resources.IdentityEntitlementsPatchItemOpAdd}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: newModels[1], + EntityType: openfga.ModelType.String(), + }, Op: resources.IdentityEntitlementsPatchItemOpAdd}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: oldModels[0], + EntityType: openfga.ModelType.String(), + }, Op: resources.IdentityEntitlementsPatchItemOpRemove}, + {Entitlement: resources.EntityEntitlement{ + Entitlement: ofganames.AdministratorRelation.String(), + EntityId: oldModels[1], + EntityType: openfga.ModelType.String(), + }, Op: resources.IdentityEntitlementsPatchItemOpRemove}, + } + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + res, err := identitySvc.PatchIdentityEntitlements(ctx, user.Id(), entitlementPatches) + c.Assert(err, gc.IsNil) + c.Assert(res, gc.Equals, true) + + for i := range 2 { + exists, err := s.JIMM.OpenFGAClient.CheckRelation(ctx, tuples[i], false) + c.Assert(err, gc.IsNil) + c.Assert(exists, gc.Equals, false) + } + for i := range 2 { + newTuple := tuples[0] + newTuple.Target = ofganames.ConvertTag(names.NewModelTag(newModels[i])) + allowed, err = s.JIMM.OpenFGAClient.CheckRelation(ctx, newTuple, false) + c.Assert(err, gc.IsNil) + c.Assert(allowed, gc.Equals, true) + } +} diff --git a/internal/rebac_admin/identities_test.go b/internal/rebac_admin/identities_test.go new file mode 100644 index 000000000..d039b18f1 --- /dev/null +++ b/internal/rebac_admin/identities_test.go @@ -0,0 +1,223 @@ +// Copyright 2024 Canonical. + +package rebac_admin_test + +import ( + "context" + "errors" + "testing" + + "github.com/canonical/ofga" + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + qt "github.com/frankban/quicktest" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/common/utils" + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/jimmtest/mocks" + "github.com/canonical/jimm/v3/internal/openfga" + "github.com/canonical/jimm/v3/internal/rebac_admin" + "github.com/canonical/jimm/v3/pkg/api/params" +) + +func TestGetIdentity(t *testing.T) { + c := qt.New(t) + jimm := jimmtest.JIMM{ + 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 + } + return nil, dbmodel.IdentityCreationError + }, + } + user := openfga.User{} + user.JimmAdmin = true + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + identitySvc := rebac_admin.NewidentitiesService(&jimm) + + // test with user found + identity, err := identitySvc.GetIdentity(ctx, "bob@canonical.com") + c.Assert(err, qt.IsNil) + c.Assert(identity.Email, qt.Equals, "bob@canonical.com") + + // test with user not found + _, err = identitySvc.GetIdentity(ctx, "bob-not-found@canonical.com") + c.Assert(err, qt.ErrorMatches, "Not Found: User with id bob-not-found@canonical.com not found") +} + +func TestListIdentities(t *testing.T) { + testUsers := []openfga.User{ + *openfga.NewUser(&dbmodel.Identity{Name: "bob0@canonical.com"}, nil), + *openfga.NewUser(&dbmodel.Identity{Name: "bob1@canonical.com"}, nil), + *openfga.NewUser(&dbmodel.Identity{Name: "bob2@canonical.com"}, nil), + *openfga.NewUser(&dbmodel.Identity{Name: "bob3@canonical.com"}, nil), + *openfga.NewUser(&dbmodel.Identity{Name: "bob4@canonical.com"}, nil), + } + c := qt.New(t) + jimm := jimmtest.JIMM{ + ListIdentities_: func(ctx context.Context, user *openfga.User, filter pagination.LimitOffsetPagination) ([]openfga.User, error) { + start := filter.Offset() + end := start + filter.Limit() + if end > len(testUsers) { + end = len(testUsers) + } + return testUsers[start:end], nil + }, + CountIdentities_: func(ctx context.Context, user *openfga.User) (int, error) { + return len(testUsers), nil + }, + } + user := openfga.User{} + user.JimmAdmin = true + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + identitySvc := rebac_admin.NewidentitiesService(&jimm) + + testCases := []struct { + desc string + size *int + page *int + wantPage int + wantSize int + wantTotal int + wantNextpage *int + emails []string + }{ + { + desc: "test with first page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(0), + wantPage: 0, + wantSize: 2, + wantNextpage: utils.IntToPointer(1), + wantTotal: len(testUsers), + emails: []string{testUsers[0].Name, testUsers[1].Name}, + }, + { + desc: "test with second page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(1), + wantPage: 1, + wantSize: 2, + wantNextpage: utils.IntToPointer(2), + wantTotal: len(testUsers), + emails: []string{testUsers[2].Name, testUsers[3].Name}, + }, + { + desc: "test with last page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(2), + wantPage: 2, + wantSize: 1, + wantNextpage: nil, + wantTotal: len(testUsers), + emails: []string{testUsers[4].Name}, + }, + } + for _, t := range testCases { + c.Run(t.desc, func(c *qt.C) { + identities, err := identitySvc.ListIdentities(ctx, &resources.GetIdentitiesParams{ + Size: t.size, + Page: t.page, + }) + c.Assert(err, qt.IsNil) + c.Assert(*identities.Meta.Page, qt.Equals, t.wantPage) + c.Assert(identities.Meta.Size, qt.Equals, t.wantSize) + if t.wantNextpage == nil { + c.Assert(identities.Next.Page, qt.IsNil) + } else { + c.Assert(*identities.Next.Page, qt.Equals, *t.wantNextpage) + } + c.Assert(*identities.Meta.Total, qt.Equals, t.wantTotal) + c.Assert(identities.Data, qt.HasLen, len(t.emails)) + for i := range len(t.emails) { + c.Assert(identities.Data[i].Email, qt.Equals, t.emails[i]) + } + }) + } +} + +func TestGetIdentityGroups(t *testing.T) { + c := qt.New(t) + var listTuplesErr error + testTuple := openfga.Tuple{ + Object: &ofga.Entity{Kind: "user", ID: "foo"}, + Relation: ofga.Relation("member"), + Target: &ofga.Entity{Kind: "group", ID: "my-group"}, + } + jimm := jimmtest.JIMM{ + 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 + } + return nil, dbmodel.IdentityCreationError + }, + 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 + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + idSvc := rebac_admin.NewidentitiesService(&jimm) + + _, err := idSvc.GetIdentityGroups(ctx, "bob-not-found@canonical.com", &resources.GetIdentitiesItemGroupsParams{}) + c.Assert(err, qt.ErrorMatches, ".*not found") + username := "bob@canonical.com" + + res, err := idSvc.GetIdentityGroups(ctx, username, &resources.GetIdentitiesItemGroupsParams{}) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsNotNil) + c.Assert(res.Data, qt.HasLen, 1) + c.Assert(*res.Next.PageToken, qt.Equals, "continuation-token") + + listTuplesErr = errors.New("foo") + _, err = idSvc.GetIdentityGroups(ctx, username, &resources.GetIdentitiesItemGroupsParams{}) + c.Assert(err, qt.ErrorMatches, "foo") +} + +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" { + return openfga.NewUser(&dbmodel.Identity{Name: "bob@canonical.com"}, nil), nil + } + return nil, dbmodel.IdentityCreationError + }, + RelationService: mocks.RelationService{ + AddRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + RemoveRelation_: func(ctx context.Context, user *openfga.User, tuples []params.RelationshipTuple) error { + return patchTuplesErr + }, + }, + } + user := openfga.User{} + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, &user) + idSvc := rebac_admin.NewidentitiesService(&jimm) + + _, err := idSvc.PatchIdentityGroups(ctx, "bob-not-found@canonical.com", nil) + c.Assert(err, qt.ErrorMatches, ".* not found") + + username := "bob@canonical.com" + operations := []resources.IdentityGroupsPatchItem{ + {Group: "test-group1", Op: resources.IdentityGroupsPatchItemOpAdd}, + {Group: "test-group2", Op: resources.IdentityGroupsPatchItemOpRemove}, + } + res, err := idSvc.PatchIdentityGroups(ctx, username, operations) + c.Assert(err, qt.IsNil) + c.Assert(res, qt.IsTrue) + + patchTuplesErr = errors.New("foo") + _, err = idSvc.PatchIdentityGroups(ctx, username, operations) + c.Assert(err, qt.ErrorMatches, ".*foo") +} diff --git a/internal/rebac_admin/package_test.go b/internal/rebac_admin/package_test.go new file mode 100644 index 000000000..4c66d1819 --- /dev/null +++ b/internal/rebac_admin/package_test.go @@ -0,0 +1,13 @@ +// Copyright 2024 Canonical. + +package rebac_admin_test + +import ( + "testing" + + "gopkg.in/check.v1" +) + +func TestPackage(t *testing.T) { + check.TestingT(t) +} diff --git a/internal/rebac_admin/resources.go b/internal/rebac_admin/resources.go new file mode 100644 index 000000000..baca7aa19 --- /dev/null +++ b/internal/rebac_admin/resources.go @@ -0,0 +1,67 @@ +// Copyright 2024 Canonical. + +package rebac_admin + +import ( + "context" + + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/jujuapi" + "github.com/canonical/jimm/v3/internal/rebac_admin/utils" +) + +type resourcesService struct { + jimm jujuapi.JIMM +} + +func newResourcesService(jimm jujuapi.JIMM) *resourcesService { + return &resourcesService{ + jimm: jimm, + } +} + +// ListResources returns a page of Resource objects of at least `size` elements if available. +func (s *resourcesService) ListResources(ctx context.Context, params *resources.GetResourcesParams) (*resources.PaginatedResponse[resources.Resource], error) { + user, err := utils.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + currentPage, expectedPageSize, pagination := pagination.CreatePaginationWithoutTotal(params.Size, params.Page) + res, err := s.jimm.ListResources(ctx, user, pagination) + if err != nil { + return nil, err + } + nextPage, res := getNextPageAndResources(currentPage, expectedPageSize, res) + rRes := make([]resources.Resource, len(res)) + for i, u := range res { + rRes[i] = utils.ToRebacResource(u) + } + + return &resources.PaginatedResponse[resources.Resource]{ + Data: rRes, + Meta: resources.ResponseMeta{ + Page: ¤tPage, + Size: len(rRes), + Total: nil, + }, + Next: resources.Next{ + Page: nextPage, + }, + }, nil +} + +// getNextPageAndResources checks for the expectedPageSize of the resources. +// If there is enough records we return the records minus 1 and advice the consumer there is another page. +// Otherwise we return the records we have and set next page as empty. +func getNextPageAndResources(currentPage, expectedPageSize int, resources []db.Resource) (*int, []db.Resource) { + var nextPage *int + if len(resources) == expectedPageSize { + nPage := currentPage + 1 + nextPage = &nPage + resources = resources[:len(resources)-1] + } + return nextPage, resources +} diff --git a/internal/rebac_admin/resources_integration_test.go b/internal/rebac_admin/resources_integration_test.go new file mode 100644 index 000000000..dbb8bc352 --- /dev/null +++ b/internal/rebac_admin/resources_integration_test.go @@ -0,0 +1,151 @@ +// Copyright 2024 Canonical. +package rebac_admin_test + +import ( + "context" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + gc "gopkg.in/check.v1" + + "github.com/canonical/jimm/v3/internal/common/utils" + "github.com/canonical/jimm/v3/internal/jimmtest" + "github.com/canonical/jimm/v3/internal/rebac_admin" +) + +type resourcesSuite struct { + jimmtest.JIMMSuite +} + +var _ = gc.Suite(&resourcesSuite{}) + +// resourcesTestEnv is used to create entries in JIMM's database. +// The rebacAdminSuite does not spin up a Juju controller so we cannot use +// regular JIMM methods to create resources. +const resourcesTestEnv = `clouds: +- name: test-cloud + type: test-provider + regions: + - name: test-cloud-region +cloud-credentials: +- owner: alice@canonical.com + name: cred-1 + cloud: test-cloud +controllers: +- name: controller-1 + uuid: 00000001-0000-0000-0000-000000000001 + cloud: test-cloud + region: test-cloud-region +models: +- name: model-1 + uuid: 00000002-0000-0000-0000-000000000001 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-2 + uuid: 00000002-0000-0000-0000-000000000002 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +- name: model-3 + uuid: 00000003-0000-0000-0000-000000000003 + controller: controller-1 + cloud: test-cloud + region: test-cloud-region + cloud-credential: cred-1 + owner: alice@canonical.com +` + +func (s *resourcesSuite) TestListResources(c *gc.C) { + ctx := context.Background() + ctx = rebac_handlers.ContextWithIdentity(ctx, s.AdminUser) + resourcesSvc := rebac_admin.NewResourcesService(s.JIMM) + tester := jimmtest.GocheckTester{C: c} + env := jimmtest.ParseEnvironment(tester, resourcesTestEnv) + env.PopulateDB(tester, s.JIMM.Database) + type testEntity struct { + Id string + ParentId string + } + ids := make([]testEntity, 0) + for _, c := range env.Clouds { + ids = append(ids, testEntity{ + Id: c.Name, + ParentId: "", + }) + } + for _, c := range env.Controllers { + ids = append(ids, testEntity{ + Id: c.UUID, + ParentId: "", + }) + } + for _, m := range env.Models { + ids = append(ids, testEntity{ + Id: m.UUID, + ParentId: env.Controller(m.Controller).UUID, + }) + } + + testCases := []struct { + desc string + size *int + page *int + wantPage int + wantSize int + wantNextpage *int + ids []testEntity + }{ + { + desc: "test with first page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(0), + wantPage: 0, + wantSize: 2, + wantNextpage: utils.IntToPointer(1), + ids: []testEntity{ids[0], ids[1]}, + }, + { + desc: "test with second page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(1), + wantPage: 1, + wantSize: 2, + wantNextpage: utils.IntToPointer(2), + ids: []testEntity{ids[2], ids[3]}, + }, + { + desc: "test with last page", + size: utils.IntToPointer(2), + page: utils.IntToPointer(2), + wantPage: 2, + wantSize: 1, + wantNextpage: nil, + ids: []testEntity{ids[4]}, + }, + } + for _, t := range testCases { + resources, err := resourcesSvc.ListResources(ctx, &resources.GetResourcesParams{ + Size: t.size, + Page: t.page, + }) + c.Assert(err, gc.IsNil) + c.Assert(*resources.Meta.Page, gc.Equals, t.wantPage) + c.Assert(resources.Meta.Size, gc.Equals, t.wantSize) + if t.wantNextpage == nil { + c.Assert(resources.Next.Page, gc.IsNil) + } else { + c.Assert(*resources.Next.Page, gc.Equals, *t.wantNextpage) + } + for i := range len(t.ids) { + c.Assert(resources.Data[i].Entity.Id, gc.Equals, t.ids[i].Id) + if t.ids[i].ParentId != "" { + c.Assert(resources.Data[i].Parent.Id, gc.Equals, t.ids[i].ParentId) + } + } + } +} diff --git a/internal/rebac_admin/utils/auth.go b/internal/rebac_admin/utils/auth.go new file mode 100644 index 000000000..376a778e4 --- /dev/null +++ b/internal/rebac_admin/utils/auth.go @@ -0,0 +1,26 @@ +// Copyright 2024 Canonical. + +package utils + +import ( + "context" + "errors" + + rebac_handlers "github.com/canonical/rebac-admin-ui-handlers/v1" + + "github.com/canonical/jimm/v3/internal/openfga" +) + +// GetUserFromContext retrieves the OpenFGA user pointer from the context +// returning an error if it does not exist or is not the correct type. +func GetUserFromContext(ctx context.Context) (*openfga.User, error) { + raw, err := rebac_handlers.GetIdentityFromContext(ctx) + if err != nil { + return nil, err + } + user, ok := raw.(*openfga.User) + if !ok { + return nil, errors.New("unable to fetch authenticated user") + } + return user, nil +} diff --git a/internal/rebac_admin/utils/errors.go b/internal/rebac_admin/utils/errors.go new file mode 100644 index 000000000..7c4c86c4e --- /dev/null +++ b/internal/rebac_admin/utils/errors.go @@ -0,0 +1,27 @@ +// Copyright 2024 Canonical. +package utils + +import "errors" + +// MultiErr handles cases where multiple errors need to be collected. +type MultiErr struct { + errors []error +} + +// AppendError stores a new error on a slice of existing errors. +func (m *MultiErr) AppendError(err error) { + m.errors = append(m.errors, err) +} + +// Error returns a single error that is the concatention of all the collected errors. +func (m *MultiErr) Error() error { + return errors.Join(m.errors...) +} + +// String returns the string format of all collected errors. +func (m *MultiErr) String() string { + if err := m.Error(); err != nil { + return err.Error() + } + return "" +} diff --git a/internal/rebac_admin/utils/types.go b/internal/rebac_admin/utils/types.go new file mode 100644 index 000000000..3e02a7a3f --- /dev/null +++ b/internal/rebac_admin/utils/types.go @@ -0,0 +1,26 @@ +// Copyright 2024 Canonical. +package utils + +import ( + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + + "github.com/canonical/jimm/v3/internal/openfga" +) + +// ToEntityEntitlement converts an OpenFGA tuple to an entity entitlement. +func ToEntityEntitlement(tuple openfga.Tuple) resources.EntityEntitlement { + return resources.EntityEntitlement{ + Entitlement: string(tuple.Relation), + EntityId: tuple.Target.ID, + EntityType: tuple.Target.Kind.String(), + } +} + +// ToEntityEntitlements converts a slice of OpenFGA tuples to a slice of entity entitlements. +func ToEntityEntitlements(tuples []openfga.Tuple) []resources.EntityEntitlement { + entitlements := make([]resources.EntityEntitlement, 0, len(tuples)) + for _, t := range tuples { + entitlements = append(entitlements, ToEntityEntitlement(t)) + } + return entitlements +} diff --git a/internal/rebac_admin/utils/utils.go b/internal/rebac_admin/utils/utils.go new file mode 100644 index 000000000..3908b3d40 --- /dev/null +++ b/internal/rebac_admin/utils/utils.go @@ -0,0 +1,72 @@ +// Copyright 2024 Canonical. + +package utils + +import ( + "fmt" + "time" + + "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/juju/names/v5" + + "github.com/canonical/jimm/v3/internal/common/pagination" + "github.com/canonical/jimm/v3/internal/db" + "github.com/canonical/jimm/v3/internal/openfga" + jimmnames "github.com/canonical/jimm/v3/pkg/names" +) + +// FromUserToIdentity parses openfga.User into resources.Identity . +func FromUserToIdentity(user openfga.User) resources.Identity { + id := fmt.Sprintf("%d", user.ID) + joined := user.CreatedAt.Format(time.RFC3339) + lastLogin := user.LastLogin.Time.Format(time.RFC3339) + return resources.Identity{ + Email: user.Name, + Id: &id, + Joined: &joined, + LastLogin: &lastLogin, + Source: "", + } +} + +// ToRebacResource parses db.Resource into resources.Resource. +func ToRebacResource(res db.Resource) resources.Resource { + r := resources.Resource{ + Entity: resources.Entity{ + Id: res.ID.String, + Name: res.Name, + Type: res.Type, + }, + } + // the parent is populated only for models and application offers. + // the parent type is set empty from the query. + if res.ParentType != "" { + r.Parent = &resources.Entity{ + Id: res.ParentId.String, + Name: res.ParentName, + Type: res.ParentType, + } + } + return r +} + +// CreateTokenPaginationFilter returns a token pagination filter based on the rebac admin request parameters. +func CreateTokenPaginationFilter(size *int, token, tokenFromHeader *string) pagination.OpenFGAPagination { + pageSize := 0 + if size != nil { + pageSize = *size + } + var pageToken string + if tokenFromHeader != nil { + pageToken = *tokenFromHeader + } else if token != nil { + pageToken = *token + } + return pagination.NewOpenFGAFilter(pageSize, pageToken) +} + +// ValidateDecomposedTag validates that a kind and ID are a valid Juju or JIMM tag. +func ValidateDecomposedTag(kind string, id string) (names.Tag, error) { + rawTag := kind + "-" + id + return jimmnames.ParseTag(rawTag) +} diff --git a/internal/rpc/client_test.go b/internal/rpc/client_test.go index 79cfebed0..209133f96 100644 --- a/internal/rpc/client_test.go +++ b/internal/rpc/client_test.go @@ -266,7 +266,7 @@ func TestProxySockets(t *testing.T) { LoginService: &mockLoginService{}, } err := rpc.ProxySockets(ctx, proxyHelpers) - c.Check(err, qt.ErrorMatches, "error reading from (client|controller).*") + c.Check(err, qt.IsNil) errChan <- err return err }) @@ -298,6 +298,68 @@ func TestProxySockets(t *testing.T) { <-errChan // Ensure go routines are cleaned up } +func TestProxySocketsControllerConnectionFails(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + + srvController := newServer(echo) + + var connController *websocket.Conn + errChan := make(chan error) + srvJIMM := newServer(func(connClient *websocket.Conn) error { + testTokenGen := testTokenGenerator{} + f := func(context.Context) (rpc.WebsocketConnectionWithMetadata, error) { + var err error + connController, err = srvController.dialer.DialWebsocket(ctx, srvController.URL) + c.Check(err, qt.IsNil) + return rpc.WebsocketConnectionWithMetadata{ + Conn: connController, + ModelName: "TestName", + }, nil + } + auditLogger := func(ale *dbmodel.AuditLogEntry) {} + proxyHelpers := rpc.ProxyHelpers{ + ConnClient: connClient, + TokenGen: &testTokenGen, + ConnectController: f, + AuditLog: auditLogger, + LoginService: &mockLoginService{}, + } + err := rpc.ProxySockets(ctx, proxyHelpers) + c.Check(err, qt.IsNil) + errChan <- err + return err + }) + + defer srvController.Close() + defer srvJIMM.Close() + ws, err := srvJIMM.dialer.DialWebsocket(ctx, srvJIMM.URL) + c.Assert(err, qt.IsNil) + defer ws.Close() + + p := json.RawMessage(`{"Key":"TestVal"}`) + msg := rpc.Message{RequestID: 1, Type: "TestType", Request: "TestReq", Params: p} + err = ws.WriteJSON(&msg) + c.Assert(err, qt.IsNil) + resp := rpc.Message{} + receiveChan := make(chan error) + go func() { + receiveChan <- ws.ReadJSON(&resp) + }() + select { + case err := <-receiveChan: + c.Assert(err, qt.IsNil) + case <-time.After(5 * time.Second): + c.Logf("took too long to read response") + c.FailNow() + } + c.Assert(resp.Response, qt.DeepEquals, msg.Params) + + // Now close the connection to the controller and ensure the model proxy is cleaned up. + connController.Close() + <-errChan // Ensure go routines are cleaned up +} + func TestCancelProxySockets(t *testing.T) { c := qt.New(t) @@ -368,7 +430,7 @@ func TestProxySocketsAuditLogs(t *testing.T) { LoginService: &mockLoginService{}, } err := rpc.ProxySockets(ctx, proxyHelpers) - c.Check(err, qt.ErrorMatches, `error reading from (client|controller).*`) + c.Check(err, qt.IsNil) errChan <- err return err }) diff --git a/internal/rpc/proxy.go b/internal/rpc/proxy.go index 60d7db1ce..79d5a09bb 100644 --- a/internal/rpc/proxy.go +++ b/internal/rpc/proxy.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/gorilla/websocket" "github.com/juju/juju/rpc/params" "github.com/juju/names/v5" "github.com/juju/zaputil/zapctx" @@ -121,22 +122,18 @@ func ProxySockets(ctx context.Context, helpers ProxyHelpers) error { }() var err error select { - // No cleanup is needed on error, when the client closes the connection - // all go routines will proceed to error and exit. case err = <-errChan: - zapctx.Debug(ctx, "Proxy error", zap.Error(err)) + if err != nil { + zapctx.Debug(ctx, "Proxy error", zap.Error(err)) + } case <-ctx.Done(): err = errors.E(op, "Context cancelled") zapctx.Debug(ctx, "Context cancelled") - helpers.ConnClient.Close() - clProxy.mu.Lock() - clProxy.closed = true - // TODO(Kian): Test removing close on dst below. The client connection should do it. - if clProxy.dst != nil { - clProxy.dst.conn.Close() - } - clProxy.mu.Unlock() } + // Close the client connection to ensure everything is cleaned up. + // Normally the client would do this but we also do it here in case the + // connection to the controller fails and we want to trigger cleanup. + helpers.ConnClient.Close() clProxy.wg.Wait() return err } @@ -316,16 +313,22 @@ func (p *modelProxy) auditLogMessage(msg *message, isResponse bool) error { return nil } +func unexpectedReadError(err error) bool { + closeError := websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseNoStatusReceived, + websocket.CloseAbnormalClosure) + _, unmarshalError := err.(*json.InvalidUnmarshalError) + return closeError || unmarshalError +} + // clientProxy proxies messages from client->controller. type clientProxy struct { modelProxy wg sync.WaitGroup errChan chan error createControllerConn func(context.Context) (WebsocketConnectionWithMetadata, error) - // mu synchronises changes to closed and modelproxy.dst, dst is is only created - // at some unspecified point in the future after a client request. - mu sync.Mutex - closed bool + connectController sync.Once } // start begins the client->controller proxier. @@ -339,8 +342,11 @@ func (p *clientProxy) start(ctx context.Context) error { zapctx.Debug(ctx, "Reading on client connection") msg := new(message) if err := p.src.readJson(&msg); err != nil { - // Error reading on the socket implies it is closed, simply return. - return fmt.Errorf("error reading from client: %w", err) + if unexpectedReadError(err) { + zapctx.Error(ctx, "unexpected client read error", zap.Error(err)) + return err + } + return nil } zapctx.Debug(ctx, "Read message from client", zap.Any("message", msg)) err := p.makeControllerConnection(ctx) @@ -387,43 +393,35 @@ func (p *clientProxy) start(ctx context.Context) error { // proxying requests from the controller to the client. func (p *clientProxy) makeControllerConnection(ctx context.Context) error { const op = errors.Op("rpc.makeControllerConnection") - p.mu.Lock() - defer p.mu.Unlock() - if p.dst != nil { - return nil - } - // Checking closed ensures we don't have a race condition with a cancelled context. - if p.closed { - err := errors.E(op, "Client connection closed while starting controller connection") - return err - } - connWithMetadata, err := p.createControllerConn(ctx) - if err != nil { - return err - } - - p.msgs.controllerUUID = connWithMetadata.ControllerUUID + var createConnErr error + // Create the controller connection once. + p.connectController.Do(func() { + connWithMetadata, err := p.createControllerConn(ctx) + if err != nil { + createConnErr = errors.E(op, err) + } - p.modelName = connWithMetadata.ModelName - p.dst = &writeLockConn{conn: connWithMetadata.Conn} - controllerToClient := controllerProxy{ - modelProxy: modelProxy{ - src: p.dst, - dst: p.src, - msgs: p.msgs, - auditLog: p.auditLog, - tokenGen: p.tokenGen, - modelName: p.modelName, - conversationId: p.conversationId, - }, - } - p.wg.Add(1) - go func() { - defer p.wg.Done() - p.errChan <- controllerToClient.start(ctx) - }() - zapctx.Debug(ctx, "Successfully made controller connection") - return nil + p.msgs.controllerUUID = connWithMetadata.ControllerUUID + p.modelName = connWithMetadata.ModelName + p.dst = &writeLockConn{conn: connWithMetadata.Conn} + controllerToClient := controllerProxy{ + modelProxy: modelProxy{ + src: p.dst, + dst: p.src, + msgs: p.msgs, + auditLog: p.auditLog, + tokenGen: p.tokenGen, + modelName: p.modelName, + conversationId: p.conversationId, + }, + } + p.wg.Add(1) + go func() { + defer p.wg.Done() + p.errChan <- controllerToClient.start(ctx) + }() + }) + return createConnErr } // controllerProxy proxies messages from controller->client with the caveat that @@ -438,8 +436,11 @@ func (p *controllerProxy) start(ctx context.Context) error { zapctx.Debug(ctx, "Reading on controller connection") msg := new(message) if err := p.src.readJson(msg); err != nil { - // Error reading on the socket implies it is closed, simply return. - return fmt.Errorf("error reading from controller: %w", err) + if unexpectedReadError(err) { + zapctx.Error(ctx, "unexpected controller read error", zap.Error(err)) + return err + } + return nil } zapctx.Debug(ctx, "Received message from controller", zap.Any("Message", msg)) permissionsRequired, err := checkPermissionsRequired(ctx, msg) diff --git a/local/jimm/setup-service-account.sh b/local/jimm/setup-service-account.sh index b18229a0c..254b379dd 100755 --- a/local/jimm/setup-service-account.sh +++ b/local/jimm/setup-service-account.sh @@ -1,6 +1,7 @@ #!/bin/bash # This script is used to setup a service account by adding a set of cloud-credentials. +# The service account is also made an admin of JIMM. # Default values below assume a lxd controller is added to JIMM. set -eux @@ -11,3 +12,4 @@ CREDENTIAL_NAME="${CREDENTIAL_NAME:-localhost}" juju add-service-account "$SERVICE_ACCOUNT_ID" juju update-service-account-credential "$SERVICE_ACCOUNT_ID" "$CLOUD" "$CREDENTIAL_NAME" +jimmctl auth relation add user-"$SERVICE_ACCOUNT_ID"@serviceaccount administrator controller-jimm diff --git a/openfga/auth_model.go b/openfga/auth_model.go index 32105d6da..543e3c8aa 100644 --- a/openfga/auth_model.go +++ b/openfga/auth_model.go @@ -9,4 +9,7 @@ import ( ) //go:embed authorisation_model.json -var AuthModelFile []byte +var AuthModelJSON []byte + +//go:embed authorisation_model.fga +var AuthModelDSL []byte diff --git a/pkg/api/client.go b/pkg/api/client.go index b84a75e7e..a736e515e 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -134,9 +134,9 @@ func (c *Client) RemoveGroup(req *params.RemoveGroupRequest) error { } // ListGroups lists the groups in JIMM. -func (c *Client) ListGroups() ([]params.Group, error) { +func (c *Client) ListGroups(req *params.ListGroupsRequest) ([]params.Group, error) { var resp params.ListGroupResponse - err := c.caller.APICall("JIMM", 4, "", "ListGroups", nil, &resp) + err := c.caller.APICall("JIMM", 4, "", "ListGroups", req, &resp) return resp.Groups, err } diff --git a/pkg/api/params/params.go b/pkg/api/params/params.go index f890c2020..4f917f30f 100644 --- a/pkg/api/params/params.go +++ b/pkg/api/params/params.go @@ -300,6 +300,11 @@ type RemoveGroupRequest struct { Name string `json:"name"` } +type ListGroupsRequest struct { + Limit int `json:"limit"` + Offset int `json:"offset"` +} + // Group holds the details of a group currently residing in JIMM. type Group struct { UUID string `json:"uuid" yaml:"uuid"`