diff --git a/cmd/jimmctl/cmd/jimmsuite_test.go b/cmd/jimmctl/cmd/jimmsuite_test.go index c1683ee7b..cde10ea91 100644 --- a/cmd/jimmctl/cmd/jimmsuite_test.go +++ b/cmd/jimmctl/cmd/jimmsuite_test.go @@ -102,8 +102,6 @@ func (s *jimmSuite) SetUpTest(c *gc.C) { s.HTTP.StartTLS() - s.Service.RegisterJwksCache(ctx) - // NOW we can set up the juju conn suites s.ControllerConfigAttrs = map[string]interface{}{ "login-token-refresh-url": u.String() + "/.well-known/jwks.json", diff --git a/cmd/jimmsrv/main.go b/cmd/jimmsrv/main.go index 4b3c6831b..60701d2ea 100644 --- a/cmd/jimmsrv/main.go +++ b/cmd/jimmsrv/main.go @@ -112,10 +112,6 @@ func start(ctx context.Context, s *service.Service) error { if _, ok := os.LookupEnv("INSECURE_SECRET_STORAGE"); ok { insecureSecretStorage = true } - insecureJwksLookup := false - if _, ok := os.LookupEnv("INSECURE_JWKS_LOOKUP"); ok { - insecureJwksLookup = true - } jimmsvc, err := jimm.NewService(ctx, jimm.Params{ ControllerUUID: os.Getenv("JIMM_UUID"), DSN: os.Getenv("JIMM_DSN"), @@ -143,7 +139,6 @@ func start(ctx context.Context, s *service.Service) error { MacaroonExpiryDuration: macaroonExpiryDuration, JWTExpiryDuration: jwtExpiryDuration, InsecureSecretStorage: insecureSecretStorage, - InsecureJwksLookup: insecureJwksLookup, OAuthAuthenticatorParams: jimm.OAuthAuthenticatorParams{ IssuerURL: issuerURL, DeviceClientID: deviceClientID, @@ -182,7 +177,6 @@ func start(ctx context.Context, s *service.Service) error { httpsrv.Shutdown(ctx) }) s.Go(httpsrv.ListenAndServe) - jimmsvc.RegisterJwksCache(ctx) zapctx.Info(ctx, "Successfully started JIMM server") return nil } diff --git a/docker-compose.yaml b/docker-compose.yaml index 2a1d149c4..5978d8c1a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -55,7 +55,6 @@ services: VAULT_PATH: "/jimm-kv/" VAULT_SECRET_FILE: "/vault/approle.json" VAULT_AUTH_PATH: "/auth/approle/login" - INSECURE_JWKS_LOOKUP: "enabled" # Note: By default we should use Vault as that is the primary means of secret storage. # INSECURE_SECRET_STORAGE: "enabled" # JIMM_DASHBOARD_LOCATION: "" @@ -248,7 +247,7 @@ services: ports: - "8082:8082" healthcheck: - test: [ "CMD", "curl", "http://localhost:8082/realms/jimm/.well-known/openid-configuration" ] + test: [ "CMD", "curl", "http://localhost:8082/health/ready" ] interval: 5s - timeout: 5s + timeout: 10s retries: 30 diff --git a/go.mod b/go.mod index e87104107..ee3546836 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( github.com/rogpeppe/fastuuid v1.2.0 go.uber.org/zap v1.24.0 golang.org/x/net v0.17.0 // indirect - golang.org/x/sync v0.4.0 + golang.org/x/sync v0.5.0 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c gopkg.in/macaroon-bakery.v2 v2.3.0 gopkg.in/macaroon.v2 v2.1.0 @@ -50,10 +50,10 @@ require ( github.com/dustinkirkland/golang-petname v0.0.0-20230626224747-e794b9370d49 github.com/go-chi/chi/v5 v5.0.8 github.com/go-chi/render v1.0.2 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/itchyny/gojq v0.12.12 github.com/juju/charm/v11 v11.0.2 - github.com/juju/clock v1.0.3 - github.com/juju/retry v1.0.0 + github.com/lestrrat-go/iter v1.0.2 github.com/lestrrat-go/jwx/v2 v2.0.11 github.com/oklog/ulid/v2 v2.1.0 github.com/stretchr/testify v1.8.4 @@ -184,17 +184,18 @@ require ( github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgproto3/v2 v2.3.1 // indirect - github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/pgtype v1.12.0 // indirect github.com/jhump/protoreflect v1.8.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.1 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/juju/aclstore/v2 v2.1.0 // indirect github.com/juju/ansiterm v1.0.0 // indirect github.com/juju/blobstore/v3 v3.0.2 // indirect + github.com/juju/clock v1.0.3 // indirect github.com/juju/collections v1.0.4 // indirect github.com/juju/description/v4 v4.0.11 // indirect github.com/juju/featureflag v1.0.0 // indirect @@ -220,6 +221,7 @@ require ( github.com/juju/pubsub/v2 v2.0.0 // indirect github.com/juju/ratelimit v1.0.2 // indirect github.com/juju/replicaset/v3 v3.0.1 // indirect + github.com/juju/retry v1.0.0 // indirect github.com/juju/rfc/v2 v2.0.0 // indirect github.com/juju/romulus v1.0.0 // indirect github.com/juju/schema v1.0.1 // indirect @@ -240,7 +242,6 @@ require ( github.com/lestrrat-go/blackmagic v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.4 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat/go-jspointer v0.0.0-20160229021354-f4881e611bdb // indirect github.com/lestrrat/go-jsref v0.0.0-20160601013240-e452c7b5801d // indirect @@ -341,11 +342,11 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/mock v0.2.0 // indirect go.uber.org/multierr v1.8.0 // indirect - golang.org/x/crypto v0.14.0 // indirect + golang.org/x/crypto v0.16.0 // indirect golang.org/x/mod v0.13.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/term v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.14.0 // indirect google.golang.org/api v0.126.0 // indirect diff --git a/go.sum b/go.sum index 9d51f21f0..21298371b 100644 --- a/go.sum +++ b/go.sum @@ -905,6 +905,8 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= @@ -983,8 +985,9 @@ github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwX github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y= github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= @@ -1030,8 +1033,9 @@ github.com/jhump/protoreflect v1.8.2 h1:k2xE7wcUomeqwY0LDCYA16y4WWfyTcMx5mKhk0d4 github.com/jhump/protoreflect v1.8.2/go.mod h1:7GcYQDdMU/O/BBrl/cX6PNHpXh6cenjd8pneu5yW7Tg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -2003,8 +2007,8 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -2157,8 +2161,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= -golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -2281,8 +2285,8 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -2291,8 +2295,8 @@ golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2305,8 +2309,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/jimm/access.go b/internal/jimm/access.go index e1422852d..6b535759b 100644 --- a/internal/jimm/access.go +++ b/internal/jimm/access.go @@ -4,19 +4,59 @@ package jimm import ( "context" + "database/sql" "fmt" + "regexp" + "strconv" + "strings" "sync" + "github.com/google/uuid" + "github.com/juju/juju/core/crossmodel" jujuparams "github.com/juju/juju/rpc/params" "github.com/juju/names/v4" + "github.com/juju/zaputil" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" + "gorm.io/gorm" + "github.com/canonical/jimm/internal/db" "github.com/canonical/jimm/internal/dbmodel" "github.com/canonical/jimm/internal/errors" "github.com/canonical/jimm/internal/jimmjwx" "github.com/canonical/jimm/internal/openfga" ofganames "github.com/canonical/jimm/internal/openfga/names" + jimmnames "github.com/canonical/jimm/pkg/names" +) + +const ( + jimmControllerName = "jimm" +) + +var ( + // Matches juju uris, jimm user/group tags and UUIDs + // Performs a single match and breaks the juju URI into 10 groups, each successive group is XORD to ensure we can run + // this just once. + // The groups are as so: + // [0] - Entire match + // [1] - tag + // [2] - A single "-", ignored + // [3] - Controller name OR user name OR group name + // [4] - A single ":", ignored + // [5] - Controller user / model owner + // [6] - A single "/", ignored + // [7] - Model name + // [8] - A single ".", ignored + // [9] - Application offer name + // [10] - Relation specifier (i.e., #member) + // A complete matcher example would look like so with square-brackets denoting groups and paranthsis denoting index: + // (1)[controller](2)[-](3)[controller-1](4)[:](5)[alice@external-place](6)[/](7)[model-1](8)[.](9)[offer-1](10)[#relation-specifier]" + // In the case of something like: user-alice@wonderland or group-alices-wonderland#member, it would look like so: + // (1)[user](2)[-](3)[alices@wonderland] + // (1)[group](2)[-](3)[alices-wonderland](10)[#member] + // So if a group, user, UUID, controller name comes in, it will always be index 3 for them + // and if a relation specifier is present, it will always be index 10 + jujuURIMatcher = regexp.MustCompile(`([a-zA-Z0-9]*)(\-|\z)([a-zA-Z0-9-@.]*)(\:|)([a-zA-Z0-9-@]*)(\/|)([a-zA-Z0-9-]*)(\.|)([a-zA-Z0-9-]*)([a-zA-Z#]*|\z)\z`) ) // ToOfferAccessString maps relation to an application offer access string. @@ -353,3 +393,331 @@ func (j *JIMM) RevokeAuditLogAccess(ctx context.Context, user *openfga.User, tar } return nil } + +// ToJAASTag converts a tag used in OpenFGA authorization model to a +// tag used in JAAS. +func (j *JIMM) ToJAASTag(ctx context.Context, tag *ofganames.Tag) (string, error) { + switch tag.Kind { + case names.UserTagKind: + return names.UserTagKind + "-" + tag.ID, nil + case names.ControllerTagKind: + if tag.ID == j.ResourceTag().Id() { + return "controller-jimm", nil + } + controller := dbmodel.Controller{ + UUID: tag.ID, + } + err := j.Database.GetController(ctx, &controller) + if err != nil { + return "", errors.E(err, fmt.Sprintf("failed to fetch controller information: %s", controller.UUID)) + } + controllerString := names.ControllerTagKind + "-" + controller.Name + if tag.Relation.String() != "" { + controllerString = controllerString + "#" + tag.Relation.String() + } + return controllerString, nil + case names.ModelTagKind: + model := dbmodel.Model{ + UUID: sql.NullString{ + String: tag.ID, + Valid: true, + }, + } + err := j.Database.GetModel(ctx, &model) + if err != nil { + return "", errors.E(err, "failed to fetch model information") + } + modelString := names.ModelTagKind + "-" + model.Controller.Name + ":" + model.OwnerUsername + "/" + model.Name + if tag.Relation.String() != "" { + modelString = modelString + "#" + tag.Relation.String() + } + return modelString, nil + case names.ApplicationOfferTagKind: + ao := dbmodel.ApplicationOffer{ + UUID: tag.ID, + } + err := j.Database.GetApplicationOffer(ctx, &ao) + if err != nil { + return "", errors.E(err, "failed to fetch application offer information") + } + aoString := names.ApplicationOfferTagKind + "-" + ao.Model.Controller.Name + ":" + ao.Model.OwnerUsername + "/" + ao.Model.Name + "." + ao.Name + if tag.Relation.String() != "" { + aoString = aoString + "#" + tag.Relation.String() + } + return aoString, nil + case jimmnames.GroupTagKind: + id, err := strconv.ParseUint(tag.ID, 10, 32) + if err != nil { + return "", errors.E(err, fmt.Sprintf("failed to parse group id: %v", tag.ID)) + } + group := dbmodel.GroupEntry{ + Model: gorm.Model{ + ID: uint(id), + }, + } + err = j.Database.GetGroup(ctx, &group) + if err != nil { + return "", errors.E(err, "failed to fetch group information") + } + groupString := jimmnames.GroupTagKind + "-" + group.Name + if tag.Relation.String() != "" { + groupString = groupString + "#" + tag.Relation.String() + } + return groupString, nil + case names.CloudTagKind: + cloud := dbmodel.Cloud{ + Name: tag.ID, + } + err := j.Database.GetCloud(ctx, &cloud) + if err != nil { + return "", errors.E(err, "failed to fetch group information") + } + cloudString := names.CloudTagKind + "-" + cloud.Name + if tag.Relation.String() != "" { + cloudString = cloudString + "#" + tag.Relation.String() + } + return cloudString, nil + default: + return "", errors.E(fmt.Sprintf("unexpected tag kind: %v", tag.Kind)) + } +} + +// resolveTag resolves JIMM tag [of any kind available] (i.e., controller-mycontroller:alex@external/mymodel.myoffer) +// into a juju string tag (i.e., controller-). +// +// If the JIMM tag is aleady of juju string tag form, the transformation is left alone. +// +// In both cases though, the resource the tag pertains to is validated to exist within the database. +func resolveTag(jimmUUID string, db *db.Database, tag string) (*ofganames.Tag, error) { + ctx := context.Background() + matches := jujuURIMatcher.FindStringSubmatch(tag) + resourceUUID := "" + trailer := "" + // We first attempt to see if group3 is a uuid + if _, err := uuid.Parse(matches[3]); err == nil { + // We know it's a UUID + resourceUUID = matches[3] + } else { + // We presume it's a user or a group + trailer = matches[3] + } + + // Matchers along the way to determine segments of the string, they'll be empty + // if the match has failed + controllerName := matches[3] + userName := matches[5] + modelName := matches[7] + offerName := matches[9] + relationString := strings.TrimLeft(matches[10], "#") + relation, err := ofganames.ParseRelation(relationString) + if err != nil { + return nil, errors.E("failed to parse relation", errors.CodeBadRequest) + } + + switch matches[1] { + case names.UserTagKind: + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: user", + zap.String("user-name", trailer), + ) + return ofganames.ConvertTagWithRelation(names.NewUserTag(trailer), relation), nil + + case jimmnames.GroupTagKind: + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: group", + zap.String("group-name", trailer), + ) + entry := &dbmodel.GroupEntry{ + Name: trailer, + } + err := db.GetGroup(ctx, entry) + if err != nil { + return nil, errors.E("group not found") + } + return ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(strconv.FormatUint(uint64(entry.ID), 10)), relation), nil + + case names.ControllerTagKind: + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: controller", + ) + controller := dbmodel.Controller{} + + if resourceUUID != "" { + controller.UUID = resourceUUID + } else if controllerName != "" { + if controllerName == jimmControllerName { + return ofganames.ConvertTagWithRelation(names.NewControllerTag(jimmUUID), relation), nil + } + controller.Name = controllerName + } + + // NOTE (alesstimec) Do we need to special-case the + // controller-jimm case - jimm controller does not exist + // in the database, but has a clearly defined UUID? + + err := db.GetController(ctx, &controller) + if err != nil { + return nil, errors.E("controller not found") + } + return ofganames.ConvertTagWithRelation(names.NewControllerTag(controller.UUID), relation), nil + + case names.ModelTagKind: + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: model", + ) + model := dbmodel.Model{} + + if resourceUUID != "" { + model.UUID = sql.NullString{String: resourceUUID, Valid: true} + } else if controllerName != "" && userName != "" && modelName != "" { + controller := dbmodel.Controller{Name: controllerName} + err := db.GetController(ctx, &controller) + if err != nil { + return nil, errors.E("controller not found") + } + model.ControllerID = controller.ID + model.OwnerUsername = userName + model.Name = modelName + } + + err := db.GetModel(ctx, &model) + if err != nil { + return nil, errors.E("model not found") + } + + return ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), relation), nil + + case names.ApplicationOfferTagKind: + zapctx.Debug( + ctx, + "Resolving JIMM tags to Juju tags for tag kind: applicationoffer", + ) + offer := dbmodel.ApplicationOffer{} + + if resourceUUID != "" { + offer.UUID = resourceUUID + } else if controllerName != "" && userName != "" && modelName != "" && offerName != "" { + offerURL, err := crossmodel.ParseOfferURL(fmt.Sprintf("%s:%s/%s.%s", controllerName, userName, modelName, offerName)) + if err != nil { + zapctx.Debug(ctx, "failed to parse application offer url", zap.String("url", fmt.Sprintf("%s:%s/%s.%s", controllerName, userName, modelName, offerName)), zaputil.Error(err)) + return nil, errors.E("failed to parse offer url", err) + } + offer.URL = offerURL.String() + } + + err := db.GetApplicationOffer(ctx, &offer) + if err != nil { + return nil, errors.E("application offer not found") + } + + return ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), relation), nil + } + return nil, errors.E("failed to map tag " + matches[1]) +} + +// ParseTag 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") + tupleKeySplit := strings.SplitN(key, "-", 2) + if len(tupleKeySplit) < 2 { + return nil, errors.E(op, errors.CodeFailedToParseTupleKey, "tag does not have tuple key delimiter") + } + tagString := key + tag, err := resolveTag(j.UUID, &j.Database, tagString) + if err != nil { + zapctx.Debug(ctx, "failed to resolve tuple object", zap.Error(err)) + return nil, errors.E(op, errors.CodeFailedToResolveTupleResource, err) + } + zapctx.Debug(ctx, "resolved JIMM tag", zap.String("tag", tag.String())) + + return tag, nil +} + +// AddGroup creates a group within JIMMs DB for reference by OpenFGA. +func (j *JIMM) AddGroup(ctx context.Context, user *openfga.User, name string) error { + const op = errors.Op("jimm.AddGroup") + + if !user.JimmAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + if err := j.Database.AddGroup(ctx, name); err != nil { + return errors.E(op, err) + } + return 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") + + if !user.JimmAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + group := &dbmodel.GroupEntry{ + Name: oldName, + } + err := j.Database.GetGroup(ctx, group) + if err != nil { + return errors.E(op, err) + } + group.Name = newName + + if err := j.Database.UpdateGroup(ctx, group); err != nil { + return errors.E(op, err) + } + return nil +} + +// RemoveGroup removes a group within JIMMs DB for reference by OpenFGA. +func (j *JIMM) RemoveGroup(ctx context.Context, user *openfga.User, name string) error { + const op = errors.Op("jimm.RemoveGroup") + + if !user.JimmAdmin { + return errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + group := &dbmodel.GroupEntry{ + Name: name, + } + err := j.Database.GetGroup(ctx, group) + if err != nil { + return errors.E(op, err) + } + err = j.OpenFGAClient.RemoveGroup(ctx, group.ResourceTag()) + if err != nil { + return errors.E(op, err) + } + + if err := j.Database.RemoveGroup(ctx, group); err != nil { + return errors.E(op, err) + } + return nil +} + +// ListGroups returns a list of groups known to JIMM. +func (j *JIMM) ListGroups(ctx context.Context, user *openfga.User) ([]dbmodel.GroupEntry, error) { + const op = errors.Op("jimm.ListGroups") + + if !user.JimmAdmin { + return nil, errors.E(op, errors.CodeUnauthorized, "unauthorized") + } + + var groups []dbmodel.GroupEntry + err := j.Database.ForEachGroup(ctx, func(ge *dbmodel.GroupEntry) error { + groups = append(groups, *ge) + return nil + }) + if err != nil { + return nil, errors.E(op, err) + } + return groups, nil +} diff --git a/internal/jimm/access_test.go b/internal/jimm/access_test.go index 5bea4814d..6d4091ec3 100644 --- a/internal/jimm/access_test.go +++ b/internal/jimm/access_test.go @@ -4,9 +4,19 @@ package jimm_test import ( "context" + "database/sql" + "sort" "testing" "time" + petname "github.com/dustinkirkland/golang-petname" + qt "github.com/frankban/quicktest" + "github.com/google/uuid" + "github.com/juju/juju/core/crossmodel" + jujuparams "github.com/juju/juju/rpc/params" + "github.com/juju/names/v4" + + "github.com/canonical/jimm/internal/constants" "github.com/canonical/jimm/internal/db" "github.com/canonical/jimm/internal/dbmodel" "github.com/canonical/jimm/internal/errors" @@ -15,10 +25,8 @@ import ( "github.com/canonical/jimm/internal/jimmtest" "github.com/canonical/jimm/internal/openfga" ofganames "github.com/canonical/jimm/internal/openfga/names" - qt "github.com/frankban/quicktest" - "github.com/google/uuid" - jujuparams "github.com/juju/juju/rpc/params" - "github.com/juju/names/v4" + jimmnames "github.com/canonical/jimm/pkg/names" + "github.com/canonical/ofga" ) // testAuthenticator is an authenticator implementation intended @@ -449,3 +457,682 @@ func TestJWTGeneratorMakeToken(t *testing.T) { } } } + +func TestParseTag(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, _, controller, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + + jimmTag := "model-" + controller.Name + ":" + user.Username + "/" + model.Name + "#administrator" + + // JIMM tag syntax for models + tag, err := j.ParseTag(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) + c.Assert(tag.Relation.String(), qt.Equals, "administrator") + + jujuTag := "model-" + model.UUID.String + "#administrator" + + // Juju tag syntax for models + tag, err = j.ParseTag(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") +} + +func TestResolveJIMM(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) + + jimmTag := "controller-jimm" + + jujuTag, err := jimm.ResolveTag(j.UUID, &j.Database, jimmTag) + c.Assert(err, qt.IsNil) + c.Assert(jujuTag, qt.DeepEquals, ofganames.ConvertTag(names.NewControllerTag(j.UUID))) +} + +func TestResolveTupleObjectMapsApplicationOffersUUIDs(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, _, controller, model, offer, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + + jimmTag := "applicationoffer-" + controller.Name + ":" + user.Username + "/" + model.Name + "." + offer.Name + "#administrator" + + jujuTag, err := jimm.ResolveTag(j.UUID, &j.Database, jimmTag) + c.Assert(err, qt.IsNil) + c.Assert(jujuTag, qt.DeepEquals, ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), ofganames.AdministratorRelation)) +} + +func TestResolveTupleObjectMapsModelUUIDs(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, _, controller, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + + jimmTag := "model-" + controller.Name + ":" + user.Username + "/" + model.Name + "#administrator" + + tag, err := jimm.ResolveTag(j.UUID, &j.Database, jimmTag) + c.Assert(err, qt.IsNil) + c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), ofganames.AdministratorRelation)) +} + +func TestResolveTupleObjectMapsControllerUUIDs(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) + + cloud := dbmodel.Cloud{ + Name: "test-cloud", + } + err = j.Database.AddCloud(context.Background(), &cloud) + c.Assert(err, qt.IsNil) + + uuid, _ := uuid.NewRandom() + controller := dbmodel.Controller{ + Name: "mycontroller", + UUID: uuid.String(), + CloudName: "test-cloud", + } + err = j.Database.AddController(ctx, &controller) + c.Assert(err, qt.IsNil) + + tag, err := jimm.ResolveTag(j.UUID, &j.Database, "controller-mycontroller#administrator") + c.Assert(err, qt.IsNil) + c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(names.NewControllerTag(uuid.String()), ofganames.AdministratorRelation)) +} + +func TestResolveTupleObjectMapsGroups(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) + + err = j.Database.AddGroup(ctx, "myhandsomegroupofdigletts") + c.Assert(err, qt.IsNil) + group := &dbmodel.GroupEntry{ + Name: "myhandsomegroupofdigletts", + } + err = j.Database.GetGroup(ctx, group) + c.Assert(err, qt.IsNil) + tag, err := jimm.ResolveTag(j.UUID, &j.Database, "group-"+group.Name+"#member") + c.Assert(err, qt.IsNil) + c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag("1"), ofganames.MemberRelation)) +} + +func TestResolveTagObjectMapsUsers(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) + + tag, err := jimm.ResolveTag(j.UUID, &j.Database, "user-alex@externally-werly#member") + c.Assert(err, qt.IsNil) + c.Assert(tag, qt.DeepEquals, ofganames.ConvertTagWithRelation(names.NewUserTag("alex@externally-werly"), ofganames.MemberRelation)) +} + +func TestResolveTupleObjectHandlesErrors(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, offer, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + + type test struct { + input string + want string + } + + tests := []test{ + // Resolves bad tuple objects in general + { + input: "unknowntag-blabla", + want: "failed to map tag unknowntag", + }, + // Resolves bad groups where they do not exist + { + input: "group-myspecialpokemon-his-name-is-youguessedit-diglett", + want: "group not found", + }, + // Resolves bad controllers where they do not exist + { + input: "controller-mycontroller-that-does-not-exist", + want: "controller not found", + }, + // Resolves bad models where the user cannot be obtained from the JIMM tag + { + input: "model-mycontroller-that-does-not-exist/mymodel", + want: "model not found", + }, + // Resolves bad models where it cannot be found on the specified controller + { + input: "model-" + controller.Name + ":alex/", + want: "model not found", + }, + // Resolves bad applicationoffers where it cannot be found on the specified controller/model combo + { + input: "applicationoffer-" + controller.Name + ":alex/" + model.Name + "." + offer.Name + "fluff", + want: "application offer not found", + }, + } + for _, tc := range tests { + _, err := jimm.ResolveTag(j.UUID, &j.Database, tc.input) + c.Assert(err, qt.ErrorMatches, tc.want) + } +} + +// createTestControllerEnvironment is a utility function creating the necessary components of adding a: +// - user +// - user group +// - controller +// - model +// - application offer +// - cloud +// - cloud credential +// +// Into the test database, returning the dbmodels to be utilised for values within tests. +// +// It returns all of the latter, but in addition to those, also: +// - an api client to make calls to an httptest instance of the server +// - a closure containing a function to close the connection +// +// TODO(ale8k): Make this an implicit thing on the JIMM suite per test & refactor the current state. +// and make the suite argument an interface of the required calls we use here. +func createTestControllerEnvironment(ctx context.Context, c *qt.C, db db.Database) ( + dbmodel.User, + dbmodel.GroupEntry, + dbmodel.Controller, + dbmodel.Model, + dbmodel.ApplicationOffer, + dbmodel.Cloud, + dbmodel.CloudCredential) { + + err := db.AddGroup(ctx, "test-group") + c.Assert(err, qt.IsNil) + group := dbmodel.GroupEntry{Name: "test-group"} + err = db.GetGroup(ctx, &group) + c.Assert(err, qt.IsNil) + + u := dbmodel.User{ + Username: petname.Generate(2, "-") + "@external", + } + c.Assert(db.DB.Create(&u).Error, qt.IsNil) + + cloud := dbmodel.Cloud{ + Name: petname.Generate(2, "-"), + Type: "aws", + Regions: []dbmodel.CloudRegion{{ + Name: petname.Generate(2, "-"), + }}, + } + c.Assert(db.DB.Create(&cloud).Error, qt.IsNil) + id, _ := uuid.NewRandom() + controller := dbmodel.Controller{ + Name: petname.Generate(2, "-"), + UUID: id.String(), + CloudName: cloud.Name, + CloudRegion: cloud.Regions[0].Name, + CloudRegions: []dbmodel.CloudRegionControllerPriority{{ + Priority: 0, + CloudRegionID: cloud.Regions[0].ID, + }}, + } + err = db.AddController(ctx, &controller) + c.Assert(err, qt.IsNil) + + cred := dbmodel.CloudCredential{ + Name: petname.Generate(2, "-"), + CloudName: cloud.Name, + OwnerUsername: u.Username, + AuthType: "empty", + } + err = db.SetCloudCredential(ctx, &cred) + c.Assert(err, qt.IsNil) + + model := dbmodel.Model{ + Name: petname.Generate(2, "-"), + UUID: sql.NullString{ + String: id.String(), + Valid: true, + }, + OwnerUsername: u.Username, + ControllerID: controller.ID, + CloudRegionID: cloud.Regions[0].ID, + CloudCredentialID: cred.ID, + Life: constants.ALIVE.String(), + Status: dbmodel.Status{ + Status: "available", + Since: sql.NullTime{ + Time: time.Now().UTC().Truncate(time.Millisecond), + Valid: true, + }, + }, + } + + err = db.AddModel(ctx, &model) + c.Assert(err, qt.IsNil) + + offerName := petname.Generate(2, "-") + offerURL, err := crossmodel.ParseOfferURL(controller.Name + ":" + u.Username + "/" + model.Name + "." + offerName) + c.Assert(err, qt.IsNil) + + offer := dbmodel.ApplicationOffer{ + UUID: id.String(), + Name: offerName, + ModelID: model.ID, + ApplicationName: petname.Generate(2, "-"), + URL: offerURL.String(), + } + err = db.AddApplicationOffer(context.Background(), &offer) + c.Assert(err, qt.IsNil) + c.Assert(len(offer.UUID), qt.Equals, 36) + + return u, group, controller, model, offer, cloud, cred +} + +func TestAddGroup(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 := openfga.NewUser(&user, ofgaClient) + u.JimmAdmin = true + + err = j.AddGroup(ctx, u, "test-group-1") + c.Assert(err, qt.IsNil) + + err = j.AddGroup(ctx, u, "test-group-1") + c.Assert(errors.ErrorCode(err), qt.Equals, errors.CodeAlreadyExists) +} + +func TestRemoveGroup(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, group, _, _, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + u := openfga.NewUser(&user, ofgaClient) + u.JimmAdmin = true + + err = j.RemoveGroup(ctx, u, group.Name) + c.Assert(err, qt.IsNil) + + err = j.RemoveGroup(ctx, u, group.Name) + c.Assert(errors.ErrorCode(err), qt.Equals, errors.CodeNotFound) +} + +func TestRemoveGroupRemovesTuples(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, group, controller, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + + err = j.Database.AddGroup(ctx, "test-group2") + c.Assert(err, qt.IsNil) + + group2 := &dbmodel.GroupEntry{ + Name: "test-group2", + } + err = j.Database.GetGroup(ctx, group2) + c.Assert(err, qt.IsNil) + + tuples := []openfga.Tuple{ + //This tuple should remain as it has no relation to group2 + { + Object: ofganames.ConvertTag(user.ResourceTag()), + Relation: "member", + Target: ofganames.ConvertTag(group.ResourceTag()), + }, + // Below tuples should all be removed as they relate to group2 + { + Object: ofganames.ConvertTag(user.ResourceTag()), + Relation: "member", + Target: ofganames.ConvertTag(group2.ResourceTag()), + }, + { + Object: ofganames.ConvertTagWithRelation(group2.ResourceTag(), ofganames.MemberRelation), + Relation: "member", + Target: ofganames.ConvertTag(group.ResourceTag()), + }, + { + Object: ofganames.ConvertTagWithRelation(group2.ResourceTag(), ofganames.MemberRelation), + Relation: "administrator", + Target: ofganames.ConvertTag(controller.ResourceTag()), + }, + { + Object: ofganames.ConvertTagWithRelation(group2.ResourceTag(), ofganames.MemberRelation), + Relation: "writer", + Target: ofganames.ConvertTag(model.ResourceTag()), + }, + } + + err = ofgaClient.AddRelation(ctx, tuples...) + c.Assert(err, qt.IsNil) + + u := openfga.NewUser(&user, ofgaClient) + u.JimmAdmin = true + + err = j.RemoveGroup(ctx, u, group.Name) + c.Assert(err, qt.IsNil) + + err = j.RemoveGroup(ctx, u, group.Name) + c.Assert(errors.ErrorCode(err), qt.Equals, errors.CodeNotFound) + + remainingTuples, _, err := ofgaClient.ReadRelatedObjects(ctx, ofga.Tuple{}, 0, "") + c.Assert(err, qt.IsNil) + c.Assert(remainingTuples, qt.HasLen, 3) + + err = j.RemoveGroup(ctx, u, group2.Name) + c.Assert(err, qt.IsNil) + + remainingTuples, _, err = ofgaClient.ReadRelatedObjects(ctx, ofga.Tuple{}, 0, "") + c.Assert(err, qt.IsNil) + c.Assert(remainingTuples, qt.HasLen, 0) +} + +func TestRenameGroup(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, group, controller, model, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + + u := openfga.NewUser(&user, ofgaClient) + u.JimmAdmin = true + + tuples := []openfga.Tuple{ + { + Object: ofganames.ConvertTag(user.ResourceTag()), + Relation: "member", + Target: ofganames.ConvertTag(group.ResourceTag()), + }, + { + Object: ofganames.ConvertTagWithRelation(group.ResourceTag(), ofganames.MemberRelation), + Relation: "administrator", + Target: ofganames.ConvertTag(controller.ResourceTag()), + }, + { + Object: ofganames.ConvertTagWithRelation(group.ResourceTag(), ofganames.MemberRelation), + Relation: "writer", + Target: ofganames.ConvertTag(model.ResourceTag()), + }, + } + + err = ofgaClient.AddRelation(ctx, tuples...) + c.Assert(err, qt.IsNil) + + err = j.RenameGroup(ctx, u, group.Name, "test-new-group") + c.Assert(err, qt.IsNil) + + group.Name = "test-new-group" + + // check the user still has member relation to the group + allowed, err := ofgaClient.CheckRelation( + ctx, + ofga.Tuple{ + Object: ofganames.ConvertTag(u.ResourceTag()), + Relation: "member", + Target: ofganames.ConvertTag(group.ResourceTag()), + }, + false, + ) + c.Assert(err, qt.IsNil) + c.Assert(allowed, qt.IsTrue) + + // check the user still has writer relation to the model via the + // group membership + allowed, err = ofgaClient.CheckRelation( + ctx, + ofga.Tuple{ + Object: ofganames.ConvertTag(u.ResourceTag()), + Relation: "writer", + Target: ofganames.ConvertTag(model.ResourceTag()), + }, + false, + ) + c.Assert(err, qt.IsNil) + c.Assert(allowed, qt.IsTrue) + + // check the user still has administrator relation to the controller + // via group membership + allowed, err = ofgaClient.CheckRelation( + ctx, + ofga.Tuple{ + Object: ofganames.ConvertTag(u.ResourceTag()), + Relation: "administrator", + Target: ofganames.ConvertTag(controller.ResourceTag()), + }, + false, + ) + c.Assert(err, qt.IsNil) + c.Assert(allowed, qt.IsTrue) +} + +func TestListGroups(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, group, _, _, _, _, _ := createTestControllerEnvironment(ctx, c, j.Database) + + u := openfga.NewUser(&user, ofgaClient) + u.JimmAdmin = true + + groups, err := j.ListGroups(ctx, u) + c.Assert(err, qt.IsNil) + c.Assert(groups, qt.DeepEquals, []dbmodel.GroupEntry{group}) + + groupNames := []string{ + "test-group0", + "test-group1", + "test-group2", + "aaaFinalGroup", + } + + for _, name := range groupNames { + err := j.AddGroup(ctx, u, name) + c.Assert(err, qt.IsNil) + } + + groups, err = j.ListGroups(ctx, u) + 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) + // 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) + c.Assert(groups[2].Name, qt.Equals, "test-group0") + c.Assert(groups[3].Name, qt.Equals, "test-group1") + c.Assert(groups[4].Name, qt.Equals, "test-group2") +} diff --git a/internal/jimm/export_test.go b/internal/jimm/export_test.go index b302efcd7..e83385f47 100644 --- a/internal/jimm/export_test.go +++ b/internal/jimm/export_test.go @@ -19,6 +19,7 @@ var ( NewControllerClient = &newControllerClient FillMigrationTarget = fillMigrationTarget InitiateMigration = &initiateMigration + ResolveTag = resolveTag ) func WatchController(w *Watcher, ctx context.Context, ctl *dbmodel.Controller) error { diff --git a/internal/jimm/jimm_test.go b/internal/jimm/jimm_test.go index f55d330f0..bd8ca1dd4 100644 --- a/internal/jimm/jimm_test.go +++ b/internal/jimm/jimm_test.go @@ -80,6 +80,10 @@ func TestFindAuditEvents(t *testing.T) { events[i] = e } + found, err := j.FindAuditEvents(context.Background(), admin, db.AuditLogFilter{}) + c.Assert(err, qt.IsNil) + c.Assert(found, qt.HasLen, len(events)) + tests := []struct { about string users []*openfga.User diff --git a/internal/jimm/model_test.go b/internal/jimm/model_test.go index 5f479878c..21ca4367f 100644 --- a/internal/jimm/model_test.go +++ b/internal/jimm/model_test.go @@ -3565,11 +3565,8 @@ clouds: regions: - name: test-region-1 - name: test-region-2 - users: - - user: alice@external - access: add-model -user-defaults: -- user: alice@external +users: +- username: alice@external controller-access: superuser cloud-credentials: - name: test-credential-1 diff --git a/internal/jimm/user.go b/internal/jimm/user.go index e88061a27..f166fb66e 100644 --- a/internal/jimm/user.go +++ b/internal/jimm/user.go @@ -59,3 +59,25 @@ func (j *JIMM) Authenticate(ctx context.Context, req *jujuparams.LoginRequest) ( u.JimmAdmin = isJimmAdmin return u, nil } + +// GetUser fetches the user specified by the username and returns +// an openfga User that can be used to verify user's permissions. +func (j *JIMM) GetUser(ctx context.Context, username string) (*openfga.User, error) { + const op = errors.Op("jimm.GetUser") + + user := dbmodel.User{ + Username: username, + } + if err := j.Database.GetUser(ctx, &user); err != nil { + return nil, err + } + u := openfga.NewUser(&user, j.OpenFGAClient) + + isJimmAdmin, err := openfga.IsAdministrator(ctx, u, j.ResourceTag()) + if err != nil { + return nil, errors.E(op, err) + } + u.JimmAdmin = isJimmAdmin + + return u, nil +} diff --git a/internal/jimmjwx/jwt.go b/internal/jimmjwx/jwt.go index f8ff1789a..033c5c523 100644 --- a/internal/jimmjwx/jwt.go +++ b/internal/jimmjwx/jwt.go @@ -6,15 +6,12 @@ import ( "context" "crypto/x509" "encoding/pem" - "fmt" - "net/http" "time" "github.com/canonical/jimm/internal/errors" "github.com/canonical/jimm/internal/jimm/credentials" "github.com/google/uuid" - "github.com/juju/clock" - "github.com/juju/retry" + "github.com/hashicorp/golang-lru/v2/expirable" "github.com/juju/zaputil/zapctx" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" @@ -24,17 +21,52 @@ import ( type JWTServiceParams struct { Host string - Secure bool Store credentials.CredentialStore Expiry time.Duration } +// JwksGetter provides a Get method to fetch the JWK set. +type JwksGetter interface { + Get(ctx context.Context) (jwk.Set, error) +} + +// CredentialCache provides a cache that will periodically fetch the JWK set from a credential store. +type CredentialCache struct { + credentials.CredentialStore + c *expirable.LRU[string, jwk.Set] +} + +// NewCredentialCache creates a new CredentialCache used for storing and periodically fetching +// a JWK set from the provided credential store. +// Note that the cache duration is configured at 1h, which should be much lower than the +// rotation period of the JWK set. +func NewCredentialCache(credentialStore credentials.CredentialStore) CredentialCache { + cache := expirable.NewLRU[string, jwk.Set](1, nil, time.Duration(1*time.Hour)) + return CredentialCache{CredentialStore: credentialStore, c: cache} +} + +const jwksCacheKey = "jwks" + +// Get implements JwksGetter.Get +func (v CredentialCache) Get(ctx context.Context) (jwk.Set, error) { + if val, ok := v.c.Get(jwksCacheKey); ok { + return val, nil + } + ks, err := v.CredentialStore.GetJWKS(ctx) + if err != nil { + return nil, err + } + v.c.Add(jwksCacheKey, ks) + return ks, nil +} + // JWTService manages the creation of JWTs that are intended to be issued // by JIMM. type JWTService struct { JWTServiceParams - - Cache *jwk.Cache + // JWKS is the JSON Web Key Set containing the public key used for verifying + // signed JWT tokens. + JWKS JwksGetter } // JWTParams are the necessary params to issue a ready-to-go JWT targeted @@ -50,44 +82,8 @@ type JWTParams struct { // NewJWTService returns a new JWT service for handling JIMMs JWTs. func NewJWTService(p JWTServiceParams) *JWTService { - return &JWTService{JWTServiceParams: p} -} - -// RegisterJWKSCache registers a cache to refresh the public key persisted by JIMM's -// JWKSService. It calls JIMM's JWKSService endpoint the same as any other ordinary -// client would. -func (j *JWTService) RegisterJWKSCache(ctx context.Context, client *http.Client) { - j.Cache = jwk.NewCache(ctx) - - _ = j.Cache.Register(j.getJWKSEndpoint(j.Secure), jwk.WithHTTPClient(client)) - - err := retry.Call(retry.CallArgs{ - Func: func() error { - zapctx.Info(ctx, "cache refresh") - select { - case <-ctx.Done(): - return nil - default: - } - if _, err := j.Cache.Refresh(ctx, j.getJWKSEndpoint(j.Secure)); err != nil { - zapctx.Debug(ctx, "Refresh error", zap.Error(err), zap.String("URL", j.getJWKSEndpoint(j.Secure))) - return err - } - return nil - }, - Attempts: 10, - Delay: 2 * time.Second, - Clock: clock.WallClock, - Stop: ctx.Done(), - }) - select { - case <-ctx.Done(): - zapctx.Info(ctx, "context cancelled stopping jwks registration gracefully", zap.Error(err)) - default: - if err != nil { - panic(err.Error()) - } - } + vaultCache := NewCredentialCache(p.Store) + return &JWTService{JWTServiceParams: p, JWKS: vaultCache} } // NewJWT creates a new JWT to represent a users access within a controller. @@ -106,12 +102,8 @@ func (j *JWTService) NewJWT(ctx context.Context, params JWTParams) ([]byte, erro } zapctx.Debug(ctx, "issuing a new JWT", zap.Any("params", params)) - if j == nil || j.Cache == nil { - zapctx.Debug(ctx, "JwtService struct value", zap.String("JwtService", fmt.Sprintf("%+v", j))) - return nil, errors.E("nil pointer in JWT service") - } - jwkSet, err := j.Cache.Get(ctx, j.getJWKSEndpoint(j.Secure)) + jwkSet, err := j.JWKS.Get(ctx) if err != nil { return nil, err } @@ -179,11 +171,3 @@ func (j *JWTService) generateJTI(ctx context.Context) (string, error) { } return id.String(), nil } - -func (j *JWTService) getJWKSEndpoint(secure bool) string { - scheme := "https://" - if !secure { - scheme = "http://" - } - return scheme + j.Host + "/.well-known/jwks.json" -} diff --git a/internal/jimmjwx/jwt_test.go b/internal/jimmjwx/jwt_test.go index 899d7d5e4..a0459f818 100644 --- a/internal/jimmjwx/jwt_test.go +++ b/internal/jimmjwx/jwt_test.go @@ -9,6 +9,8 @@ import ( "github.com/canonical/jimm/internal/jimmjwx" qt "github.com/frankban/quicktest" + "github.com/lestrrat-go/iter/arrayiter" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ) @@ -29,14 +31,10 @@ func TestRegisterJWKSCacheRegistersTheCacheSuccessfully(t *testing.T) { jwtService := jimmjwx.NewJWTService(jimmjwx.JWTServiceParams{ Host: u.Host, Store: store, - Secure: true, Expiry: time.Minute, }) - // Test RegisterJWKSCache does register the public key just setup - jwtService.RegisterJWKSCache(ctx, srv.Client()) - - set, err := jwtService.Cache.Get(ctx, "https://"+u.Host+"/.well-known/jwks.json") + set, err := jwtService.JWKS.Get(ctx) c.Assert(err, qt.IsNil) c.Assert(set.Len(), qt.Equals, 1) } @@ -65,11 +63,8 @@ func TestNewJWTIsParsableByExponent(t *testing.T) { jwtService := jimmjwx.NewJWTService(jimmjwx.JWTServiceParams{ Host: u.Host, Store: store, - Secure: true, Expiry: time.Minute, }) - // Setup JWKS Cache - jwtService.RegisterJWKSCache(ctx, srv.Client()) // Mint a new JWT tok, err := jwtService.NewJWT(ctx, jimmjwx.JWTParams{ @@ -83,7 +78,7 @@ func TestNewJWTIsParsableByExponent(t *testing.T) { c.Assert(err, qt.IsNil) // Retrieve pubkey from cache - set, err := jwtService.Cache.Get(ctx, "https://"+u.Host+"/.well-known/jwks.json") + set, err := jwtService.JWKS.Get(ctx) c.Assert(err, qt.IsNil) // Test the token parses @@ -129,11 +124,8 @@ func TestNewJWTExpires(t *testing.T) { jwtService := jimmjwx.NewJWTService(jimmjwx.JWTServiceParams{ Host: u.Host, Store: store, - Secure: true, Expiry: time.Nanosecond, }) - // Setup JWKS Cache - jwtService.RegisterJWKSCache(ctx, srv.Client()) // Mint a new JWT tok, err := jwtService.NewJWT(ctx, jimmjwx.JWTParams{ @@ -147,7 +139,7 @@ func TestNewJWTExpires(t *testing.T) { c.Assert(err, qt.IsNil) // Retrieve pubkey from cache - set, err := jwtService.Cache.Get(ctx, "https://"+u.Host+"/.well-known/jwks.json") + set, err := jwtService.JWKS.Get(ctx) c.Assert(err, qt.IsNil) time.Sleep(time.Nanosecond * 10) @@ -159,3 +151,28 @@ func TestNewJWTExpires(t *testing.T) { ) c.Assert(err, qt.ErrorMatches, `"exp" not satisfied`) } + +func TestCredentialCache(t *testing.T) { + c := qt.New(t) + store := newStore(c) + ctx := context.Background() + set, _, err := jimmjwx.GenerateJWK(ctx) + c.Assert(err, qt.IsNil) + store.PutJWKS(ctx, set) + vaultCache := jimmjwx.NewCredentialCache(store) + gotSet, err := vaultCache.Get(ctx) + c.Assert(err, qt.IsNil) + c.Assert(gotSet.Len(), qt.Not(qt.Equals), 0) + expectedKeyPairs := getKeyPairs(ctx, set) + wantKeyPairs := getKeyPairs(ctx, gotSet) + c.Assert(expectedKeyPairs, qt.DeepEquals, wantKeyPairs) +} + +func getKeyPairs(ctx context.Context, set jwk.Set) []*arrayiter.Pair { + res := make([]*arrayiter.Pair, 0) + iterator := set.Keys(ctx) + for val := iterator.Pair(); iterator.Next(ctx); val = iterator.Pair() { + res = append(res, val) + } + return res +} diff --git a/internal/jimmtest/suite.go b/internal/jimmtest/suite.go index 926cd64b0..863079bcb 100644 --- a/internal/jimmtest/suite.go +++ b/internal/jimmtest/suite.go @@ -134,10 +134,8 @@ func (s *JIMMSuite) SetUpTest(c *gc.C) { s.JIMM.JWTService = jimmjwx.NewJWTService(jimmjwx.JWTServiceParams{ Host: u.Host, Store: s.JIMM.CredentialStore, - Secure: false, Expiry: time.Minute, }) - s.JIMM.JWTService.RegisterJWKSCache(ctx, s.Server.Client()) s.JIMM.Dialer = &jujuclient.Dialer{ JWTService: s.JIMM.JWTService, } diff --git a/internal/jujuapi/access_control.go b/internal/jujuapi/access_control.go index d7757a038..9a713f28e 100644 --- a/internal/jujuapi/access_control.go +++ b/internal/jujuapi/access_control.go @@ -4,23 +4,15 @@ package jujuapi import ( "context" - "database/sql" - "fmt" "regexp" "strconv" - "strings" + "time" - "github.com/google/uuid" - "github.com/juju/juju/core/crossmodel" - "github.com/juju/names/v4" "github.com/juju/zaputil" "github.com/juju/zaputil/zapctx" "go.uber.org/zap" - "gorm.io/gorm" apiparams "github.com/canonical/jimm/api/params" - "github.com/canonical/jimm/internal/db" - "github.com/canonical/jimm/internal/dbmodel" "github.com/canonical/jimm/internal/errors" "github.com/canonical/jimm/internal/openfga" ofganames "github.com/canonical/jimm/internal/openfga/names" @@ -67,11 +59,7 @@ func (r *controllerRoot) AddGroup(ctx context.Context, req apiparams.AddGroupReq return errors.E(op, errors.CodeBadRequest, "invalid group name") } - if !r.user.JimmAdmin { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - if err := r.jimm.DB().AddGroup(ctx, req.Name); err != nil { + if err := r.jimm.AddGroup(ctx, r.user, req.Name); err != nil { zapctx.Error(ctx, "failed to add group", zaputil.Error(err)) return errors.E(op, err) } @@ -86,20 +74,7 @@ func (r *controllerRoot) RenameGroup(ctx context.Context, req apiparams.RenameGr return errors.E(op, errors.CodeBadRequest, "invalid group name") } - if !r.user.JimmAdmin { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - group := &dbmodel.GroupEntry{ - Name: req.Name, - } - err := r.jimm.DB().GetGroup(ctx, group) - if err != nil { - return errors.E(op, err) - } - group.Name = req.NewName - - if err := r.jimm.DB().UpdateGroup(ctx, group); err != nil { + if err := r.jimm.RenameGroup(ctx, r.user, req.Name, req.NewName); err != nil { zapctx.Error(ctx, "failed to rename group", zaputil.Error(err)) return errors.E(op, err) } @@ -110,23 +85,7 @@ func (r *controllerRoot) RenameGroup(ctx context.Context, req apiparams.RenameGr func (r *controllerRoot) RemoveGroup(ctx context.Context, req apiparams.RemoveGroupRequest) error { const op = errors.Op("jujuapi.RemoveGroup") - if !r.user.JimmAdmin { - return errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - group := &dbmodel.GroupEntry{ - Name: req.Name, - } - err := r.jimm.DB().GetGroup(ctx, group) - if err != nil { - return errors.E(op, err) - } - err = r.jimm.AuthorizationClient().RemoveGroup(ctx, group.ResourceTag()) - if err != nil { - return errors.E(op, err) - } - - if err := r.jimm.DB().RemoveGroup(ctx, group); err != nil { + if err := r.jimm.RemoveGroup(ctx, r.user, req.Name); err != nil { zapctx.Error(ctx, "failed to remove group", zaputil.Error(err)) return errors.E(op, err) } @@ -137,178 +96,20 @@ func (r *controllerRoot) RemoveGroup(ctx context.Context, req apiparams.RemoveGr func (r *controllerRoot) ListGroups(ctx context.Context) (apiparams.ListGroupResponse, error) { const op = errors.Op("jujuapi.ListGroups") - if !r.user.JimmAdmin { - return apiparams.ListGroupResponse{}, errors.E(op, errors.CodeUnauthorized, "unauthorized") - } - - var groups []apiparams.Group - err := r.jimm.DB().ForEachGroup(ctx, func(ctl *dbmodel.GroupEntry) error { - groups = append(groups, ctl.ToAPIGroupEntry()) - return nil - }) + groups, err := r.jimm.ListGroups(ctx, r.user) if err != nil { return apiparams.ListGroupResponse{}, errors.E(op, err) } - - return apiparams.ListGroupResponse{Groups: groups}, nil -} - -// resolveTag resolves JIMM tag [of any kind available] (i.e., controller-mycontroller:alex@external/mymodel.myoffer) -// into a juju string tag (i.e., controller-). -// -// If the JIMM tag is aleady of juju string tag form, the transformation is left alone. -// -// In both cases though, the resource the tag pertains to is validated to exist within the database. -func resolveTag(jimmUUID string, db *db.Database, tag string) (*ofganames.Tag, error) { - ctx := context.Background() - matches := jujuURIMatcher.FindStringSubmatch(tag) - resourceUUID := "" - trailer := "" - // We first attempt to see if group3 is a uuid - if _, err := uuid.Parse(matches[3]); err == nil { - // We know it's a UUID - resourceUUID = matches[3] - } else { - // We presume it's a user or a group - trailer = matches[3] - } - - // Matchers along the way to determine segments of the string, they'll be empty - // if the match has failed - controllerName := matches[3] - userName := matches[5] - modelName := matches[7] - offerName := matches[9] - relationString := strings.TrimLeft(matches[10], "#") - relation, err := ofganames.ParseRelation(relationString) - if err != nil { - return nil, errors.E("failed to parse relation", errors.CodeBadRequest) - } - - switch matches[1] { - case names.UserTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: user", - zap.String("user-name", trailer), - ) - return ofganames.ConvertTagWithRelation(names.NewUserTag(trailer), relation), nil - - case jimmnames.GroupTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: group", - zap.String("group-name", trailer), - ) - entry := &dbmodel.GroupEntry{ - Name: trailer, - } - err := db.GetGroup(ctx, entry) - if err != nil { - return nil, errors.E("group not found") - } - return ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag(strconv.FormatUint(uint64(entry.ID), 10)), relation), nil - - case names.ControllerTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: controller", - ) - controller := dbmodel.Controller{} - - if resourceUUID != "" { - controller.UUID = resourceUUID - } else if controllerName != "" { - if controllerName == jimmControllerName { - return ofganames.ConvertTagWithRelation(names.NewControllerTag(jimmUUID), relation), nil - } - controller.Name = controllerName - } - - // NOTE (alesstimec) Do we need to special-case the - // controller-jimm case - jimm controller does not exist - // in the database, but has a clearly defined UUID? - - err := db.GetController(ctx, &controller) - if err != nil { - return nil, errors.E("controller not found") - } - return ofganames.ConvertTagWithRelation(names.NewControllerTag(controller.UUID), relation), nil - - case names.ModelTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: model", - ) - model := dbmodel.Model{} - - if resourceUUID != "" { - model.UUID = sql.NullString{String: resourceUUID, Valid: true} - } else if controllerName != "" && userName != "" && modelName != "" { - controller := dbmodel.Controller{Name: controllerName} - err := db.GetController(ctx, &controller) - if err != nil { - return nil, errors.E("controller not found") - } - model.ControllerID = controller.ID - model.OwnerUsername = userName - model.Name = modelName - } - - err := db.GetModel(ctx, &model) - if err != nil { - return nil, errors.E("model not found") + groupsResponse := make([]apiparams.Group, len(groups)) + for i, g := range groups { + groupsResponse[i] = apiparams.Group{ + Name: g.Name, + CreatedAt: g.CreatedAt.Format(time.RFC3339), + UpdatedAt: g.UpdatedAt.Format(time.RFC3339), } - - return ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), relation), nil - - case names.ApplicationOfferTagKind: - zapctx.Debug( - ctx, - "Resolving JIMM tags to Juju tags for tag kind: applicationoffer", - ) - offer := dbmodel.ApplicationOffer{} - - if resourceUUID != "" { - offer.UUID = resourceUUID - } else if controllerName != "" && userName != "" && modelName != "" && offerName != "" { - offerURL, err := crossmodel.ParseOfferURL(fmt.Sprintf("%s:%s/%s.%s", controllerName, userName, modelName, offerName)) - if err != nil { - zapctx.Debug(ctx, "failed to parse application offer url", zap.String("url", fmt.Sprintf("%s:%s/%s.%s", controllerName, userName, modelName, offerName)), zaputil.Error(err)) - return nil, errors.E("failed to parse offer url", err) - } - offer.URL = offerURL.String() - } - - err := db.GetApplicationOffer(ctx, &offer) - if err != nil { - return nil, errors.E("application offer not found") - } - - return ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), relation), nil } - return nil, errors.E("failed to map tag " + matches[1]) -} - -// parseTag 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 parseTag(ctx context.Context, jimmUUID string, db *db.Database, key string) (*ofganames.Tag, error) { - op := errors.Op("jujuapi.parseTag") - tupleKeySplit := strings.SplitN(key, "-", 2) - if len(tupleKeySplit) < 2 { - return nil, errors.E(op, errors.CodeFailedToParseTupleKey, "tag does not have tuple key delimiter") - } - tagString := key - tag, err := resolveTag(jimmUUID, db, tagString) - if err != nil { - zapctx.Debug(ctx, "failed to resolve tuple object", zap.Error(err)) - return nil, errors.E(op, errors.CodeFailedToResolveTupleResource, err) - } - zapctx.Debug(ctx, "resolved JIMM tag", zap.String("tag", tag.String())) - return tag, nil + return apiparams.ListGroupResponse{Groups: groupsResponse}, nil } // AddRelation creates a tuple between two objects [if applicable] @@ -421,14 +222,14 @@ func (r *controllerRoot) parseTuple(ctx context.Context, tuple apiparams.Relatio return nil, errors.E(op, errors.CodeBadRequest, "target object not specified") } if tuple.TargetObject != "" { - targetTag, err := parseTag(ctx, r.jimm.ResourceTag().Id(), r.jimm.DB(), tuple.TargetObject) + targetTag, err := r.jimm.ParseTag(ctx, tuple.TargetObject) if err != nil { return nil, parseTagError("failed to parse tuple target object key", tuple.TargetObject, err) } t.Target = targetTag } if tuple.Object != "" { - objectTag, err := parseTag(ctx, r.jimm.ResourceTag().Id(), r.jimm.DB(), tuple.Object) + objectTag, err := r.jimm.ParseTag(ctx, tuple.Object) if err != nil { return nil, parseTagError("failed to parse tuple object key", tuple.Object, err) } @@ -438,92 +239,6 @@ func (r *controllerRoot) parseTuple(ctx context.Context, tuple apiparams.Relatio return &t, nil } -func (r *controllerRoot) toJAASTag(ctx context.Context, tag *ofganames.Tag) (string, error) { - switch tag.Kind { - case names.UserTagKind: - return names.UserTagKind + "-" + tag.ID, nil - case names.ControllerTagKind: - if tag.ID == r.jimm.ResourceTag().Id() { - return "controller-jimm", nil - } - controller := dbmodel.Controller{ - UUID: tag.ID, - } - err := r.jimm.DB().GetController(ctx, &controller) - if err != nil { - return "", errors.E(err, fmt.Sprintf("failed to fetch controller information: %s", controller.UUID)) - } - controllerString := names.ControllerTagKind + "-" + controller.Name - if tag.Relation.String() != "" { - controllerString = controllerString + "#" + tag.Relation.String() - } - return controllerString, nil - case names.ModelTagKind: - model := dbmodel.Model{ - UUID: sql.NullString{ - String: tag.ID, - Valid: true, - }, - } - err := r.jimm.DB().GetModel(ctx, &model) - if err != nil { - return "", errors.E(err, "failed to fetch model information") - } - modelString := names.ModelTagKind + "-" + model.Controller.Name + ":" + model.OwnerUsername + "/" + model.Name - if tag.Relation.String() != "" { - modelString = modelString + "#" + tag.Relation.String() - } - return modelString, nil - case names.ApplicationOfferTagKind: - ao := dbmodel.ApplicationOffer{ - UUID: tag.ID, - } - err := r.jimm.DB().GetApplicationOffer(ctx, &ao) - if err != nil { - return "", errors.E(err, "failed to fetch application offer information") - } - aoString := names.ApplicationOfferTagKind + "-" + ao.Model.Controller.Name + ":" + ao.Model.OwnerUsername + "/" + ao.Model.Name + "." + ao.Name - if tag.Relation.String() != "" { - aoString = aoString + "#" + tag.Relation.String() - } - return aoString, nil - case jimmnames.GroupTagKind: - id, err := strconv.ParseUint(tag.ID, 10, 32) - if err != nil { - return "", errors.E(err, fmt.Sprintf("failed to parse group id: %v", tag.ID)) - } - group := dbmodel.GroupEntry{ - Model: gorm.Model{ - ID: uint(id), - }, - } - err = r.jimm.DB().GetGroup(ctx, &group) - if err != nil { - return "", errors.E(err, "failed to fetch group information") - } - groupString := jimmnames.GroupTagKind + "-" + group.Name - if tag.Relation.String() != "" { - groupString = groupString + "#" + tag.Relation.String() - } - return groupString, nil - case names.CloudTagKind: - cloud := dbmodel.Cloud{ - Name: tag.ID, - } - err := r.jimm.DB().GetCloud(ctx, &cloud) - if err != nil { - return "", errors.E(err, "failed to fetch group information") - } - cloudString := names.CloudTagKind + "-" + cloud.Name - if tag.Relation.String() != "" { - cloudString = cloudString + "#" + tag.Relation.String() - } - return cloudString, nil - default: - return "", errors.E(fmt.Sprintf("unexpected tag kind: %v", tag.Kind)) - } -} - // 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") @@ -547,11 +262,11 @@ func (r *controllerRoot) ListRelationshipTuples(ctx context.Context, req apipara } tuples := make([]apiparams.RelationshipTuple, len(responseTuples)) for i, t := range responseTuples { - object, err := r.toJAASTag(ctx, t.Object) + object, err := r.jimm.ToJAASTag(ctx, t.Object) if err != nil { return returnValue, errors.E(op, err) } - target, err := r.toJAASTag(ctx, t.Target) + target, err := r.jimm.ToJAASTag(ctx, t.Target) if err != nil { return returnValue, errors.E(op, err) } diff --git a/internal/jujuapi/access_control_test.go b/internal/jujuapi/access_control_test.go index fda65a63f..c3e9e1db3 100644 --- a/internal/jujuapi/access_control_test.go +++ b/internal/jujuapi/access_control_test.go @@ -11,7 +11,6 @@ import ( petname "github.com/dustinkirkland/golang-petname" "github.com/google/uuid" "github.com/juju/juju/core/crossmodel" - "github.com/juju/names/v4" jc "github.com/juju/testing/checkers" gc "gopkg.in/check.v1" @@ -23,7 +22,6 @@ import ( "github.com/canonical/jimm/internal/jujuapi" "github.com/canonical/jimm/internal/openfga" ofganames "github.com/canonical/jimm/internal/openfga/names" - jimmnames "github.com/canonical/jimm/pkg/names" ) type accessControlSuite struct { @@ -1314,158 +1312,6 @@ func (s *accessControlSuite) TestCheckRelationControllerAdministratorFlow(c *gc. None-facade related tests */ -func (s *accessControlSuite) TestResolveTupleObjectHandlesErrors(c *gc.C) { - ctx := context.Background() - - _, _, controller, model, offer, _, _, _, closeClient := createTestControllerEnvironment(ctx, c, s) - closeClient() - - type test struct { - input string - want string - } - - tests := []test{ - // Resolves bad tuple objects in general - { - input: "unknowntag-blabla", - want: "failed to map tag unknowntag", - }, - // Resolves bad groups where they do not exist - { - input: "group-myspecialpokemon-his-name-is-youguessedit-diglett", - want: "group not found", - }, - // Resolves bad controllers where they do not exist - { - input: "controller-mycontroller-that-does-not-exist", - want: "controller not found", - }, - // Resolves bad models where the user cannot be obtained from the JIMM tag - { - input: "model-mycontroller-that-does-not-exist/mymodel", - want: "model not found", - }, - // Resolves bad models where it cannot be found on the specified controller - { - input: "model-" + controller.Name + ":alex/", - want: "model not found", - }, - // Resolves bad applicationoffers where it cannot be found on the specified controller/model combo - { - input: "applicationoffer-" + controller.Name + ":alex/" + model.Name + "." + offer.Name + "fluff", - want: "application offer not found", - }, - } - for _, tc := range tests { - _, err := jujuapi.ResolveTag(s.JIMM.UUID, s.JIMM.DB(), tc.input) - c.Assert(err, gc.ErrorMatches, tc.want) - } -} - -func (s *accessControlSuite) TestResolveTagObjectMapsUsers(c *gc.C) { - tag, err := jujuapi.ResolveTag(s.JIMM.UUID, s.JIMM.DB(), "user-alex@externally-werly#member") - c.Assert(err, gc.IsNil) - c.Assert(tag, gc.DeepEquals, ofganames.ConvertTagWithRelation(names.NewUserTag("alex@externally-werly"), ofganames.MemberRelation)) -} - -func (s *accessControlSuite) TestResolveTupleObjectMapsGroups(c *gc.C) { - ctx := context.Background() - err := s.JIMM.Database.AddGroup(context.Background(), "myhandsomegroupofdigletts") - c.Assert(err, gc.IsNil) - group := &dbmodel.GroupEntry{ - Name: "myhandsomegroupofdigletts", - } - err = s.JIMM.Database.GetGroup(ctx, group) - c.Assert(err, gc.IsNil) - tag, err := jujuapi.ResolveTag(s.JIMM.UUID, s.JIMM.DB(), "group-"+group.Name+"#member") - c.Assert(err, gc.IsNil) - c.Assert(tag, gc.DeepEquals, ofganames.ConvertTagWithRelation(jimmnames.NewGroupTag("1"), ofganames.MemberRelation)) -} - -func (s *accessControlSuite) TestResolveTupleObjectMapsControllerUUIDs(c *gc.C) { - ctx := context.Background() - - cloud := dbmodel.Cloud{ - Name: "test-cloud", - } - err := s.JIMM.Database.AddCloud(context.Background(), &cloud) - c.Assert(err, gc.IsNil) - - uuid, _ := uuid.NewRandom() - controller := dbmodel.Controller{ - Name: "mycontroller", - UUID: uuid.String(), - CloudName: "test-cloud", - } - err = s.JIMM.Database.AddController(ctx, &controller) - c.Assert(err, gc.IsNil) - - tag, err := jujuapi.ResolveTag(s.JIMM.UUID, s.JIMM.DB(), "controller-mycontroller#administrator") - c.Assert(err, gc.IsNil) - c.Assert(tag, gc.DeepEquals, ofganames.ConvertTagWithRelation(names.NewControllerTag(uuid.String()), ofganames.AdministratorRelation)) -} - -func (s *accessControlSuite) TestResolveTupleObjectMapsModelUUIDs(c *gc.C) { - ctx := context.Background() - - user, _, controller, model, _, _, _, _, closeClient := createTestControllerEnvironment(ctx, c, s) - defer closeClient() - - jimmTag := "model-" + controller.Name + ":" + user.Username + "/" + model.Name + "#administrator" - - tag, err := jujuapi.ResolveTag(s.JIMM.UUID, s.JIMM.DB(), jimmTag) - c.Assert(err, gc.IsNil) - c.Assert(tag, gc.DeepEquals, ofganames.ConvertTagWithRelation(names.NewModelTag(model.UUID.String), ofganames.AdministratorRelation)) - -} - -func (s *accessControlSuite) TestResolveTupleObjectMapsApplicationOffersUUIDs(c *gc.C) { - ctx := context.Background() - - user, _, controller, model, offer, _, _, _, closeClient := createTestControllerEnvironment(ctx, c, s) - closeClient() - - jimmTag := "applicationoffer-" + controller.Name + ":" + user.Username + "/" + model.Name + "." + offer.Name + "#administrator" - - jujuTag, err := jujuapi.ResolveTag(s.JIMM.UUID, s.JIMM.DB(), jimmTag) - c.Assert(err, gc.IsNil) - c.Assert(jujuTag, gc.DeepEquals, ofganames.ConvertTagWithRelation(names.NewApplicationOfferTag(offer.UUID), ofganames.AdministratorRelation)) -} - -func (s *accessControlSuite) TestResolveJIMM(c *gc.C) { - jimmTag := "controller-jimm" - - jujuTag, err := jujuapi.ResolveTag(s.JIMM.UUID, s.JIMM.DB(), jimmTag) - c.Assert(err, gc.IsNil) - c.Assert(jujuTag, gc.DeepEquals, ofganames.ConvertTag(names.NewControllerTag(s.JIMM.UUID))) -} - -func (s *accessControlSuite) TestParseTag(c *gc.C) { - ctx := context.Background() - - user, _, controller, model, _, _, _, _, closeClient := createTestControllerEnvironment(ctx, c, s) - defer closeClient() - - jimmTag := "model-" + controller.Name + ":" + user.Username + "/" + model.Name + "#administrator" - - // JIMM tag syntax for models - tag, err := jujuapi.ParseTag(ctx, s.JIMM.UUID, s.JIMM.DB(), jimmTag) - c.Assert(err, gc.IsNil) - c.Assert(tag.Kind.String(), gc.Equals, names.ModelTagKind) - c.Assert(tag.ID, gc.Equals, model.UUID.String) - c.Assert(tag.Relation.String(), gc.Equals, "administrator") - - jujuTag := "model-" + model.UUID.String + "#administrator" - - // Juju tag syntax for models - tag, err = jujuapi.ParseTag(ctx, s.JIMM.UUID, s.JIMM.DB(), jujuTag) - c.Assert(err, gc.IsNil) - c.Assert(tag.ID, gc.Equals, model.UUID.String) - c.Assert(tag.Kind.String(), gc.Equals, names.ModelTagKind) - c.Assert(tag.Relation.String(), gc.Equals, "administrator") -} - // createTestControllerEnvironment is a utility function creating the necessary components of adding a: // - user // - user group diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index 73ff0f4af..d749da9d8 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -21,6 +21,7 @@ import ( "github.com/canonical/jimm/internal/jimm" "github.com/canonical/jimm/internal/jujuapi/rpc" "github.com/canonical/jimm/internal/openfga" + ofganames "github.com/canonical/jimm/internal/openfga/names" "github.com/canonical/jimm/internal/pubsub" ) @@ -29,6 +30,7 @@ type JIMM interface { 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) error AddModel(ctx context.Context, u *openfga.User, args *jimm.ModelCreateArgs) (_ *jujuparams.ModelInfo, err error) Authenticate(ctx context.Context, req *jujuparams.LoginRequest) (*openfga.User, error) OAuthAuthenticationService() jimm.OAuthAuthenticator @@ -55,6 +57,7 @@ type JIMM interface { 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.User) (*dbmodel.ControllerConfig, error) GetJimmControllerAccess(ctx context.Context, user *openfga.User, tag names.UserTag) (string, error) + GetUser(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) @@ -66,16 +69,20 @@ type JIMM interface { InitiateMigration(ctx context.Context, user *openfga.User, spec jujuparams.MigrationSpec, targetControllerID uint) (jujuparams.InitiateMigrationResult, error) InitiateInternalMigration(ctx context.Context, user *openfga.User, modelTag names.ModelTag, targetController string) (jujuparams.InitiateMigrationResult, error) ListApplicationOffers(ctx context.Context, user *openfga.User, filters ...jujuparams.OfferFilter) ([]jujuparams.ApplicationOfferAdminDetails, error) + ListGroups(ctx context.Context, user *openfga.User) ([]dbmodel.GroupEntry, error) ModelDefaultsForCloud(ctx context.Context, user *dbmodel.User, 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) 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) QueryModelsJq(ctx context.Context, models []dbmodel.Model, jqQuery string) (params.CrossModelQueryResponse, 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 @@ -86,6 +93,7 @@ type JIMM interface { SetControllerDeprecated(ctx context.Context, user *openfga.User, controllerName string, deprecated bool) error SetModelDefaults(ctx context.Context, user *dbmodel.User, cloudTag names.CloudTag, region string, configs map[string]interface{}) error SetUserModelDefaults(ctx context.Context, user *dbmodel.User, configs map[string]interface{}) error + ToJAASTag(ctx context.Context, tag *ofganames.Tag) (string, error) UnsetModelDefaults(ctx context.Context, user *dbmodel.User, cloudTag names.CloudTag, region string, keys []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 @@ -155,13 +163,11 @@ func (r *controllerRoot) masquerade(ctx context.Context, userTag string) (*openf if !r.user.JimmAdmin { return nil, errors.E(errors.CodeUnauthorized, "unauthorized") } - user := dbmodel.User{ - Username: ut.Id(), - } - if err := r.jimm.DB().GetUser(ctx, &user); err != nil { + user, err := r.jimm.GetUser(ctx, ut.Id()) + if err != nil { return nil, err } - return openfga.NewUser(&user, r.jimm.AuthorizationClient()), nil + return user, nil } // parseUserTag parses a names.UserTag and validates it is for an diff --git a/internal/jujuapi/export_test.go b/internal/jujuapi/export_test.go index 429e326fa..1f7c3ae06 100644 --- a/internal/jujuapi/export_test.go +++ b/internal/jujuapi/export_test.go @@ -13,8 +13,6 @@ import ( var ( NewModelAccessWatcher = newModelAccessWatcher - ParseTag = parseTag - ResolveTag = resolveTag ModelInfoFromPath = modelInfoFromPath AuditParamsToFilter = auditParamsToFilter AuditLogDefaultLimit = limitDefault @@ -40,12 +38,10 @@ func RunModelAccessWatcher(w *modelAccessWatcher) { } func ToJAASTag(db db.Database, tag *ofganames.Tag) (string, error) { - c := controllerRoot{ - jimm: &jimm.JIMM{ - Database: db, - }, + jimm := &jimm.JIMM{ + Database: db, } - return c.toJAASTag(context.Background(), tag) + return jimm.ToJAASTag(context.Background(), tag) } func NewControllerRoot(j JIMM, p Params) *controllerRoot { diff --git a/local/keycloak/jimm-realm.json b/local/keycloak/jimm-realm.json index b2fb73b2c..f06f63022 100644 --- a/local/keycloak/jimm-realm.json +++ b/local/keycloak/jimm-realm.json @@ -2276,4 +2276,4 @@ "clientPolicies": { "policies": [] } -} \ No newline at end of file +} diff --git a/service.go b/service.go index e99f32542..26cb7038c 100644 --- a/service.go +++ b/service.go @@ -4,7 +4,6 @@ package jimm import ( "context" - "crypto/tls" "encoding/json" "net/http" "os" @@ -177,10 +176,6 @@ type Params struct { // instead of dedicated secure storage. SHOULD NOT BE USED IN PRODUCTION. InsecureSecretStorage bool - // InsecureJwksLookup instructs JIMM to lookup its JWKS value via - // http instead of https. Useful when running JIMM in a docker compose. - InsecureJwksLookup bool - // OAuthAuthenticatorParams holds parameters needed to configure an OAuthAuthenticator // implementation. OAuthAuthenticatorParams OAuthAuthenticatorParams @@ -235,24 +230,6 @@ func (s *Service) StartJWKSRotator(ctx context.Context, checkRotateRequired <-ch return s.jimm.JWKService.StartJWKSRotator(ctx, checkRotateRequired, initialRotateRequiredTime) } -// RegisterJwksCache registers the JWKS Cache with JIMM's JWT service. -func (s *Service) RegisterJwksCache(ctx context.Context) { - if s.jimm.JWTService == nil { - zapctx.Warn(ctx, "skipping JWKS cache registration - service not available") - return - } - tlsConfig := &tls.Config{ - InsecureSkipVerify: true, - } - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsConfig, - }, - Timeout: 15 * time.Second, - } - s.jimm.JWTService.RegisterJWKSCache(ctx, client) -} - // NewService creates a new Service using the given params. func NewService(ctx context.Context, p Params) (*Service, error) { const op = errors.Op("NewService") @@ -343,7 +320,6 @@ func NewService(ctx context.Context, p Params) (*Service, error) { s.jimm.JWKService = jimmjwx.NewJWKSService(s.jimm.CredentialStore) s.jimm.JWTService = jimmjwx.NewJWTService(jimmjwx.JWTServiceParams{ Host: p.PublicDNSName, - Secure: !p.InsecureJwksLookup, Store: s.jimm.CredentialStore, Expiry: p.JWTExpiryDuration, })