├── pkg ├── web │ ├── interfaces.go │ ├── middlewares.go │ └── router.go ├── responses │ └── responses.go ├── idp │ ├── interfaces.go │ ├── third_party.go │ ├── handlers.go │ └── service.go ├── schemas │ ├── interfaces.go │ ├── handlers.go │ └── service.go ├── identities │ ├── interfaces.go │ ├── service.go │ ├── handlers.go │ └── service_test.go ├── metrics │ └── handlers.go ├── clients │ ├── interfaces.go │ ├── handlers.go │ ├── service.go │ ├── handlers_test.go │ └── service_test.go └── status │ ├── build.go │ ├── handlers.go │ └── handlers_test.go ├── CODEOWNERS ├── .dockerignore ├── internal ├── version │ └── const.go ├── monitoring │ ├── interfaces.go │ ├── prometheus │ │ └── prometheus.go │ ├── middlewares.go │ └── middlewares_test.go ├── config │ ├── flags.go │ └── specs.go ├── logging │ ├── interfaces.go │ ├── logger_test.go │ ├── middlewares.go │ └── logger.go ├── tracing │ ├── config.go │ ├── middleware.go │ └── tracer.go ├── k8s │ └── client.go ├── responses │ └── responses.go ├── hydra │ └── client.go ├── kratos │ └── client.go └── http │ └── types │ └── generic.go ├── ui ├── Makefile ├── static │ └── sass │ │ └── styles.scss ├── tsconfig.json ├── README.md └── package.json ├── cmd ├── Makefile └── main.go ├── deployments ├── helm │ ├── postgresql │ │ └── values.yaml │ ├── hydra │ │ └── values.yaml │ └── kratos │ │ └── values.yaml └── kubectl │ ├── service.yaml │ ├── deployment.yaml │ └── configMap.yaml ├── .github ├── .jira_sync_config.yaml └── workflows │ ├── auto-approver.yaml │ ├── scan.yaml │ ├── ci.yaml │ ├── unittest.yaml │ ├── release.yaml │ ├── build.yaml │ ├── ossf.yaml │ ├── codeql-analysis.yaml │ └── publish.yaml ├── structure-tests.yaml ├── .gitignore ├── rockcraft.yaml ├── Makefile ├── skaffold.yaml ├── renovate.json ├── CHANGELOG.md ├── go.mod ├── README.md └── LICENSE /pkg/web/interfaces.go: -------------------------------------------------------------------------------- 1 | package web 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @canonical/identity 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # comment 2 | deployments/ 3 | .github/ -------------------------------------------------------------------------------- /internal/version/const.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | const Version = "1.2.0" // x-release-please-version 4 | -------------------------------------------------------------------------------- /ui/Makefile: -------------------------------------------------------------------------------- 1 | NPM?=npm 2 | 3 | 4 | build: install 5 | $(NPM) run build --frozen=lockfile 6 | .PHONY=build 7 | 8 | install: 9 | $(NPM) install 10 | .PHONY=install 11 | 12 | test: 13 | $(NPM) ci 14 | .PHONY=test 15 | -------------------------------------------------------------------------------- /pkg/responses/responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | type Response struct { 4 | Data interface{} `json:"data"` 5 | Message string `json:"message"` 6 | Status int `json:"status"` 7 | Meta interface{} `json:"_meta"` 8 | } 9 | -------------------------------------------------------------------------------- /cmd/Makefile: -------------------------------------------------------------------------------- 1 | GO111MODULE?=on 2 | CGO_ENABLED?=0 3 | GOOS?=linux 4 | GO_BIN?=app 5 | GO?=go 6 | GOFLAGS?=-ldflags=-w -ldflags=-s -a -buildvcs 7 | 8 | 9 | .EXPORT_ALL_VARIABLES: 10 | 11 | build: 12 | $(GO) build -o $(GO_BIN) ./ 13 | 14 | .PHONY=build -------------------------------------------------------------------------------- /internal/monitoring/interfaces.go: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | type MonitorInterface interface { 4 | GetService() string 5 | GetResponseTimeMetric(map[string]string) (MetricInterface, error) 6 | } 7 | 8 | type MetricInterface interface { 9 | Observe(float64) 10 | } 11 | -------------------------------------------------------------------------------- /internal/config/flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "flag" 4 | 5 | type Flags struct { 6 | ShowVersion bool 7 | } 8 | 9 | func NewFlags() *Flags { 10 | f := new(Flags) 11 | 12 | flag.BoolVar(&f.ShowVersion, "version", false, "Show the app version and exit") 13 | flag.Parse() 14 | 15 | return f 16 | } 17 | -------------------------------------------------------------------------------- /internal/logging/interfaces.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | type LoggerInterface interface { 4 | Errorf(string, ...interface{}) 5 | Infof(string, ...interface{}) 6 | Warnf(string, ...interface{}) 7 | Debugf(string, ...interface{}) 8 | Fatalf(string, ...interface{}) 9 | Error(...interface{}) 10 | Info(...interface{}) 11 | Warn(...interface{}) 12 | Debug(...interface{}) 13 | Fatal(...interface{}) 14 | } 15 | -------------------------------------------------------------------------------- /deployments/helm/postgresql/values.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | postgresql: 3 | auth: 4 | database: "iam" 5 | username: "iam" 6 | password: "iam" 7 | postgresPassword: "iam" 8 | primary: 9 | initdb: 10 | scripts: 11 | init.sql: | 12 | CREATE DATABASE kratos; 13 | CREATE DATABASE hydra; 14 | 15 | auth: 16 | database: "iam" 17 | username: "iam" 18 | password: "iam" 19 | postgresPassword: "iam" -------------------------------------------------------------------------------- /deployments/helm/hydra/values.yaml: -------------------------------------------------------------------------------- 1 | hydra: 2 | dev: true 3 | config: 4 | dsn: "postgres://iam:iam@postgresql.default.svc.cluster.local/hydra?sslmode=disable&max_conn_lifetime=10s" 5 | secrets: 6 | system: 7 | - SUFNUGxhdGZvcm0K 8 | urls: 9 | self: 10 | issuer: https://localhost:4444/ 11 | login: https://my-idp/login 12 | consent: https://my-idp/consent 13 | automigration: 14 | enabled: true -------------------------------------------------------------------------------- /deployments/kubectl/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | annotations: 6 | prometheus.io/path: /api/v0/metrics 7 | prometheus.io/scrape: "true" 8 | io.cilium/global-service: "true" 9 | name: identity-platform-admin-ui 10 | spec: 11 | ports: 12 | - name: http 13 | port: 80 14 | protocol: TCP 15 | targetPort: http 16 | selector: 17 | app: identity-platform-admin-ui 18 | type: ClusterIP 19 | --- 20 | -------------------------------------------------------------------------------- /pkg/idp/interfaces.go: -------------------------------------------------------------------------------- 1 | package idp 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type ServiceInterface interface { 8 | ListResources(context.Context) ([]*Configuration, error) 9 | GetResource(context.Context, string) ([]*Configuration, error) 10 | EditResource(context.Context, string, *Configuration) ([]*Configuration, error) 11 | CreateResource(context.Context, *Configuration) ([]*Configuration, error) 12 | DeleteResource(context.Context, string) error 13 | } 14 | -------------------------------------------------------------------------------- /.github/.jira_sync_config.yaml: -------------------------------------------------------------------------------- 1 | # From https://github.com/canonical/gh-jira-sync-bot#client-side-configuration 2 | settings: 3 | jira_project_key: "IAM" 4 | status_mapping: 5 | opened: Untriaged 6 | closed: done 7 | components: 8 | - Admin UI 9 | labels: 10 | - bug 11 | - enhancement 12 | add_gh_comment: true 13 | sync_description: true 14 | sync_comments: true 15 | epic_key: "IAM-471" 16 | label_mapping: 17 | enhancement: Story 18 | bug: Bug 19 | -------------------------------------------------------------------------------- /internal/logging/logger_test.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDebugLogger(t *testing.T) { 10 | assert := assert.New(t) 11 | assert.NotPanics(func() { NewLogger("DEBUG", "log.txt") }, "No panic should have been thrown") 12 | } 13 | 14 | func TestInvalidLevel(t *testing.T) { 15 | assert := assert.New(t) 16 | assert.NotPanics(func() { NewLogger("invalid", "log.txt") }, "No panic should have been thrown") 17 | } 18 | -------------------------------------------------------------------------------- /ui/static/sass/styles.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | 4 | //settings 5 | $breakpoint-navigation-threshold: 820px; 6 | 7 | // import vanilla-framework 8 | @import 'vanilla-framework/scss/build'; 9 | 10 | .login-card { 11 | width: 100%; 12 | max-width: 439px; 13 | margin-left: auto; 14 | margin-right: auto; 15 | align-items: center; 16 | text-align: center; 17 | } 18 | 19 | .login-button { 20 | width: 95%; 21 | margin-left: auto; 22 | margin-right: auto; 23 | align-items: center; 24 | } 25 | -------------------------------------------------------------------------------- /pkg/schemas/interfaces.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "context" 5 | 6 | kClient "github.com/ory/kratos-client-go" 7 | ) 8 | 9 | type ServiceInterface interface { 10 | ListSchemas(context.Context, int64, int64) (*IdentitySchemaData, error) 11 | GetSchema(context.Context, string) (*IdentitySchemaData, error) 12 | EditSchema(context.Context, string, *kClient.IdentitySchemaContainer) (*IdentitySchemaData, error) 13 | CreateSchema(context.Context, *kClient.IdentitySchemaContainer) (*IdentitySchemaData, error) 14 | DeleteSchema(context.Context, string) error 15 | } 16 | -------------------------------------------------------------------------------- /pkg/identities/interfaces.go: -------------------------------------------------------------------------------- 1 | package identities 2 | 3 | import ( 4 | "context" 5 | 6 | kClient "github.com/ory/kratos-client-go" 7 | ) 8 | 9 | type ServiceInterface interface { 10 | ListIdentities(context.Context, int64, int64, string) (*IdentityData, error) 11 | GetIdentity(context.Context, string) (*IdentityData, error) 12 | CreateIdentity(context.Context, *kClient.CreateIdentityBody) (*IdentityData, error) 13 | UpdateIdentity(context.Context, string, *kClient.UpdateIdentityBody) (*IdentityData, error) 14 | DeleteIdentity(context.Context, string) (*IdentityData, error) 15 | } 16 | -------------------------------------------------------------------------------- /internal/tracing/config.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 5 | ) 6 | 7 | type Config struct { 8 | OtelHTTPEndpoint string 9 | OtelGRPCEndpoint string 10 | Logger logging.LoggerInterface 11 | 12 | Enabled bool 13 | } 14 | 15 | func NewConfig(enabled bool, otelGRPCEndpoint, otelHTTPEndpoint string, logger logging.LoggerInterface) *Config { 16 | c := new(Config) 17 | 18 | c.OtelGRPCEndpoint = otelGRPCEndpoint 19 | c.OtelHTTPEndpoint = otelHTTPEndpoint 20 | c.Logger = logger 21 | c.Enabled = enabled 22 | 23 | return c 24 | } 25 | -------------------------------------------------------------------------------- /pkg/web/middlewares.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | cors "github.com/go-chi/cors" 7 | ) 8 | 9 | func middlewareCORS(origins []string) func(http.Handler) http.Handler { 10 | return cors.Handler( 11 | cors.Options{ 12 | AllowedOrigins: origins, 13 | AllowedMethods: []string{ 14 | http.MethodHead, 15 | http.MethodGet, 16 | http.MethodPost, 17 | http.MethodPut, 18 | http.MethodPatch, 19 | http.MethodDelete, 20 | http.MethodOptions, 21 | }, 22 | AllowedHeaders: []string{"*"}, 23 | AllowCredentials: true, 24 | MaxAge: 300, // Maximum value not ignored by any of major browsers 25 | }, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/metrics/handlers.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 7 | "github.com/go-chi/chi/v5" 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | ) 10 | 11 | type API struct { 12 | logger logging.LoggerInterface 13 | } 14 | 15 | func (a *API) RegisterEndpoints(mux *chi.Mux) { 16 | mux.Get("/api/v0/metrics", a.prometheusHTTP) 17 | } 18 | 19 | func (a *API) prometheusHTTP(w http.ResponseWriter, r *http.Request) { 20 | promhttp.Handler().ServeHTTP(w, r) 21 | } 22 | 23 | func NewAPI(logger logging.LoggerInterface) *API { 24 | a := new(API) 25 | 26 | a.logger = logger 27 | 28 | return a 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/auto-approver.yaml: -------------------------------------------------------------------------------- 1 | name: auto-approver 2 | run-name: CI for approving PRs 3 | 4 | on: 5 | push: 6 | branches: 7 | - "renovate/**" 8 | 9 | jobs: 10 | autoapprove: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 14 | - name: Approve PR 15 | run: | 16 | gh pr review --approve || true 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} 19 | - name: Enable automerge if required 20 | if: startsWith(github.ref_name, 'renovate/auto-') 21 | run: | 22 | gh pr merge --auto --merge || true 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} 25 | -------------------------------------------------------------------------------- /internal/k8s/client.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "k8s.io/client-go/kubernetes" 5 | coreV1 "k8s.io/client-go/kubernetes/typed/core/v1" 6 | "k8s.io/client-go/rest" 7 | ) 8 | 9 | func NewCoreV1Client() (coreV1.CoreV1Interface, error) { 10 | // httpClient := new(http.Client) 11 | // httpClient.Transport = otelhttp.NewTransport(http.DefaultTransport) 12 | 13 | // creates the in-cluster config 14 | config, err := rest.InClusterConfig() 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | // creates the clientset 20 | // clientset, err := kubernetes.NewForConfigAndClient(config, httpClient) 21 | clientset, err := kubernetes.NewForConfig(config) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return clientset.CoreV1(), nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/responses/responses.go: -------------------------------------------------------------------------------- 1 | package responses 2 | 3 | import "encoding/json" 4 | 5 | type Response struct { 6 | Data interface{} `json:"data"` 7 | Message string `json:"message"` 8 | Status int `json:"status"` 9 | Links interface{} `json:"_links"` 10 | Meta interface{} `json:"_meta"` 11 | } 12 | 13 | func (r *Response) PrepareResponse() ([]byte, error) { 14 | resp, err := json.Marshal(r) 15 | if err != nil { 16 | return nil, err 17 | } 18 | return resp, err 19 | } 20 | 21 | func NewResponse(data interface{}, msg string, status int, links interface{}, meta interface{}) *Response { 22 | r := new(Response) 23 | r.Data = data 24 | r.Message = msg 25 | r.Status = status 26 | r.Links = links 27 | r.Meta = meta 28 | return r 29 | } 30 | -------------------------------------------------------------------------------- /pkg/clients/interfaces.go: -------------------------------------------------------------------------------- 1 | package clients 2 | 3 | import ( 4 | "context" 5 | 6 | hClient "github.com/ory/hydra-client-go/v2" 7 | ) 8 | 9 | type HydraClientInterface interface { 10 | OAuth2Api() hClient.OAuth2Api 11 | } 12 | 13 | type OAuth2Client = hClient.OAuth2Client 14 | 15 | type ServiceInterface interface { 16 | GetClient(context.Context, string) (*ServiceResponse, error) 17 | CreateClient(context.Context, *hClient.OAuth2Client) (*ServiceResponse, error) 18 | UpdateClient(context.Context, *hClient.OAuth2Client) (*ServiceResponse, error) 19 | ListClients(context.Context, *ListClientsRequest) (*ServiceResponse, error) 20 | DeleteClient(context.Context, string) (*ServiceResponse, error) 21 | UnmarshalClient(data []byte) (*hClient.OAuth2Client, error) 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/scan.yaml: -------------------------------------------------------------------------------- 1 | name: container scan 2 | run-name: Scanning container ${{ inputs.image }} to ghcr.io/canonical/identity-platform-admin-ui 3 | 4 | on: 5 | workflow_call: 6 | inputs: 7 | image: 8 | type: string 9 | required: true 10 | description: "image to scan" 11 | jobs: 12 | scan: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Scan image with Trivy 17 | uses: aquasecurity/trivy-action@master 18 | with: 19 | image-ref: ${{ inputs.image }} 20 | format: 'sarif' 21 | output: 'trivy-results.sarif' 22 | 23 | - name: Upload scan results to GitHub 24 | uses: github/codeql-action/upload-sarif@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2 25 | with: 26 | sarif_file: 'trivy-results.sarif' -------------------------------------------------------------------------------- /internal/hydra/client.go: -------------------------------------------------------------------------------- 1 | package hydra 2 | 3 | import ( 4 | "net/http" 5 | 6 | client "github.com/ory/hydra-client-go/v2" 7 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 8 | ) 9 | 10 | type Client struct { 11 | c *client.APIClient 12 | } 13 | 14 | func (c *Client) OAuth2Api() client.OAuth2Api { 15 | return c.c.OAuth2Api 16 | } 17 | 18 | func NewClient(url string, debug bool) *Client { 19 | c := new(Client) 20 | 21 | configuration := client.NewConfiguration() 22 | 23 | configuration.Debug = debug 24 | configuration.Servers = []client.ServerConfiguration{ 25 | { 26 | URL: url, 27 | }, 28 | } 29 | 30 | configuration.HTTPClient = new(http.Client) 31 | configuration.HTTPClient.Transport = otelhttp.NewTransport(http.DefaultTransport) 32 | 33 | c.c = client.NewAPIClient(configuration) 34 | 35 | return c 36 | } 37 | -------------------------------------------------------------------------------- /internal/kratos/client.go: -------------------------------------------------------------------------------- 1 | package kratos 2 | 3 | import ( 4 | "net/http" 5 | 6 | client "github.com/ory/kratos-client-go" 7 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 8 | ) 9 | 10 | type Client struct { 11 | c *client.APIClient 12 | } 13 | 14 | func (c *Client) IdentityApi() client.IdentityApi { 15 | return c.c.IdentityApi 16 | } 17 | 18 | func NewClient(url string, debug bool) *Client { 19 | c := new(Client) 20 | 21 | configuration := client.NewConfiguration() 22 | configuration.Debug = debug 23 | configuration.Servers = []client.ServerConfiguration{ 24 | { 25 | URL: url, 26 | }, 27 | } 28 | 29 | configuration.HTTPClient = new(http.Client) 30 | configuration.HTTPClient.Transport = otelhttp.NewTransport(http.DefaultTransport) 31 | 32 | c.c = client.NewAPIClient(configuration) 33 | 34 | return c 35 | } 36 | -------------------------------------------------------------------------------- /pkg/status/build.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "runtime/debug" 5 | 6 | "github.com/canonical/identity-platform-admin-ui/internal/version" 7 | ) 8 | 9 | type BuildInfo struct { 10 | Version string `json:"version"` 11 | CommitHash string `json:"commit_hash"` 12 | Name string `json:"name"` 13 | } 14 | 15 | func buildInfo() *BuildInfo { 16 | info, ok := debug.ReadBuildInfo() 17 | 18 | if !ok { 19 | return nil 20 | } 21 | 22 | buildInfo := new(BuildInfo) 23 | buildInfo.Name = info.Main.Path 24 | buildInfo.Version = version.Version 25 | buildInfo.CommitHash = gitRevision(info.Settings) 26 | 27 | return buildInfo 28 | } 29 | 30 | func gitRevision(settings []debug.BuildSetting) string { 31 | for _, setting := range settings { 32 | if setting.Key == "vcs.revision" { 33 | return setting.Value 34 | } 35 | } 36 | 37 | return "n/a" 38 | } 39 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "jsx": "preserve", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "lib": [ 11 | "dom", 12 | "dom.iterable", 13 | "esnext" 14 | ], 15 | "allowJs": true, 16 | "allowSyntheticDefaultImports": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": false, 22 | "sourceMap": true, 23 | "strictNullChecks": true, 24 | "incremental": true, 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | ".eslintrc.js", 29 | "**/*.ts", 30 | "**/*.tsx" 31 | ], 32 | "exclude": [ 33 | "node_modules" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /structure-tests.yaml: -------------------------------------------------------------------------------- 1 | schemaVersion: 2.0.0 2 | 3 | 4 | globalEnvVars: 5 | - key: "KRATOS_PUBLIC_URL" 6 | value: "https://kratos.iam.public" 7 | - key: "KRATOS_ADMIN_URL" 8 | value: "https://kratos.iam.admin" 9 | - key: "HYDRA_ADMIN_URL" 10 | value: "https://hydra.iam.admin" 11 | - key: "IDP_CONFIGMAP_NAME" 12 | value: idps 13 | - key: "IDP_CONFIGMAP_NAMESPACE" 14 | value: default 15 | - key: "SCHEMAS_CONFIGMAP_NAME" 16 | value: identity-schemas 17 | - key: "SCHEMAS_CONFIGMAP_NAMESPACE" 18 | value: default 19 | 20 | fileExistenceTests: 21 | - name: "no go binary" 22 | path: "/usr/bin/go" 23 | shouldExist: false 24 | - name: "application go binary" 25 | path: "/usr/bin/identity-platform-admin-ui" 26 | shouldExist: true 27 | commandTests: 28 | - name: "application version" 29 | command: "/usr/bin/identity-platform-admin-ui" 30 | args: ["-version"] 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | run-name: CI for ${{ github.sha }} on ${{ github.ref_name }} 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - "main" 9 | - "release-**" 10 | tags: 11 | - "v**" 12 | pull_request: 13 | branches: 14 | - "*" 15 | 16 | jobs: 17 | unit-test: 18 | uses: ./.github/workflows/unittest.yaml 19 | build: 20 | uses: ./.github/workflows/build.yaml 21 | publish: 22 | if: ${{ (github.ref == 'refs/heads/main') || (github.ref_type == 'tag') }} 23 | needs: [build, unit-test] 24 | uses: ./.github/workflows/publish.yaml 25 | with: 26 | rock: ${{ needs.build.outputs.rock }} 27 | scan: 28 | if: ${{ (github.ref == 'refs/heads/main') || (github.ref_type == 'tag') }} 29 | needs: publish 30 | uses: ./.github/workflows/scan.yaml 31 | with: 32 | image: ${{ needs.publish.outputs.image }} -------------------------------------------------------------------------------- /internal/http/types/generic.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | ) 7 | 8 | type Response struct { 9 | Data interface{} `json:"data"` 10 | Message string `json:"message"` 11 | Status int `json:"status"` 12 | Meta *Pagination `json:"_meta"` 13 | } 14 | 15 | type Pagination struct { 16 | Page int64 `json:"page"` 17 | Size int64 `json:"size"` 18 | } 19 | 20 | func NewPaginationWithDefaults() *Pagination { 21 | p := new(Pagination) 22 | 23 | p.Page = 1 24 | p.Size = 100 25 | 26 | return p 27 | } 28 | 29 | func ParsePagination(q url.Values) *Pagination { 30 | 31 | p := NewPaginationWithDefaults() 32 | 33 | if page, err := strconv.ParseInt(q.Get("page"), 10, 64); err == nil && page > 1 { 34 | p.Page = page 35 | } 36 | 37 | if size, err := strconv.ParseInt(q.Get("size"), 10, 64); err == nil && size > 0 { 38 | p.Size = size 39 | } 40 | 41 | return p 42 | } 43 | -------------------------------------------------------------------------------- /internal/tracing/middleware.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 7 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 8 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 9 | ) 10 | 11 | // Middleware is the monitoring middleware object implementing Prometheus monitoring 12 | type Middleware struct { 13 | service string 14 | 15 | monitor monitoring.MonitorInterface 16 | logger logging.LoggerInterface 17 | } 18 | 19 | func (mdw *Middleware) OpenTelemetry(handler http.Handler) http.Handler { 20 | return otelhttp.NewHandler(handler, "server") 21 | } 22 | 23 | // NewMiddleware returns a Middleware based on the type of monitor 24 | func NewMiddleware(monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Middleware { 25 | mdw := new(Middleware) 26 | 27 | mdw.monitor = monitor 28 | 29 | mdw.logger = logger 30 | 31 | return mdw 32 | } 33 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # Admin UI 2 | 3 | This is the Admin UI for the Canonical identity platform. 4 | 5 | ### `npm dev` 6 | 7 | Runs the app in the development mode.\ 8 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 9 | 10 | The page will reload when you make changes.\ 11 | You may also see any lint errors in the console. 12 | 13 | ### `npm test` 14 | 15 | Launches the test runner in the interactive watch mode.\ 16 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 17 | 18 | ### `npm run build` 19 | 20 | Builds the app for production to the `build` folder.\ 21 | It correctly bundles React in production mode and optimizes the build for the best performance. 22 | 23 | The build is minified and the filenames include the hashes.\ 24 | Your app is ready to be deployed! 25 | 26 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 27 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yaml: -------------------------------------------------------------------------------- 1 | run-name: Unit test steps for ${{ github.sha }} on ${{ github.ref_name }} 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 11 | - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4 12 | with: 13 | go-version: '1.19' 14 | - uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3 15 | with: 16 | node-version: 18 17 | 18 | - name: Build js UI 19 | run: make npm-build 20 | 21 | - name: Build Go code 22 | run: make test 23 | 24 | - uses: codecov/codecov-action@c4cf8a4f03f0ac8585acb7c1b7ce3460ec15782f # v4 25 | with: 26 | files: ./coverage.out 27 | - name: Upload Go test results 28 | uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3 29 | with: 30 | name: Go-results 31 | path: test.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /ui/node_modules 5 | /.pnp 6 | .pnp.js 7 | .next 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | /ui/dist 15 | **/ui/dist 16 | /ui/static/css 17 | /bin 18 | /tmp 19 | /identity_platform_admin_ui 20 | *.rock 21 | 22 | # misc 23 | .DS_Store 24 | .env.local 25 | .env.development.local 26 | .env.test.local 27 | .env.production.local 28 | .idea 29 | 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | 35 | ### Go ### 36 | # Binaries for programs and plugins 37 | *.exe 38 | *.exe~ 39 | *.dll 40 | *.so 41 | *.dylib 42 | 43 | # Test binary, built with `go test -c` 44 | *.test 45 | 46 | # Output of the go coverage tool, specifically when used with LiteIDE 47 | *.out 48 | 49 | # Dependency directories (remove the comment below to include it) 50 | # vendor/ 51 | 52 | ### Go Patch ### 53 | /vendor/ 54 | /Godeps/ 55 | 56 | # gomocks auto-generated 57 | **/mock_*.go 58 | 59 | # go binary name 60 | app 61 | -------------------------------------------------------------------------------- /rockcraft.yaml: -------------------------------------------------------------------------------- 1 | name: identity-platform-admin-ui 2 | 3 | base: bare 4 | build-base: ubuntu:22.04 5 | version: '1.2.0' # x-release-please-version 6 | summary: Canonical Identity platform Admin UI 7 | description: | 8 | This is the Canonical Identity platform admin UI used for connecting 9 | Ory Kratos with Ory Hydra. 10 | license: Apache-2.0 11 | 12 | platforms: 13 | amd64: 14 | 15 | services: 16 | admin-ui: 17 | override: replace 18 | command: /usr/bin/identity-platform-admin-ui 19 | startup: enabled 20 | 21 | parts: 22 | certificates: 23 | plugin: nil 24 | stage-packages: 25 | - ca-certificates 26 | 27 | go-build: 28 | plugin: go 29 | source: . 30 | source-type: local 31 | build-snaps: 32 | - go/1.19/stable 33 | - node/18/stable 34 | build-packages: 35 | - make 36 | - git 37 | override-build: | 38 | make npm-build build 39 | install -D -m755 cmd/app ${CRAFT_PART_INSTALL}/opt/identity-platform-admin-ui/bin/app 40 | organize: 41 | opt/identity-platform-admin-ui/bin/app: usr/bin/identity-platform-admin-ui 42 | stage-packages: 43 | - base-files_var 44 | -------------------------------------------------------------------------------- /internal/config/specs.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // EnvSpec is the basic environment configuration setup needed for the app to start 4 | type EnvSpec struct { 5 | OtelGRPCEndpoint string `envconfig:"otel_grpc_endpoint"` 6 | OtelHTTPEndpoint string `envconfig:"otel_http_endpoint"` 7 | TracingEnabled bool `envconfig:"tracing_enabled" default:"true"` 8 | 9 | LogLevel string `envconfig:"log_level" default:"error"` 10 | LogFile string `envconfig:"log_file" default:"log.txt"` 11 | 12 | Port int `envconfig:"port" default:"8080"` 13 | 14 | Debug bool `envconfig:"debug" default:"false"` 15 | 16 | KratosPublicURL string `envconfig:"kratos_public_url" required:"true"` 17 | KratosAdminURL string `envconfig:"kratos_admin_url" required:"true"` 18 | HydraAdminURL string `envconfig:"hydra_admin_url" required:"true"` 19 | 20 | IDPConfigMapName string `envconfig:"idp_configmap_name" required:"true"` 21 | IDPConfigMapNamespace string `envconfig:"idp_configmap_namespace" required:"true"` 22 | 23 | SchemasConfigMapName string `envconfig:"schemas_configmap_name" required:"true"` 24 | SchemasConfigMapNamespace string `envconfig:"schemas_configmap_namespace" required:"true"` 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - "release-**" 9 | 10 | 11 | jobs: 12 | release-please: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: google-github-actions/release-please-action@4c5670f886fe259db4d11222f7dff41c1382304d # v3 16 | with: 17 | release-type: simple 18 | package-name: "" 19 | default-branch: main 20 | pull-request-title-pattern: "ci: release ${version}" 21 | token: ${{ secrets.PAT_TOKEN }} 22 | extra-files: | 23 | rockcraft.yaml 24 | internal/version/const.go 25 | id: release 26 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 27 | - name: Workaround for https://github.com/googleapis/release-please/issues/922 28 | if: ${{ steps.release.outputs.pr != '' }} 29 | run: | 30 | echo "Closing and reopening PR to trigger checks" 31 | gh pr close ${{ fromJSON(steps.release.outputs.pr).number }} || true 32 | gh pr reopen ${{ fromJSON(steps.release.outputs.pr).number }} || true 33 | gh pr merge --auto --merge ${{ fromJSON(steps.release.outputs.pr).number }} || true 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} 36 | -------------------------------------------------------------------------------- /deployments/kubectl/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: identity-platform-admin-ui 6 | spec: 7 | replicas: 1 8 | selector: 9 | matchLabels: 10 | app: identity-platform-admin-ui 11 | strategy: 12 | type: Recreate 13 | template: 14 | metadata: 15 | labels: 16 | app: identity-platform-admin-ui 17 | annotations: 18 | prometheus.io/path: /api/v0/metrics 19 | prometheus.io/scrape: "true" 20 | prometheus.io/port: "8000" 21 | spec: 22 | containers: 23 | - image: identity-platform-admin-ui 24 | name: identity-platform-admin-ui 25 | envFrom: 26 | - configMapRef: 27 | name: identity-platform-admin-ui 28 | ports: 29 | - name: http 30 | containerPort: 8000 31 | readinessProbe: 32 | httpGet: 33 | path: "/api/v0/status" 34 | port: 8000 35 | initialDelaySeconds: 1 36 | failureThreshold: 10 37 | timeoutSeconds: 5 38 | periodSeconds: 30 39 | livenessProbe: 40 | httpGet: 41 | path: "/api/v0/status" 42 | port: 8000 43 | initialDelaySeconds: 1 44 | failureThreshold: 10 45 | timeoutSeconds: 5 46 | periodSeconds: 30 47 | imagePullSecrets: 48 | - name: regcred-github 49 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO111MODULE?=on 2 | CGO_ENABLED?=0 3 | GOOS?=linux 4 | GO_BIN?=app 5 | GO?=go 6 | GOFLAGS?=-ldflags=-w -ldflags=-s -a -buildvcs 7 | UI_FOLDER?= 8 | MICROK8S_REGISTRY_FLAG?=SKAFFOLD_DEFAULT_REPO=localhost:32000 9 | SKAFFOLD?=skaffold 10 | 11 | 12 | .EXPORT_ALL_VARIABLES: 13 | 14 | mocks: vendor 15 | $(GO) install github.com/golang/mock/mockgen@v1.6.0 16 | # generate gomocks 17 | $(GO) generate ./... 18 | .PHONY: mocks 19 | 20 | test: mocks vet 21 | $(GO) test ./... -cover -coverprofile coverage_source.out 22 | # this will be cached, just needed to the test.json 23 | $(GO) test ./... -cover -coverprofile coverage_source.out -json > test_source.json 24 | cat coverage_source.out | grep -v "mock_*" | tee coverage.out 25 | cat test_source.json | grep -v "mock_*" | tee test.json 26 | .PHONY: test 27 | 28 | vet: cmd/ui/dist 29 | $(GO) vet ./... 30 | .PHONY: vet 31 | 32 | vendor: 33 | $(GO) mod vendor 34 | .PHONY: vendor 35 | 36 | build: cmd/ui/dist 37 | $(MAKE) -C cmd build 38 | .PHONY: build 39 | 40 | # plan is to use this as a probe, if folder is there target wont run and npm-build will skip 41 | # but not working atm 42 | cmd/ui/dist: 43 | @echo "copy dist npm files into cmd/ui folder" 44 | mkdir -p cmd/ui/dist 45 | # TODO: Uncomment when ui is added 46 | # cp -r $(UI_FOLDER)ui/dist cmd/ui/ 47 | 48 | npm-build: 49 | $(MAKE) -C ui/ build 50 | .PHONY: npm-build 51 | 52 | 53 | dev: 54 | @$(MICROK8S_REGISTRY_FLAG) $(SKAFFOLD) run --mute-logs=all --port-forward 55 | .PHONY: dev 56 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | run-name: Build steps for ${{ github.sha }} on ${{ github.ref_name }} 2 | 3 | 4 | on: 5 | workflow_call: 6 | outputs: 7 | rock: 8 | description: "rock image" 9 | value: ${{ jobs.build.outputs.rock }} 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | rock: ${{ steps.set.outputs.rock }} 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 19 | 20 | - uses: canonical/craft-actions/rockcraft-pack@main 21 | id: rockcraft 22 | - name: Set rock output 23 | id: set 24 | run: echo "rock=${{ steps.rockcraft.outputs.rock }}" >> "$GITHUB_OUTPUT" 25 | 26 | - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3 27 | with: 28 | path: ${{ steps.rockcraft.outputs.rock }} 29 | name: ${{ steps.rockcraft.outputs.rock }} 30 | 31 | - name: Install Syft 32 | run: | 33 | curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin 34 | - name: Create SBOM 35 | run: syft $(realpath ${{ steps.rockcraft.outputs.rock }}) -o spdx-json=identity_platform_admin_ui.sbom.json 36 | 37 | - name: Upload SBOM 38 | uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3 39 | with: 40 | name: identity-platform-admin-ui-sbom 41 | path: "identity_platform_admin_ui.sbom.json" 42 | 43 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "identity-platform-admin-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@canonical/react-components": "0.47.1", 7 | "react": "18.2.0", 8 | "react-toastify": "9.1.3", 9 | "sass-embedded": "1.69.4", 10 | "vanilla-framework": "4.5.0" 11 | }, 12 | "engines": { 13 | "node": "18" 14 | }, 15 | "scripts": { 16 | "clean": "rm -rf node_modules css static/css *.log _site/ .next/", 17 | "dev": "npm run build-css && next dev", 18 | "build": "", 19 | "start": "next start", 20 | "lint": "next lint", 21 | "build-css": "node_modules/sass-embedded/sass.js --load-path node_modules --source-map static/sass:static/css && postcss --map false --use autoprefixer --replace 'static/css/**/*.css'" 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "react-app", 26 | "react-app/jest", 27 | "plugin:@next/next/recommended" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "devDependencies": { 43 | "@testing-library/jest-dom": "6.1.4", 44 | "@testing-library/react": "14.0.0", 45 | "@testing-library/user-event": "14.5.1", 46 | "@typescript-eslint/eslint-plugin": "6.8.0", 47 | "autoprefixer": "10.4.16", 48 | "eslint": "8.52.0", 49 | "eslint-config-prettier": "9.0.0", 50 | "eslint-plugin-prettier": "5.0.1", 51 | "eslint-plugin-react": "7.33.2", 52 | "postcss": "8.4.31", 53 | "postcss-cli": "10.1.0", 54 | "prettier": "3.0.3", 55 | "typescript": "5.2.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/logging/middlewares.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-chi/chi/v5/middleware" 10 | ) 11 | 12 | // brain-picked from DefaultLogFormatter https://raw.githubusercontent.com/go-chi/chi/v5.0.8/middleware/logger.go 13 | 14 | // LogFormatter is a simple logger that implements a middleware.LogFormatter. 15 | type LogFormatter struct { 16 | Logger LoggerInterface 17 | } 18 | 19 | // NewLogEntry creates a new LogEntry for the request. 20 | func (l *LogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry { 21 | entry := new(LogEntry) 22 | 23 | entry.LogFormatter = l 24 | entry.request = r 25 | entry.buf = new(bytes.Buffer) 26 | 27 | reqID := middleware.GetReqID(r.Context()) 28 | if reqID != "" { 29 | fmt.Fprintf(entry.buf, "[%s] ", reqID) 30 | } 31 | 32 | fmt.Fprintf(entry.buf, "%s ", r.Method) 33 | 34 | scheme := "http" 35 | if r.TLS != nil { 36 | scheme = "https" 37 | } 38 | 39 | fmt.Fprintf(entry.buf, "%s://%s%s %s ", scheme, r.Host, r.RequestURI, r.Proto) 40 | fmt.Fprintf(entry.buf, "from %s ", r.RemoteAddr) 41 | 42 | return entry 43 | } 44 | 45 | type LogEntry struct { 46 | *LogFormatter 47 | request *http.Request 48 | buf *bytes.Buffer 49 | } 50 | 51 | func (l *LogEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { 52 | 53 | fmt.Fprintf(l.buf, "%v %03d %dB in %s", header, status, bytes, elapsed) 54 | 55 | l.Logger.Debug(l.buf.String()) 56 | } 57 | 58 | // TODO @shipperizer see if implementing this or not 59 | func (l *LogEntry) Panic(v interface{}, stack []byte) { 60 | return 61 | } 62 | 63 | func NewLogFormatter(logger LoggerInterface) *LogFormatter { 64 | l := new(LogFormatter) 65 | 66 | l.Logger = logger 67 | 68 | return l 69 | } 70 | -------------------------------------------------------------------------------- /pkg/status/handlers.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 8 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 9 | "github.com/go-chi/chi/v5" 10 | "go.opentelemetry.io/otel/trace" 11 | ) 12 | 13 | const okValue = "ok" 14 | 15 | type Status struct { 16 | Status string `json:"status"` 17 | BuildInfo *BuildInfo `json:"buildInfo"` 18 | } 19 | 20 | type API struct { 21 | tracer trace.Tracer 22 | 23 | monitor monitoring.MonitorInterface 24 | logger logging.LoggerInterface 25 | } 26 | 27 | func (a *API) RegisterEndpoints(mux *chi.Mux) { 28 | mux.Get("/api/v0/status", a.alive) 29 | mux.Get("/api/v0/version", a.version) 30 | 31 | } 32 | 33 | func (a *API) alive(w http.ResponseWriter, r *http.Request) { 34 | w.Header().Set("Content-Type", "application/json") 35 | w.WriteHeader(http.StatusOK) 36 | 37 | rr := Status{ 38 | Status: okValue, 39 | } 40 | 41 | _, span := a.tracer.Start(r.Context(), "buildInfo") 42 | 43 | if buildInfo := buildInfo(); buildInfo != nil { 44 | rr.BuildInfo = buildInfo 45 | } 46 | 47 | span.End() 48 | 49 | json.NewEncoder(w).Encode(rr) 50 | 51 | } 52 | 53 | func (a *API) version(w http.ResponseWriter, r *http.Request) { 54 | w.Header().Set("Content-Type", "application/json") 55 | w.WriteHeader(http.StatusOK) 56 | 57 | info := new(BuildInfo) 58 | if buildInfo := buildInfo(); buildInfo != nil { 59 | info = buildInfo 60 | } 61 | 62 | json.NewEncoder(w).Encode(info) 63 | 64 | } 65 | 66 | func NewAPI(tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *API { 67 | a := new(API) 68 | 69 | a.tracer = tracer 70 | a.monitor = monitor 71 | a.logger = logger 72 | 73 | return a 74 | } 75 | -------------------------------------------------------------------------------- /internal/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "go.uber.org/zap" 10 | "go.uber.org/zap/zapcore" 11 | "gopkg.in/natefinch/lumberjack.v2" 12 | ) 13 | 14 | // NewLogger creates a new default logger 15 | // it will need to be closed with 16 | // ``` 17 | // defer logger.Desugar().Sync() 18 | // ``` 19 | // to make sure all has been piped out before terminating 20 | func NewLogger(l, logpath string) *zap.SugaredLogger { 21 | var lvl string 22 | 23 | val := strings.ToLower(l) 24 | 25 | switch val { 26 | case "debug", "error", "warn", "info": 27 | lvl = val 28 | case "warning": 29 | lvl = "warn" 30 | default: 31 | lvl = "error" 32 | } 33 | 34 | rawJSON := []byte( 35 | fmt.Sprintf( 36 | `{ 37 | "level": "%s", 38 | "encoding": "json", 39 | "outputPaths": ["stdout"], 40 | "errorOutputPaths": ["stdout","stderr"], 41 | "encoderConfig": { 42 | "messageKey": "message", 43 | "levelKey": "severity", 44 | "levelEncoder": "lowercase", 45 | "timeKey": "@timestamp", 46 | "timeEncoder": "rfc3339nano" 47 | } 48 | }`, 49 | lvl), 50 | ) 51 | 52 | var cfg zap.Config 53 | if err := json.Unmarshal(rawJSON, &cfg); err != nil { 54 | panic(err) 55 | } 56 | 57 | core := zapcore.NewTee( 58 | zapcore.NewCore(zapcore.NewJSONEncoder(cfg.EncoderConfig), zapcore.AddSync(newRotator(logpath)), cfg.Level), 59 | zapcore.NewCore(zapcore.NewJSONEncoder(cfg.EncoderConfig), zapcore.AddSync(os.Stdout), cfg.Level), 60 | ) 61 | 62 | return zap.New(core).Sugar() 63 | 64 | } 65 | 66 | func newRotator(path string) *lumberjack.Logger { 67 | r := new(lumberjack.Logger) 68 | 69 | r.Filename = path 70 | r.MaxSize = 500 // megabyte 71 | r.MaxBackups = 2 72 | r.MaxAge = 3 // day 73 | 74 | return r 75 | } 76 | -------------------------------------------------------------------------------- /pkg/status/handlers_test.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/go-chi/chi/v5" 13 | "github.com/golang/mock/gomock" 14 | "github.com/stretchr/testify/assert" 15 | "go.opentelemetry.io/otel/trace" 16 | ) 17 | 18 | //go:generate mockgen -build_flags=--mod=mod -package status -destination ./mock_logger.go -source=../../internal/logging/interfaces.go 19 | //go:generate mockgen -build_flags=--mod=mod -package status -destination ./mock_monitor.go -source=../../internal/monitoring/interfaces.go 20 | //go:generate mockgen -build_flags=--mod=mod -package status -destination ./mock_tracer.go go.opentelemetry.io/otel/trace Tracer 21 | 22 | func TestAliveOK(t *testing.T) { 23 | ctrl := gomock.NewController(t) 24 | defer ctrl.Finish() 25 | 26 | mockLogger := NewMockLoggerInterface(ctrl) 27 | mockMonitor := NewMockMonitorInterface(ctrl) 28 | mockTracer := NewMockTracer(ctrl) 29 | 30 | req := httptest.NewRequest(http.MethodGet, "/api/v0/status", nil) 31 | w := httptest.NewRecorder() 32 | 33 | mockTracer.EXPECT().Start(gomock.Any(), gomock.Any()).Times(1).Return(context.TODO(), trace.SpanFromContext(req.Context())) 34 | 35 | mux := chi.NewMux() 36 | NewAPI(mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) 37 | 38 | mux.ServeHTTP(w, req) 39 | res := w.Result() 40 | defer res.Body.Close() 41 | data, err := ioutil.ReadAll(res.Body) 42 | if err != nil { 43 | t.Fatalf("expected error to be nil got %v", err) 44 | } 45 | receivedStatus := new(Status) 46 | if err := json.Unmarshal(data, receivedStatus); err != nil { 47 | t.Fatalf("expected error to be nil got %v", err) 48 | } 49 | assert.Equalf(t, "ok", receivedStatus.Status, "Expected %s, got %s", "ok", receivedStatus.Status) 50 | } 51 | -------------------------------------------------------------------------------- /internal/monitoring/prometheus/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 7 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 8 | "github.com/prometheus/client_golang/prometheus" 9 | ) 10 | 11 | type Monitor struct { 12 | service string 13 | 14 | responseTime *prometheus.HistogramVec 15 | 16 | logger logging.LoggerInterface 17 | } 18 | 19 | func (m *Monitor) GetService() string { 20 | return m.service 21 | } 22 | 23 | func (m *Monitor) GetResponseTimeMetric(tags map[string]string) (monitoring.MetricInterface, error) { 24 | if m.responseTime == nil { 25 | return nil, fmt.Errorf("metric not instantiated") 26 | } 27 | 28 | return m.responseTime.With(tags), nil 29 | } 30 | 31 | func (m *Monitor) registerHistograms() { 32 | histograms := make([]*prometheus.HistogramVec, 0) 33 | 34 | labels := map[string]string{ 35 | "service": m.service, 36 | } 37 | 38 | m.responseTime = prometheus.NewHistogramVec( 39 | prometheus.HistogramOpts{ 40 | Name: "http_response_time_seconds", 41 | Help: "http_response_time_seconds", 42 | ConstLabels: labels, 43 | }, 44 | []string{"route", "status"}, 45 | ) 46 | 47 | histograms = append(histograms, m.responseTime) 48 | 49 | for _, histogram := range histograms { 50 | err := prometheus.Register(histogram) 51 | 52 | switch err.(type) { 53 | case nil: 54 | return 55 | case prometheus.AlreadyRegisteredError: 56 | m.logger.Debugf("metric %v already registered", histogram) 57 | default: 58 | m.logger.Errorf("metric %v could not be registered", histogram) 59 | } 60 | } 61 | } 62 | 63 | func NewMonitor(service string, logger logging.LoggerInterface) *Monitor { 64 | m := new(Monitor) 65 | 66 | m.service = service 67 | m.logger = logger 68 | 69 | m.registerHistograms() 70 | 71 | return m 72 | } 73 | -------------------------------------------------------------------------------- /internal/monitoring/middlewares.go: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "time" 8 | 9 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 10 | "github.com/go-chi/chi/v5/middleware" 11 | ) 12 | 13 | const ( 14 | // IDPathRegex regexp used to swap the {id*} parameters in the path with simply id 15 | // supports alphabetic characters and underscores, no dashes 16 | IDPathRegex string = "{[a-zA-Z_]*}" 17 | ) 18 | 19 | // Middleware is the monitoring middleware object implementing Prometheus monitoring 20 | type Middleware struct { 21 | service string 22 | regex *regexp.Regexp 23 | 24 | monitor MonitorInterface 25 | logger logging.LoggerInterface 26 | } 27 | 28 | func (mdw *Middleware) ResponseTime() func(http.Handler) http.Handler { 29 | return func(next http.Handler) http.Handler { 30 | return http.HandlerFunc( 31 | func(w http.ResponseWriter, r *http.Request) { 32 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 33 | startTime := time.Now() 34 | 35 | next.ServeHTTP(ww, r) 36 | 37 | tags := map[string]string{ 38 | "route": fmt.Sprintf("%s%s", r.Method, mdw.regex.ReplaceAll([]byte(r.URL.Path), []byte("id"))), 39 | "status": fmt.Sprint(ww.Status()), 40 | } 41 | 42 | m, err := mdw.monitor.GetResponseTimeMetric(tags) 43 | 44 | if err != nil { 45 | mdw.logger.Debugf("error fetching metric: %s; keep going....", err) 46 | 47 | return 48 | } 49 | 50 | m.Observe(time.Since(startTime).Seconds()) 51 | }, 52 | ) 53 | } 54 | } 55 | 56 | // NewMiddleware returns a Middleware based on the type of monitor 57 | func NewMiddleware(monitor MonitorInterface, logger logging.LoggerInterface) *Middleware { 58 | mdw := new(Middleware) 59 | 60 | mdw.monitor = monitor 61 | 62 | mdw.service = monitor.GetService() 63 | mdw.logger = logger 64 | mdw.regex = regexp.MustCompile(IDPathRegex) 65 | 66 | return mdw 67 | } 68 | -------------------------------------------------------------------------------- /deployments/helm/kratos/values.yaml: -------------------------------------------------------------------------------- 1 | kratos: 2 | development: true 3 | config: 4 | serve: 5 | admin: 6 | base_url: http://kratos-admin.default.svc.cluster.local 7 | dsn: "postgres://iam:iam@postgresql.default.svc.cluster.local/kratos?sslmode=disable&max_conn_lifetime=10s" 8 | secrets: 9 | default: 10 | - SUFNUGxhdGZvcm0K 11 | identity: 12 | default_schema_id: default 13 | schemas: 14 | - id: default 15 | url: file:///etc/config/identity.default.schema.json 16 | courier: 17 | smtp: 18 | connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true 19 | selfservice: 20 | default_browser_return_url: http://127.0.0.1:4455/ 21 | automigration: 22 | enabled: true 23 | identitySchemas: 24 | "identity.default.schema.json": | 25 | { 26 | "$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json", 27 | "$schema": "http://json-schema.org/draft-07/schema#", 28 | "title": "Person", 29 | "type": "object", 30 | "properties": { 31 | "traits": { 32 | "type": "object", 33 | "properties": { 34 | "email": { 35 | "type": "string", 36 | "format": "email", 37 | "title": "E-Mail", 38 | "ory.sh/kratos": { 39 | "credentials": { 40 | "password": { 41 | "identifier": true 42 | } 43 | }, 44 | "recovery": { 45 | "via": "email" 46 | }, 47 | "verification": { 48 | "via": "email" 49 | } 50 | } 51 | } 52 | }, 53 | "required": [ 54 | "email" 55 | ], 56 | "additionalProperties": false 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/monitoring/middlewares_test.go: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/golang/mock/gomock" 10 | "github.com/prometheus/client_golang/prometheus/promhttp" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | //go:generate mockgen -build_flags=--mod=mod -package monitoring -destination ./mock_monitor.go -source=./interfaces.go 15 | //go:generate mockgen -build_flags=--mod=mod -package monitoring -destination ./mock_logger.go -source=../logging/interfaces.go 16 | 17 | type API struct{} 18 | 19 | func (a *API) RegisterEndpoints(router *chi.Mux) { 20 | router.Get("/api/v1/metrics", a.prometheusHTTP) 21 | router.Get("/api/test", a.test) 22 | } 23 | 24 | func (a *API) prometheusHTTP(w http.ResponseWriter, r *http.Request) { 25 | promhttp.Handler().ServeHTTP(w, r) 26 | } 27 | 28 | func (a *API) test(w http.ResponseWriter, r *http.Request) { 29 | w.Header().Set("Content-Type", "application/json") 30 | w.WriteHeader(http.StatusOK) 31 | } 32 | 33 | func TestMiddlewareResponseTime(t *testing.T) { 34 | ctrl := gomock.NewController(t) 35 | defer ctrl.Finish() 36 | 37 | mockMonitor := NewMockMonitorInterface(ctrl) 38 | mockMetric := NewMockMetricInterface(ctrl) 39 | mockLogger := NewMockLoggerInterface(ctrl) 40 | mockMonitor.EXPECT().GetService().Times(1) 41 | mockMonitor.EXPECT().GetResponseTimeMetric(gomock.Any()).Times(1).Return(mockMetric, nil) 42 | mockMetric.EXPECT().Observe(gomock.Any()).Times(1) 43 | 44 | assert := assert.New(t) 45 | 46 | router := chi.NewMux() 47 | 48 | router.Use(NewMiddleware(mockMonitor, mockLogger).ResponseTime()) 49 | 50 | new(API).RegisterEndpoints(router) 51 | 52 | // setup metrics endpoint 53 | req, err := http.NewRequest(http.MethodGet, "/api/test", nil) 54 | req.Header.Set("Content-Type", "application/json") 55 | assert.Nil(err, "error should be nil") 56 | 57 | rr := httptest.NewRecorder() 58 | 59 | router.ServeHTTP(rr, req) 60 | } 61 | -------------------------------------------------------------------------------- /skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v4beta6 2 | kind: Config 3 | build: 4 | artifacts: 5 | - image: "identity-platform-admin-ui" 6 | sync: 7 | infer: 8 | - "cmd/main.go" 9 | - "go.mod" 10 | - "go.sum" 11 | custom: 12 | buildCommand: ./build.sh 13 | dependencies: 14 | paths: 15 | - rockcraft.yaml 16 | platforms: ["linux/amd64"] 17 | local: 18 | push: true 19 | 20 | test: 21 | - image: "identity-platform-admin-ui" 22 | structureTests: 23 | - './structure-tests.yaml' 24 | 25 | 26 | manifests: 27 | rawYaml: 28 | - "deployments/kubectl/*" 29 | 30 | deploy: 31 | kubectl: 32 | helm: 33 | releases: 34 | - name: postgresql 35 | remoteChart: oci://registry-1.docker.io/bitnamicharts/postgresql 36 | valuesFiles: ["deployments/helm/postgresql/values.yaml"] 37 | - name: kratos 38 | remoteChart: kratos 39 | repo: https://k8s.ory.sh/helm/charts 40 | valuesFiles: ["deployments/helm/kratos/values.yaml"] 41 | wait: false 42 | - name: hydra 43 | remoteChart: hydra 44 | repo: https://k8s.ory.sh/helm/charts 45 | valuesFiles: ["deployments/helm/hydra/values.yaml"] 46 | wait: false 47 | - name: oathkeeper 48 | remoteChart: oathkeeper 49 | repo: https://k8s.ory.sh/helm/charts 50 | setValues: 51 | demo: "true" 52 | wait: false 53 | 54 | portForward: 55 | - resourceType: service 56 | resourceName: identity-platform-admin-ui 57 | namespace: default 58 | port: 80 59 | localPort: 8000 60 | - resourceType: service 61 | resourceName: kratos-admin 62 | namespace: default 63 | port: 80 64 | localPort: 14434 65 | - resourceType: service 66 | resourceName: kratos-public 67 | namespace: default 68 | port: 80 69 | localPort: 14433 70 | - resourceType: service 71 | resourceName: hydra-admin 72 | namespace: default 73 | port: 4445 74 | localPort: 14445 75 | - resourceType: service 76 | resourceName: hydra-public 77 | namespace: default 78 | port: 4444 79 | localPort: 14444 80 | - resourceType: service 81 | resourceName: oathkeeper-api 82 | namespace: default 83 | port: 4456 84 | localPort: 14456 -------------------------------------------------------------------------------- /.github/workflows/ossf.yaml: -------------------------------------------------------------------------------- 1 | name: Scorecard analysis workflow 2 | on: 3 | # Only the default branch is supported. 4 | branch_protection_rule: 5 | schedule: 6 | # Weekly on Saturdays. 7 | - cron: '30 1 * * 6' 8 | push: 9 | branches: [ main ] 10 | 11 | # Declare default permissions as read only. 12 | permissions: read-all 13 | 14 | jobs: 15 | analysis: 16 | name: Scorecard analysis 17 | runs-on: ubuntu-latest 18 | permissions: 19 | # Needed if using Code scanning alerts 20 | security-events: write 21 | # Needed for GitHub OIDC token if publish_results is true 22 | id-token: write 23 | 24 | steps: 25 | - name: "Checkout code" 26 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 27 | with: 28 | persist-credentials: false 29 | 30 | - name: "Run analysis" 31 | uses: ossf/scorecard-action@483ef80eb98fb506c348f7d62e28055e49fe2398 # v2.3.0 32 | with: 33 | results_file: results.sarif 34 | results_format: sarif 35 | # (Optional) fine-grained personal access token. Uncomment the `repo_token` line below if: 36 | # - you want to enable the Branch-Protection check on a *public* repository, or 37 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-fine-grained-pat-optional. 38 | repo_token: ${{ secrets.SCORECARD_TOKEN }} 39 | 40 | # Publish the results for public repositories to enable scorecard badges. For more details, see 41 | # https://github.com/ossf/scorecard-action#publishing-results. 42 | # For private repositories, `publish_results` will automatically be set to `false`, regardless 43 | # of the value entered here. 44 | publish_results: true 45 | 46 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 47 | # format to the repository Actions tab. 48 | - name: "Upload artifact" 49 | uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 50 | with: 51 | name: SARIF file 52 | path: results.sarif 53 | retention-days: 5 54 | 55 | # required for Code scanning alerts 56 | - name: "Upload SARIF results to code scanning" 57 | uses: github/codeql-action/upload-sarif@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2.22.4 58 | with: 59 | sarif_file: results.sarif 60 | -------------------------------------------------------------------------------- /pkg/web/router.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | ih "github.com/canonical/identity-platform-admin-ui/internal/hydra" 7 | ik "github.com/canonical/identity-platform-admin-ui/internal/kratos" 8 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 9 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 10 | "github.com/canonical/identity-platform-admin-ui/internal/tracing" 11 | chi "github.com/go-chi/chi/v5" 12 | middleware "github.com/go-chi/chi/v5/middleware" 13 | trace "go.opentelemetry.io/otel/trace" 14 | 15 | "github.com/canonical/identity-platform-admin-ui/pkg/clients" 16 | "github.com/canonical/identity-platform-admin-ui/pkg/identities" 17 | "github.com/canonical/identity-platform-admin-ui/pkg/idp" 18 | "github.com/canonical/identity-platform-admin-ui/pkg/metrics" 19 | "github.com/canonical/identity-platform-admin-ui/pkg/schemas" 20 | "github.com/canonical/identity-platform-admin-ui/pkg/status" 21 | ) 22 | 23 | func NewRouter(idpConfig *idp.Config, schemasConfig *schemas.Config, hydraClient *ih.Client, kratos *ik.Client, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) http.Handler { 24 | router := chi.NewMux() 25 | 26 | middlewares := make(chi.Middlewares, 0) 27 | middlewares = append( 28 | middlewares, 29 | middleware.RequestID, 30 | monitoring.NewMiddleware(monitor, logger).ResponseTime(), 31 | middlewareCORS([]string{"*"}), 32 | ) 33 | 34 | // TODO @shipperizer add a proper configuration to enable http logger middleware as it's expensive 35 | if true { 36 | middlewares = append( 37 | middlewares, 38 | middleware.RequestLogger(logging.NewLogFormatter(logger)), // LogFormatter will only work if logger is set to DEBUG level 39 | ) 40 | } 41 | 42 | router.Use(middlewares...) 43 | 44 | status.NewAPI(tracer, monitor, logger).RegisterEndpoints(router) 45 | metrics.NewAPI(logger).RegisterEndpoints(router) 46 | identities.NewAPI( 47 | identities.NewService(kratos.IdentityApi(), tracer, monitor, logger), 48 | logger, 49 | ).RegisterEndpoints(router) 50 | clients.NewAPI( 51 | clients.NewService(hydraClient, tracer, monitor, logger), 52 | logger, 53 | ).RegisterEndpoints(router) 54 | idp.NewAPI( 55 | idp.NewService(idpConfig, tracer, monitor, logger), 56 | logger, 57 | ).RegisterEndpoints(router) 58 | schemas.NewAPI( 59 | schemas.NewService(schemasConfig, tracer, monitor, logger), 60 | logger, 61 | ).RegisterEndpoints(router) 62 | return tracing.NewMiddleware(monitor, logger).OpenTelemetry(router) 63 | } 64 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":disableDependencyDashboard", 6 | ":automergeDigest", 7 | ":automergePatch", 8 | ":automergeMinor", 9 | ":rebaseStalePrs", 10 | ":semanticCommits", 11 | ":semanticCommitScope(deps)", 12 | "helpers:pinGitHubActionDigests", 13 | ], 14 | "automergeType": "pr", 15 | "rebaseWhen": "behind-base-branch", 16 | "packageRules": [ 17 | { 18 | "groupName": "github actions", 19 | "matchManagers": ["github-actions"], 20 | "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"], 21 | "automerge": true, 22 | "schedule": ["at any time"], 23 | "additionalBranchPrefix": "auto-" 24 | }, 25 | { 26 | "groupName": "UI deps", 27 | "matchManagers": ["npm"], 28 | "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"], 29 | "automerge": true, 30 | "schedule": ["at any time"], 31 | "prPriority": 4, 32 | "additionalBranchPrefix": "auto-" 33 | }, 34 | { 35 | "groupName": "internal UI dependencies", 36 | "groupSlug": "internal", 37 | "packagePatterns": [ 38 | "^@canonical", 39 | "^canonicalwebteam", 40 | "^vanilla-framework" 41 | ], 42 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 43 | "schedule": ["at any time"], 44 | "prPriority": 5, 45 | "additionalBranchPrefix": "auto-" 46 | }, 47 | { 48 | "groupName": "internal UI dependencies", 49 | "groupSlug": "internal", 50 | "packagePatterns": [ 51 | "^@canonical", 52 | "^canonicalwebteam", 53 | "^vanilla-framework" 54 | ], 55 | "matchUpdateTypes": ["major"], 56 | "schedule": ["at any time"], 57 | "prPriority": 5 58 | }, 59 | { 60 | "groupName": "Go deps", 61 | "matchManagers": ["gomod"], 62 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 63 | "schedule": ["at any time"], 64 | "additionalBranchPrefix": "auto-" 65 | }, 66 | { 67 | "groupName": "Go deps", 68 | "matchManagers": ["gomod"], 69 | "matchUpdateTypes": ["major"], 70 | "schedule": ["at any time"] 71 | }, 72 | { 73 | "groupName": "renovate packages", 74 | "matchSourceUrlPrefixes": ["https://github.com/renovatebot/"], 75 | "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"], 76 | "automerge": true, 77 | "schedule": ["at any time"], 78 | "additionalBranchPrefix": "auto-" 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yaml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [main] 14 | schedule: 15 | - cron: '0 19 * * 4' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['go'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2 -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: container publish 2 | run-name: Publish container from ${{ inputs.rock }} to ghcr.io/canonical/identity-platform-admin-ui 3 | 4 | on: 5 | workflow_call: 6 | inputs: 7 | rock: 8 | type: string 9 | required: true 10 | description: "rock path to download" 11 | outputs: 12 | image: 13 | description: "container image" 14 | value: ${{ jobs.publish.outputs.image }} 15 | 16 | jobs: 17 | publish: 18 | runs-on: ubuntu-latest 19 | outputs: 20 | image: ${{ steps.set.outputs.image }} 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 24 | 25 | - name: Download Artifact 26 | uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3 27 | with: 28 | name: ${{ inputs.rock }} 29 | 30 | - name: Install Skopeo 31 | run: sudo snap install --devmode --channel edge skopeo 32 | 33 | - name: Install Container Structure Tests tools 34 | run: | 35 | mkdir -p bin/ 36 | curl -Lo bin/container-structure-test https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 37 | chmod +x bin/container-structure-test 38 | echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH 39 | - name: Run container structural tests 40 | run: | 41 | # docker-daemon avoids the push to the remote registry 42 | sudo skopeo --insecure-policy copy oci-archive:$(realpath ./"${{ inputs.rock }}") docker-daemon:ghcr.io/canonical/identity-platform-admin-ui:${{ github.sha }} --dest-creds ${{ github.actor }}:${{ secrets.GITHUB_TOKEN }} 43 | container-structure-test test -c structure-tests.yaml -i ghcr.io/canonical/identity-platform-admin-ui:${{ github.sha }} 44 | - name: Upload ROCK to ghcr.io with latest 45 | id: latest 46 | if: github.ref_type == 'branch' 47 | run: | 48 | sudo skopeo --insecure-policy copy oci-archive:$(realpath ./"${{ inputs.rock }}") docker://ghcr.io/canonical/identity-platform-admin-ui:"${{ github.sha }}" --dest-creds "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" 49 | sudo skopeo --insecure-policy copy oci-archive:$(realpath ./"${{ inputs.rock }}") docker://ghcr.io/canonical/identity-platform-admin-ui:latest --dest-creds "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" 50 | echo "image=ghcr.io/canonical/identity-platform-admin-ui:${{ github.sha }}" >> "$GITHUB_ENV" 51 | - name: Upload ROCK to ghcr.io with stable 52 | id: stable 53 | if: github.ref_type == 'tag' 54 | run: | 55 | sudo skopeo --insecure-policy copy oci-archive:$(realpath ./"${{ inputs.rock }}") docker://ghcr.io/canonical/identity-platform-admin-ui:"${{ github.ref_name }}" --dest-creds "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" 56 | sudo skopeo --insecure-policy copy oci-archive:$(realpath ./"${{ inputs.rock }}") docker://ghcr.io/canonical/identity-platform-admin-ui:stable --dest-creds "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" 57 | echo "image=ghcr.io/canonical/identity-platform-admin-ui:${{ github.ref_name }}" >> "$GITHUB_ENV" 58 | - name: Set output of image 59 | id: set 60 | run: echo "image=$image" >> "$GITHUB_OUTPUT" 61 | 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.2.0](https://github.com/canonical/identity-platform-admin-ui/compare/v1.1.0...v1.2.0) (2023-08-10) 4 | 5 | 6 | ### Features 7 | 8 | * add idp handlers ([405bad3](https://github.com/canonical/identity-platform-admin-ui/commit/405bad314cb3b3a79b0455b74b7a123cb09818b7)) 9 | * add idp service ([4f04546](https://github.com/canonical/identity-platform-admin-ui/commit/4f04546e2a1f75f16ce36a1bea051ce012d8e44c)) 10 | * wire up main and router with new dependencies ([7c218d3](https://github.com/canonical/identity-platform-admin-ui/commit/7c218d3ea8fd9413e808afa7f54a265a3e1dec6d)) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * add otel tracing to hydra client ([64871cd](https://github.com/canonical/identity-platform-admin-ui/commit/64871cdb232a92ebb11b4ed0d05282898cdc9f9d)) 16 | * create k8s coreV1 package ([ff260f9](https://github.com/canonical/identity-platform-admin-ui/commit/ff260f927d1930fb587ac515962fe4605b2d9223)) 17 | * drop unused const ([bb3bd28](https://github.com/canonical/identity-platform-admin-ui/commit/bb3bd28a0f1df6904d5f6355b9bcc198276d8db7)) 18 | * use io pkg instead of ioutil ([909459c](https://github.com/canonical/identity-platform-admin-ui/commit/909459c1041391d6906e20ecbe9c129523c8774f)) 19 | * use new instead of & syntax ([9908ddc](https://github.com/canonical/identity-platform-admin-ui/commit/9908ddc30301816b623d0bf8e064cae1c1dd91f6)) 20 | 21 | ## [1.1.0](https://github.com/canonical/identity-platform-admin-ui/compare/v1.0.0...v1.1.0) (2023-07-27) 22 | 23 | 24 | ### Features 25 | 26 | * add hydra service ([17a3c86](https://github.com/canonical/identity-platform-admin-ui/commit/17a3c866cffcf5ef8c5f54881482ccfe2f4b4d1d)) 27 | * add identities service layer ([d619daf](https://github.com/canonical/identity-platform-admin-ui/commit/d619dafe04f3452402f488a4f75739cfdc68b2d5)) 28 | * create apis for identities kratos REST endpoints ([6da5dae](https://github.com/canonical/identity-platform-admin-ui/commit/6da5dae6f73602c80057ed20b2de7bdb06288fcb)) 29 | * create kratos client ([d009507](https://github.com/canonical/identity-platform-admin-ui/commit/d009507359360bbd1fa05b494e5db25d68721d77)) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * add jaeger propagator as ory components support only these spans for now ([5a90f83](https://github.com/canonical/identity-platform-admin-ui/commit/5a90f838f224add360c81aeaf88a66e2811a7185)) 35 | * fail if HYDRA_ADMIN_URL is not provided ([c9e1844](https://github.com/canonical/identity-platform-admin-ui/commit/c9e18449a2cef297ed34414ec1a5b88177ce9b38)) 36 | * IAM-339 - add generic response pkg ([b98a505](https://github.com/canonical/identity-platform-admin-ui/commit/b98a505ac3ababddb27a0b903842db4f73a65e1d)) 37 | * introduce otelHTTP and otelGRPC exporter for tempo ([9156892](https://github.com/canonical/identity-platform-admin-ui/commit/91568926bc441372c4b342a5cdd42433b6fbd3fb)) 38 | * only print hydra debug logs on debug ([15dc2b4](https://github.com/canonical/identity-platform-admin-ui/commit/15dc2b4ba473384569b13fcbc84ecb29cfb021d4)) 39 | * wire up new kratos endpoints ([1d881a7](https://github.com/canonical/identity-platform-admin-ui/commit/1d881a70ddfed165ba85017d517f56e9e7b2c490)) 40 | 41 | ## 1.0.0 (2023-07-07) 42 | 43 | 44 | ### Features 45 | 46 | * Add go code skeleton ([10aec9d](https://github.com/canonical/identity-platform-admin-ui/commit/10aec9d8f2181d7c6c0d5cc2aebf861381827139)) 47 | * add ui skeleton ([ce6b51f](https://github.com/canonical/identity-platform-admin-ui/commit/ce6b51ff0659c16751b7d2371d4b19f399cad59a)) 48 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | 10 | "syscall" 11 | "time" 12 | 13 | ih "github.com/canonical/identity-platform-admin-ui/internal/hydra" 14 | "github.com/canonical/identity-platform-admin-ui/internal/k8s" 15 | "github.com/kelseyhightower/envconfig" 16 | 17 | "github.com/canonical/identity-platform-admin-ui/internal/config" 18 | ik "github.com/canonical/identity-platform-admin-ui/internal/kratos" 19 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 20 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring/prometheus" 21 | "github.com/canonical/identity-platform-admin-ui/internal/tracing" 22 | "github.com/canonical/identity-platform-admin-ui/internal/version" 23 | "github.com/canonical/identity-platform-admin-ui/pkg/idp" 24 | "github.com/canonical/identity-platform-admin-ui/pkg/schemas" 25 | "github.com/canonical/identity-platform-admin-ui/pkg/web" 26 | ) 27 | 28 | func main() { 29 | 30 | specs := new(config.EnvSpec) 31 | 32 | if err := envconfig.Process("", specs); err != nil { 33 | panic(fmt.Errorf("issues with environment sourcing: %s", err)) 34 | } 35 | 36 | flags := config.NewFlags() 37 | 38 | switch { 39 | case flags.ShowVersion: 40 | fmt.Printf("App Version: %s\n", version.Version) 41 | os.Exit(0) 42 | default: 43 | break 44 | } 45 | 46 | logger := logging.NewLogger(specs.LogLevel, specs.LogFile) 47 | 48 | monitor := prometheus.NewMonitor("identity-admin-ui", logger) 49 | tracer := tracing.NewTracer(tracing.NewConfig(specs.TracingEnabled, specs.OtelGRPCEndpoint, specs.OtelHTTPEndpoint, logger)) 50 | 51 | hAdminClient := ih.NewClient(specs.HydraAdminURL, specs.Debug) 52 | kAdminClient := ik.NewClient(specs.KratosAdminURL, specs.Debug) 53 | kPublicClient := ik.NewClient(specs.KratosPublicURL, specs.Debug) 54 | 55 | k8sCoreV1, err := k8s.NewCoreV1Client() 56 | 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | idpConfig := &idp.Config{ 62 | K8s: k8sCoreV1, 63 | Name: specs.IDPConfigMapName, 64 | Namespace: specs.IDPConfigMapNamespace, 65 | } 66 | 67 | schemasConfig := &schemas.Config{ 68 | K8s: k8sCoreV1, 69 | Kratos: kPublicClient.IdentityApi(), 70 | Name: specs.SchemasConfigMapName, 71 | Namespace: specs.SchemasConfigMapNamespace, 72 | } 73 | router := web.NewRouter(idpConfig, schemasConfig, hAdminClient, kAdminClient, tracer, monitor, logger) 74 | 75 | logger.Infof("Starting server on port %v", specs.Port) 76 | 77 | srv := &http.Server{ 78 | Addr: fmt.Sprintf("0.0.0.0:%v", specs.Port), 79 | WriteTimeout: time.Second * 15, 80 | ReadTimeout: time.Second * 15, 81 | IdleTimeout: time.Second * 60, 82 | Handler: router, 83 | } 84 | 85 | go func() { 86 | if err := srv.ListenAndServe(); err != nil { 87 | logger.Fatal(err) 88 | } 89 | }() 90 | 91 | c := make(chan os.Signal, 1) 92 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 93 | 94 | // Block until we receive our signal. 95 | <-c 96 | 97 | // Create a deadline to wait for. 98 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 99 | defer cancel() 100 | // Doesn't block if no connections, but will otherwise wait 101 | // until the timeout deadline. 102 | srv.Shutdown(ctx) 103 | 104 | logger.Desugar().Sync() 105 | 106 | // Optionally, you could run srv.Shutdown in a goroutine and block on 107 | // <-ctx.Done() if your application should wait for other services 108 | // to finalize based on context cancellation. 109 | logger.Info("Shutting down") 110 | os.Exit(0) 111 | 112 | } 113 | -------------------------------------------------------------------------------- /internal/tracing/tracer.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "context" 5 | "runtime/debug" 6 | 7 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 8 | "go.opentelemetry.io/contrib/propagators/jaeger" 9 | "go.opentelemetry.io/otel" 10 | "go.opentelemetry.io/otel/attribute" 11 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace" 12 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 13 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" 14 | "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" 15 | "go.opentelemetry.io/otel/propagation" 16 | "go.opentelemetry.io/otel/sdk/resource" 17 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 18 | semconv "go.opentelemetry.io/otel/semconv/v1.18.0" 19 | "go.opentelemetry.io/otel/trace" 20 | ) 21 | 22 | type Tracer struct { 23 | tracer trace.Tracer 24 | 25 | logger logging.LoggerInterface 26 | } 27 | 28 | func (t *Tracer) init(service string, e sdktrace.SpanExporter) { 29 | traceProvider := sdktrace.NewTracerProvider( 30 | sdktrace.WithSampler(sdktrace.AlwaysSample()), 31 | sdktrace.WithBatcher(e), 32 | sdktrace.WithResource( 33 | t.buildResource(service), 34 | ), 35 | ) 36 | 37 | otel.SetTracerProvider(traceProvider) 38 | otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}, jaeger.Jaeger{})) 39 | 40 | t.tracer = otel.Tracer(service) 41 | } 42 | 43 | func (t *Tracer) gitRevision(settings []debug.BuildSetting) string { 44 | for _, setting := range settings { 45 | if setting.Key == "vcs.revision" { 46 | return setting.Value 47 | } 48 | } 49 | 50 | return "n/a" 51 | } 52 | 53 | func (t *Tracer) buildResource(service string) *resource.Resource { 54 | var res *resource.Resource 55 | 56 | res = resource.NewWithAttributes( 57 | semconv.SchemaURL, 58 | semconv.ServiceName(service), 59 | semconv.ServiceVersion("n/a"), 60 | ) 61 | 62 | if info, ok := debug.ReadBuildInfo(); ok { 63 | if service == "" { 64 | service = info.Path 65 | } 66 | 67 | res = resource.NewWithAttributes( 68 | semconv.SchemaURL, 69 | semconv.ServiceName(service), 70 | attribute.String("git_sha", t.gitRevision(info.Settings)), 71 | attribute.String("app", info.Main.Path), 72 | ) 73 | } 74 | 75 | return res 76 | } 77 | 78 | func (t *Tracer) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { 79 | return t.tracer.Start(ctx, spanName, opts...) 80 | } 81 | 82 | // basic tracer implementation of trace.Tracer, just adding some extra configuration 83 | func NewTracer(cfg *Config) *Tracer { 84 | t := new(Tracer) 85 | 86 | t.logger = cfg.Logger 87 | 88 | // if tracing disabled skip the config 89 | if !cfg.Enabled { 90 | t.tracer = trace.NewNoopTracerProvider().Tracer("github.com/canonical/identity-platform-admin-ui") 91 | 92 | return t 93 | } 94 | 95 | var err error 96 | var exporter sdktrace.SpanExporter 97 | 98 | if cfg.OtelGRPCEndpoint != "" { 99 | exporter, err = otlptrace.New( 100 | context.TODO(), 101 | otlptracegrpc.NewClient( 102 | otlptracegrpc.WithEndpoint(cfg.OtelGRPCEndpoint), 103 | otlptracegrpc.WithInsecure(), 104 | ), 105 | ) 106 | } else if cfg.OtelHTTPEndpoint != "" { 107 | exporter, err = otlptrace.New( 108 | context.TODO(), 109 | otlptracehttp.NewClient( 110 | otlptracehttp.WithEndpoint(cfg.OtelHTTPEndpoint), 111 | otlptracehttp.WithInsecure(), 112 | ), 113 | ) 114 | } else { 115 | exporter, err = stdouttrace.New( 116 | stdouttrace.WithPrettyPrint(), 117 | ) 118 | } 119 | 120 | if err != nil { 121 | t.logger.Errorf("unable to initialize tracing exporter due: %w", err) 122 | return nil 123 | } 124 | 125 | // set tracer provider and propagator properly, this is to ensure all 126 | // instrumentation library could run well 127 | t.init("github.com/canonical/identity-platform-admin-ui", exporter) 128 | 129 | return t 130 | } 131 | -------------------------------------------------------------------------------- /deployments/kubectl/configMap.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: identity-platform-admin-ui 6 | data: 7 | PORT: "8000" 8 | LOG_LEVEL: DEBUG 9 | TRACING_ENABLED: "false" 10 | KRATOS_PUBLIC_URL: http://kratos-public.default.svc.cluster.local 11 | KRATOS_ADMIN_URL: http://kratos-public.default.svc.cluster.local 12 | HYDRA_ADMIN_URL: http://hydra-admin.default.svc.cluster.local:4445 13 | IDP_CONFIGMAP_NAME: idps 14 | IDP_CONFIGMAP_NAMESPACE: default 15 | SCHEMAS_CONFIGMAP_NAME: identity-schemas 16 | SCHEMAS_CONFIGMAP_NAMESPACE: default 17 | 18 | --- 19 | apiVersion: v1 20 | kind: ConfigMap 21 | metadata: 22 | name: idps 23 | data: 24 | "idps.yaml": | 25 | - id: microsoft_af675f353bd7451588e2b8032e315f6f 26 | client_id: af675f35-3bd7-4515-88e2-b8032e315f6f 27 | provider: microsoft 28 | client_secret: 3y38Q~aslkdhaskjhd~W0xWDB.123u98asd 29 | microsoft_tenant: e1574293-28de-4e94-87d5-b61c76fc14e1 30 | mapper_url: file:///etc/config/kratos/microsoft_schema.jsonnet 31 | scope: 32 | - profile 33 | - email 34 | - address 35 | - phone 36 | - id: google_18fa2999e6c9475aa49515d933d8e8ce 37 | client_id: 18fa2999-e6c9-475a-a495-15d933d8e8ce 38 | provider: google 39 | client_secret: 3y38Q~aslkdhaskjhd~W0xWDB.123u98asd 40 | mapper_url: file:///etc/config/kratos/google_schema.jsonnet 41 | scope: 42 | - profile 43 | - email 44 | - address 45 | - phone 46 | - id: aws_18fa2999e6c9475aa49589d941d8e1zy 47 | client_id: 18fa2999-e6c9-475a-a495-89d941d8e1zy 48 | provider: aws 49 | client_secret: 3y38Q~aslkdhaskjhd~W0xWDB.123u98asd 50 | mapper_url: file:///etc/config/kratos/google_schema.jsonnet 51 | scope: 52 | - profile 53 | - email 54 | - address 55 | - phone 56 | --- 57 | apiVersion: v1 58 | kind: ConfigMap 59 | metadata: 60 | name: identity-schemas 61 | data: 62 | "default.schema": "default" 63 | "admin_v0": | 64 | { 65 | "id": "admin_v0", 66 | "schema": { 67 | "$id": "https://schemas.canonical.com/presets/kratos/admin_v0.json", 68 | "$schema": "http://json-schema.org/draft-07/schema#", 69 | "properties": { 70 | "additionalProperties": true, 71 | "traits": { 72 | "properties": { 73 | "email": { 74 | "format": "email", 75 | "minLength": 3, 76 | "ory.sh/kratos": { 77 | "verification": { 78 | "via": "email" 79 | } 80 | }, 81 | "title": "E-Mail", 82 | "type": "string" 83 | }, 84 | "name": { 85 | "title": "Name", 86 | "type": "string" 87 | }, 88 | "phone_number": { 89 | "title": "Phone Number", 90 | "type": "string" 91 | }, 92 | "username": { 93 | "ory.sh/kratos": { 94 | "credentials": { 95 | "password": { 96 | "identifier": true 97 | } 98 | } 99 | }, 100 | "title": "Username", 101 | "type": "string" 102 | } 103 | }, 104 | "type": "object" 105 | } 106 | }, 107 | "title": "Admin Account", 108 | "type": "object" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/canonical/identity-platform-admin-ui 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.0.10 7 | github.com/go-chi/cors v1.2.1 8 | github.com/golang/mock v1.6.0 9 | github.com/google/uuid v1.3.1 10 | github.com/kelseyhightower/envconfig v1.4.0 11 | github.com/ory/hydra-client-go/v2 v2.1.1 12 | github.com/ory/kratos-client-go v1.0.0 13 | github.com/prometheus/client_golang v1.17.0 14 | github.com/stretchr/testify v1.8.4 15 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 16 | go.opentelemetry.io/contrib/propagators/jaeger v1.20.0 17 | go.opentelemetry.io/otel v1.19.0 18 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 19 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 20 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 21 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.19.0 22 | go.opentelemetry.io/otel/sdk v1.19.0 23 | go.opentelemetry.io/otel/trace v1.19.0 24 | go.uber.org/zap v1.26.0 25 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 26 | gopkg.in/yaml.v3 v3.0.1 27 | k8s.io/api v0.28.3 28 | k8s.io/apimachinery v0.28.3 29 | k8s.io/client-go v0.28.3 30 | ) 31 | 32 | require ( 33 | github.com/beorn7/perks v1.0.1 // indirect 34 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 35 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 36 | github.com/davecgh/go-spew v1.1.1 // indirect 37 | github.com/emicklei/go-restful/v3 v3.9.0 // indirect 38 | github.com/felixge/httpsnoop v1.0.3 // indirect 39 | github.com/go-logr/logr v1.2.4 // indirect 40 | github.com/go-logr/stdr v1.2.2 // indirect 41 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 42 | github.com/go-openapi/jsonreference v0.20.2 // indirect 43 | github.com/go-openapi/swag v0.22.3 // indirect 44 | github.com/gogo/protobuf v1.3.2 // indirect 45 | github.com/golang/protobuf v1.5.3 // indirect 46 | github.com/google/gnostic v0.5.7-v3refs // indirect 47 | github.com/google/gnostic-models v0.6.8 // indirect 48 | github.com/google/go-cmp v0.5.9 // indirect 49 | github.com/google/gofuzz v1.2.0 // indirect 50 | github.com/google/pprof v0.0.0-20221010195024-131d412537ea // indirect 51 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect 52 | github.com/josharian/intern v1.0.0 // indirect 53 | github.com/json-iterator/go v1.1.12 // indirect 54 | github.com/mailru/easyjson v0.7.7 // indirect 55 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 56 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 57 | github.com/modern-go/reflect2 v1.0.2 // indirect 58 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 59 | github.com/pmezard/go-difflib v1.0.0 // indirect 60 | github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect 61 | github.com/prometheus/common v0.44.0 // indirect 62 | github.com/prometheus/procfs v0.11.1 // indirect 63 | go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0 // indirect 64 | go.opentelemetry.io/otel/metric v1.19.0 // indirect 65 | go.opentelemetry.io/proto/otlp v1.0.0 // indirect 66 | go.uber.org/atomic v1.10.0 // indirect 67 | go.uber.org/multierr v1.10.0 // indirect 68 | golang.org/x/net v0.17.0 // indirect 69 | golang.org/x/oauth2 v0.11.0 // indirect 70 | golang.org/x/sys v0.13.0 // indirect 71 | golang.org/x/term v0.13.0 // indirect 72 | golang.org/x/text v0.13.0 // indirect 73 | golang.org/x/time v0.3.0 // indirect 74 | google.golang.org/appengine v1.6.7 // indirect 75 | google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 // indirect 76 | google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 // indirect 77 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect 78 | google.golang.org/grpc v1.58.2 // indirect 79 | google.golang.org/protobuf v1.31.0 // indirect 80 | gopkg.in/inf.v0 v0.9.1 // indirect 81 | gopkg.in/yaml.v2 v2.4.0 // indirect 82 | k8s.io/klog/v2 v2.100.1 // indirect 83 | k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect 84 | k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect 85 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 86 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect 87 | sigs.k8s.io/yaml v1.3.0 // indirect 88 | ) 89 | -------------------------------------------------------------------------------- /pkg/idp/third_party.go: -------------------------------------------------------------------------------- 1 | package idp 2 | 3 | import "encoding/json" 4 | 5 | // TODO @shipperizer once import of library is fixed find a way to use this schema with extra yaml annotations 6 | // coming from https://pkg.go.dev/github.com/ory/kratos@v1.0.0/selfservice/strategy/oidc#Configuration 7 | // importing the library github.com/ory/kratos fails due to compilations on their side 8 | type Configuration struct { 9 | // ID is the provider's ID 10 | ID string `json:"id" yaml:"id"` 11 | 12 | // Provider is either "generic" for a generic OAuth 2.0 / OpenID Connect Provider or one of: 13 | // - generic 14 | // - google 15 | // - github 16 | // - github-app 17 | // - gitlab 18 | // - microsoft 19 | // - discord 20 | // - slack 21 | // - facebook 22 | // - auth0 23 | // - vk 24 | // - yandex 25 | // - apple 26 | // - spotify 27 | // - netid 28 | // - dingtalk 29 | // - linkedin 30 | // - patreon 31 | Provider string `json:"provider" yaml:"provider"` 32 | 33 | // Label represents an optional label which can be used in the UI generation. 34 | Label string `json:"label"` 35 | 36 | // ClientID is the application's Client ID. 37 | ClientID string `json:"client_id" yaml:"client_id"` 38 | 39 | // ClientSecret is the application's secret. 40 | ClientSecret string `json:"client_secret" yaml:"client_secret"` 41 | 42 | // IssuerURL is the OpenID Connect Server URL. You can leave this empty if `provider` is not set to `generic`. 43 | // If set, neither `auth_url` nor `token_url` are required. 44 | IssuerURL string `json:"issuer_url" yaml:"issuer_url"` 45 | 46 | // AuthURL is the authorize url, typically something like: https://example.org/oauth2/auth 47 | // Should only be used when the OAuth2 / OpenID Connect server is not supporting OpenID Connect Discovery and when 48 | // `provider` is set to `generic`. 49 | AuthURL string `json:"auth_url" yaml:"auth_url"` 50 | 51 | // TokenURL is the token url, typically something like: https://example.org/oauth2/token 52 | // Should only be used when the OAuth2 / OpenID Connect server is not supporting OpenID Connect Discovery and when 53 | // `provider` is set to `generic`. 54 | TokenURL string `json:"token_url" yaml:"token_url"` 55 | 56 | // Tenant is the Azure AD Tenant to use for authentication, and must be set when `provider` is set to `microsoft`. 57 | // Can be either `common`, `organizations`, `consumers` for a multitenant application or a specific tenant like 58 | // `8eaef023-2b34-4da1-9baa-8bc8c9d6a490` or `contoso.onmicrosoft.com`. 59 | Tenant string `json:"microsoft_tenant" yaml:"microsoft_tenant"` 60 | 61 | // SubjectSource is a flag which controls from which endpoint the subject identifier is taken by microsoft provider. 62 | // Can be either `userinfo` or `me`. 63 | // If the value is `uerinfo` then the subject identifier is taken from sub field of uderifo standard endpoint response. 64 | // If the value is `me` then the `id` field of https://graph.microsoft.com/v1.0/me response is taken as subject. 65 | // The default is `userinfo`. 66 | SubjectSource string `json:"subject_source" yaml:"subject_source"` 67 | 68 | // TeamId is the Apple Developer Team ID that's needed for the `apple` `provider` to work. 69 | // It can be found Apple Developer website and combined with `apple_private_key` and `apple_private_key_id` 70 | // is used to generate `client_secret` 71 | TeamId string `json:"apple_team_id" yaml:"apple_team_id"` 72 | 73 | // PrivateKeyId is the private Apple key identifier. Keys can be generated via developer.apple.com. 74 | // This key should be generated with the `Sign In with Apple` option checked. 75 | // This is needed when `provider` is set to `apple` 76 | PrivateKeyId string `json:"apple_private_key_id" yaml:"apple_private_key_id"` 77 | 78 | // PrivateKeyId is the Apple private key identifier that can be downloaded during key generation. 79 | // This is needed when `provider` is set to `apple` 80 | PrivateKey string `json:"apple_private_key" yaml:"apple_private_key"` 81 | 82 | // Scope specifies optional requested permissions. 83 | Scope []string `json:"scope" yaml:"scope"` 84 | 85 | // Mapper specifies the JSONNet code snippet which uses the OpenID Connect Provider's data (e.g. GitHub or Google 86 | // profile information) to hydrate the identity's data. 87 | // 88 | // It can be either a URL (file://, http(s)://, base64://) or an inline JSONNet code snippet. 89 | Mapper string `json:"mapper_url" yaml:"mapper_url"` 90 | 91 | // RequestedClaims string encoded json object that specifies claims and optionally their properties which should be 92 | // included in the id_token or returned from the UserInfo Endpoint. 93 | // 94 | // More information: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter 95 | RequestedClaims json.RawMessage `json:"requested_claims" yaml:"requested_claims"` 96 | } 97 | -------------------------------------------------------------------------------- /pkg/idp/handlers.go: -------------------------------------------------------------------------------- 1 | package idp 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/canonical/identity-platform-admin-ui/internal/http/types" 9 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 10 | "github.com/go-chi/chi/v5" 11 | ) 12 | 13 | const okValue = "ok" 14 | 15 | type API struct { 16 | service ServiceInterface 17 | 18 | logger logging.LoggerInterface 19 | } 20 | 21 | func (a *API) RegisterEndpoints(mux *chi.Mux) { 22 | mux.Get("/api/v0/idps", a.handleList) 23 | mux.Get("/api/v0/idps/{id}", a.handleDetail) 24 | mux.Post("/api/v0/idps", a.handleCreate) 25 | mux.Patch("/api/v0/idps/{id}", a.handlePartialUpdate) 26 | mux.Delete("/api/v0/idps/{id}", a.handleRemove) 27 | } 28 | 29 | func (a *API) handleList(w http.ResponseWriter, r *http.Request) { 30 | w.Header().Set("Content-Type", "application/json") 31 | 32 | idps, err := a.service.ListResources(r.Context()) 33 | 34 | if err != nil { 35 | 36 | rr := types.Response{ 37 | Status: http.StatusInternalServerError, 38 | Message: err.Error(), 39 | } 40 | 41 | w.WriteHeader(http.StatusInternalServerError) 42 | json.NewEncoder(w).Encode(rr) 43 | 44 | return 45 | } 46 | 47 | w.WriteHeader(http.StatusOK) 48 | json.NewEncoder(w).Encode( 49 | types.Response{ 50 | Data: idps, 51 | Message: "List of IDPs", 52 | Status: http.StatusOK, 53 | }, 54 | ) 55 | } 56 | 57 | func (a *API) handleDetail(w http.ResponseWriter, r *http.Request) { 58 | w.Header().Set("Content-Type", "application/json") 59 | ID := chi.URLParam(r, "id") 60 | 61 | idps, err := a.service.GetResource(r.Context(), ID) 62 | 63 | if err != nil { 64 | 65 | rr := types.Response{ 66 | Status: http.StatusInternalServerError, 67 | Message: err.Error(), 68 | } 69 | 70 | w.WriteHeader(http.StatusInternalServerError) 71 | json.NewEncoder(w).Encode(rr) 72 | 73 | return 74 | } 75 | 76 | w.WriteHeader(http.StatusOK) 77 | json.NewEncoder(w).Encode( 78 | types.Response{ 79 | Data: idps, 80 | Message: "Detail of IDPs", 81 | Status: http.StatusOK, 82 | }, 83 | ) 84 | } 85 | 86 | func (a *API) handlePartialUpdate(w http.ResponseWriter, r *http.Request) { 87 | w.Header().Set("Content-Type", "application/json") 88 | ID := chi.URLParam(r, "id") 89 | 90 | defer r.Body.Close() 91 | body, err := io.ReadAll(r.Body) 92 | 93 | if err != nil { 94 | w.WriteHeader(http.StatusBadRequest) 95 | json.NewEncoder(w).Encode( 96 | types.Response{ 97 | Message: "Error parsing request payload", 98 | Status: http.StatusBadRequest, 99 | }, 100 | ) 101 | 102 | return 103 | } 104 | 105 | idp := new(Configuration) 106 | if err := json.Unmarshal(body, idp); err != nil { 107 | w.WriteHeader(http.StatusBadRequest) 108 | json.NewEncoder(w).Encode( 109 | types.Response{ 110 | Message: "Error parsing JSON payload", 111 | Status: http.StatusBadRequest, 112 | }, 113 | ) 114 | 115 | return 116 | 117 | } 118 | 119 | idps, err := a.service.EditResource(r.Context(), ID, idp) 120 | 121 | if err != nil { 122 | 123 | rr := types.Response{ 124 | Status: http.StatusInternalServerError, 125 | Message: err.Error(), 126 | } 127 | 128 | w.WriteHeader(http.StatusInternalServerError) 129 | json.NewEncoder(w).Encode(rr) 130 | 131 | return 132 | } 133 | 134 | w.WriteHeader(http.StatusOK) 135 | json.NewEncoder(w).Encode( 136 | types.Response{ 137 | Data: idps, 138 | Message: "Updated IDP", 139 | Status: http.StatusOK, 140 | }, 141 | ) 142 | } 143 | 144 | func (a *API) handleCreate(w http.ResponseWriter, r *http.Request) { 145 | w.Header().Set("Content-Type", "application/json") 146 | defer r.Body.Close() 147 | body, err := io.ReadAll(r.Body) 148 | 149 | if err != nil { 150 | w.WriteHeader(http.StatusBadRequest) 151 | json.NewEncoder(w).Encode( 152 | types.Response{ 153 | Message: "Error parsing request payload", 154 | Status: http.StatusBadRequest, 155 | }, 156 | ) 157 | 158 | return 159 | } 160 | 161 | idp := new(Configuration) 162 | if err := json.Unmarshal(body, idp); err != nil { 163 | w.WriteHeader(http.StatusBadRequest) 164 | json.NewEncoder(w).Encode( 165 | types.Response{ 166 | Message: "Error parsing JSON payload", 167 | Status: http.StatusBadRequest, 168 | }, 169 | ) 170 | 171 | return 172 | 173 | } 174 | 175 | idps, err := a.service.CreateResource(r.Context(), idp) 176 | 177 | if err != nil { 178 | status := http.StatusInternalServerError 179 | 180 | if idps != nil { 181 | status = http.StatusConflict 182 | } 183 | 184 | rr := types.Response{ 185 | Status: status, 186 | Message: err.Error(), 187 | } 188 | 189 | w.WriteHeader(status) 190 | json.NewEncoder(w).Encode(rr) 191 | 192 | return 193 | } 194 | 195 | w.WriteHeader(http.StatusOK) 196 | json.NewEncoder(w).Encode( 197 | types.Response{ 198 | Data: idps, 199 | Message: "Created IDP", 200 | Status: http.StatusOK, 201 | }, 202 | ) 203 | } 204 | 205 | func (a *API) handleRemove(w http.ResponseWriter, r *http.Request) { 206 | w.Header().Set("Content-Type", "application/json") 207 | id := chi.URLParam(r, "id") 208 | 209 | err := a.service.DeleteResource(r.Context(), id) 210 | 211 | if err != nil { 212 | 213 | rr := types.Response{ 214 | Status: http.StatusInternalServerError, 215 | Message: err.Error(), 216 | } 217 | 218 | w.WriteHeader(http.StatusInternalServerError) 219 | json.NewEncoder(w).Encode(rr) 220 | 221 | return 222 | } 223 | 224 | w.WriteHeader(http.StatusOK) 225 | json.NewEncoder(w).Encode( 226 | types.Response{ 227 | Data: nil, 228 | Message: "IDP deleted", 229 | Status: http.StatusOK, 230 | }, 231 | ) 232 | } 233 | 234 | func NewAPI(service ServiceInterface, logger logging.LoggerInterface) *API { 235 | a := new(API) 236 | 237 | a.service = service 238 | 239 | a.logger = logger 240 | 241 | return a 242 | } 243 | -------------------------------------------------------------------------------- /pkg/identities/service.go: -------------------------------------------------------------------------------- 1 | package identities 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 11 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 12 | kClient "github.com/ory/kratos-client-go" 13 | "go.opentelemetry.io/otel/trace" 14 | ) 15 | 16 | type Service struct { 17 | kratos kClient.IdentityApi 18 | 19 | tracer trace.Tracer 20 | monitor monitoring.MonitorInterface 21 | logger logging.LoggerInterface 22 | } 23 | 24 | type IdentityData struct { 25 | Identities []kClient.Identity 26 | Error *kClient.GenericError 27 | } 28 | 29 | // TODO @shipperizer verify during integration test if this is actually the format 30 | type KratosError struct { 31 | Error *kClient.GenericError `json:"error,omitempty"` 32 | } 33 | 34 | func (s *Service) buildListRequest(ctx context.Context, page, size int64, credID string) kClient.IdentityApiListIdentitiesRequest { 35 | r := s.kratos.ListIdentities(ctx).Page(page).PerPage(size) 36 | 37 | if credID != "" { 38 | r = r.CredentialsIdentifier(credID) 39 | } 40 | 41 | return r 42 | } 43 | 44 | func (s *Service) parseError(r *http.Response) *kClient.GenericError { 45 | gerr := KratosError{Error: kClient.NewGenericErrorWithDefaults()} 46 | 47 | defer r.Body.Close() 48 | body, _ := io.ReadAll(r.Body) 49 | 50 | if err := json.Unmarshal(body, &gerr); err != nil { 51 | gerr.Error.SetMessage("unable to parse kratos error response") 52 | gerr.Error.SetCode(http.StatusInternalServerError) 53 | } 54 | 55 | return gerr.Error 56 | } 57 | 58 | // TODO @shipperizer fix pagination 59 | func (s *Service) ListIdentities(ctx context.Context, page, size int64, credID string) (*IdentityData, error) { 60 | ctx, span := s.tracer.Start(ctx, "kratos.IdentityApi.ListIdentities") 61 | defer span.End() 62 | 63 | identities, rr, err := s.kratos.ListIdentitiesExecute( 64 | s.buildListRequest(ctx, page, size, credID), 65 | ) 66 | 67 | data := new(IdentityData) 68 | 69 | if err != nil { 70 | s.logger.Error(err) 71 | data.Error = s.parseError(rr) 72 | } 73 | 74 | data.Identities = identities 75 | 76 | // TODO @shipperizer check if identities is defaulting to empty slice inside kratos-client 77 | if data.Identities == nil { 78 | data.Identities = make([]kClient.Identity, 0) 79 | } 80 | 81 | return data, err 82 | } 83 | 84 | func (s *Service) GetIdentity(ctx context.Context, ID string) (*IdentityData, error) { 85 | ctx, span := s.tracer.Start(ctx, "kratos.IdentityApi.GetIdentity") 86 | defer span.End() 87 | 88 | identity, rr, err := s.kratos.GetIdentityExecute( 89 | s.kratos.GetIdentity(ctx, ID), 90 | ) 91 | 92 | data := new(IdentityData) 93 | 94 | if err != nil { 95 | s.logger.Error(err) 96 | data.Error = s.parseError(rr) 97 | } 98 | 99 | if identity != nil { 100 | data.Identities = []kClient.Identity{*identity} 101 | } else { 102 | data.Identities = []kClient.Identity{} 103 | } 104 | 105 | return data, err 106 | } 107 | 108 | func (s *Service) CreateIdentity(ctx context.Context, bodyID *kClient.CreateIdentityBody) (*IdentityData, error) { 109 | ctx, span := s.tracer.Start(ctx, "kratos.IdentityApi.CreateIdentity") 110 | defer span.End() 111 | 112 | if bodyID == nil { 113 | err := fmt.Errorf("no identity data passed") 114 | 115 | data := new(IdentityData) 116 | data.Identities = []kClient.Identity{} 117 | data.Error = s.parseError(nil) 118 | data.Error.SetMessage(err.Error()) 119 | 120 | s.logger.Error(err) 121 | 122 | return data, err 123 | } 124 | 125 | identity, rr, err := s.kratos.CreateIdentityExecute( 126 | s.kratos.CreateIdentity(ctx).CreateIdentityBody(*bodyID), 127 | ) 128 | 129 | data := new(IdentityData) 130 | 131 | if err != nil { 132 | s.logger.Error(err) 133 | data.Error = s.parseError(rr) 134 | } 135 | 136 | if identity != nil { 137 | data.Identities = []kClient.Identity{*identity} 138 | } else { 139 | data.Identities = []kClient.Identity{} 140 | } 141 | 142 | return data, err 143 | } 144 | 145 | func (s *Service) UpdateIdentity(ctx context.Context, ID string, bodyID *kClient.UpdateIdentityBody) (*IdentityData, error) { 146 | ctx, span := s.tracer.Start(ctx, "kratos.IdentityApi.UpdateIdentity") 147 | defer span.End() 148 | if ID == "" { 149 | err := fmt.Errorf("no identity ID passed") 150 | 151 | data := new(IdentityData) 152 | data.Identities = []kClient.Identity{} 153 | data.Error = s.parseError(nil) 154 | data.Error.SetMessage(err.Error()) 155 | 156 | s.logger.Error(err) 157 | 158 | return data, err 159 | } 160 | 161 | if bodyID == nil { 162 | err := fmt.Errorf("no identity body passed") 163 | 164 | data := new(IdentityData) 165 | data.Identities = []kClient.Identity{} 166 | data.Error = s.parseError(nil) 167 | data.Error.SetMessage(err.Error()) 168 | 169 | s.logger.Error(err) 170 | 171 | return data, err 172 | } 173 | 174 | identity, rr, err := s.kratos.UpdateIdentityExecute( 175 | s.kratos.UpdateIdentity(ctx, ID).UpdateIdentityBody(*bodyID), 176 | ) 177 | 178 | data := new(IdentityData) 179 | 180 | if err != nil { 181 | s.logger.Error(err) 182 | data.Error = s.parseError(rr) 183 | } 184 | 185 | if identity != nil { 186 | data.Identities = []kClient.Identity{*identity} 187 | } else { 188 | data.Identities = []kClient.Identity{} 189 | } 190 | 191 | return data, err 192 | } 193 | 194 | func (s *Service) DeleteIdentity(ctx context.Context, ID string) (*IdentityData, error) { 195 | ctx, span := s.tracer.Start(ctx, "kratos.IdentityApi.DeleteIdentity") 196 | defer span.End() 197 | 198 | rr, err := s.kratos.DeleteIdentityExecute( 199 | s.kratos.DeleteIdentity(ctx, ID), 200 | ) 201 | 202 | data := new(IdentityData) 203 | 204 | if err != nil { 205 | s.logger.Error(err) 206 | data.Error = s.parseError(rr) 207 | } 208 | 209 | data.Identities = []kClient.Identity{} 210 | 211 | return data, err 212 | } 213 | 214 | func NewService(kratos kClient.IdentityApi, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Service { 215 | s := new(Service) 216 | 217 | s.kratos = kratos 218 | 219 | s.monitor = monitor 220 | s.tracer = tracer 221 | s.logger = logger 222 | 223 | return s 224 | } 225 | -------------------------------------------------------------------------------- /pkg/schemas/handlers.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/canonical/identity-platform-admin-ui/internal/http/types" 9 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 10 | "github.com/go-chi/chi/v5" 11 | kClient "github.com/ory/kratos-client-go" 12 | ) 13 | 14 | const okValue = "ok" 15 | 16 | type API struct { 17 | service ServiceInterface 18 | 19 | logger logging.LoggerInterface 20 | } 21 | 22 | func (a *API) RegisterEndpoints(mux *chi.Mux) { 23 | mux.Get("/api/v0/schemas", a.handleList) 24 | mux.Get("/api/v0/schemas/{id}", a.handleDetail) 25 | mux.Post("/api/v0/schemas", a.handleCreate) 26 | mux.Patch("/api/v0/schemas/{id}", a.handlePartialUpdate) 27 | mux.Delete("/api/v0/schemas/{id}", a.handleRemove) 28 | } 29 | 30 | func (a *API) handleList(w http.ResponseWriter, r *http.Request) { 31 | w.Header().Set("Content-Type", "application/json") 32 | 33 | pagination := types.ParsePagination(r.URL.Query()) 34 | 35 | schemas, err := a.service.ListSchemas(r.Context(), pagination.Page, pagination.Size) 36 | 37 | if err != nil { 38 | rr := a.error(schemas.Error) 39 | 40 | w.WriteHeader(rr.Status) 41 | json.NewEncoder(w).Encode(rr) 42 | 43 | return 44 | } 45 | 46 | w.WriteHeader(http.StatusOK) 47 | json.NewEncoder(w).Encode( 48 | types.Response{ 49 | Data: schemas.IdentitySchemas, 50 | Message: "List of Identity Schemas", 51 | Status: http.StatusOK, 52 | Meta: pagination, 53 | }, 54 | ) 55 | } 56 | 57 | func (a *API) handleDetail(w http.ResponseWriter, r *http.Request) { 58 | w.Header().Set("Content-Type", "application/json") 59 | ID := chi.URLParam(r, "id") 60 | 61 | schemas, err := a.service.GetSchema(r.Context(), ID) 62 | 63 | if err != nil { 64 | rr := a.error(schemas.Error) 65 | 66 | w.WriteHeader(rr.Status) 67 | json.NewEncoder(w).Encode(rr) 68 | 69 | return 70 | } 71 | 72 | w.WriteHeader(http.StatusOK) 73 | json.NewEncoder(w).Encode( 74 | types.Response{ 75 | Data: schemas.IdentitySchemas, 76 | Message: "Detail of Identity Schemas", 77 | Status: http.StatusOK, 78 | }, 79 | ) 80 | } 81 | 82 | func (a *API) handlePartialUpdate(w http.ResponseWriter, r *http.Request) { 83 | w.Header().Set("Content-Type", "application/json") 84 | ID := chi.URLParam(r, "id") 85 | 86 | defer r.Body.Close() 87 | body, err := io.ReadAll(r.Body) 88 | 89 | if err != nil { 90 | w.WriteHeader(http.StatusBadRequest) 91 | json.NewEncoder(w).Encode( 92 | types.Response{ 93 | Message: "Error parsing request payload", 94 | Status: http.StatusBadRequest, 95 | }, 96 | ) 97 | 98 | return 99 | } 100 | 101 | schema := new(kClient.IdentitySchemaContainer) 102 | if err := json.Unmarshal(body, schema); err != nil { 103 | w.WriteHeader(http.StatusBadRequest) 104 | json.NewEncoder(w).Encode( 105 | types.Response{ 106 | Message: "Error parsing JSON payload", 107 | Status: http.StatusBadRequest, 108 | }, 109 | ) 110 | 111 | return 112 | 113 | } 114 | 115 | schemas, err := a.service.EditSchema(r.Context(), ID, schema) 116 | 117 | if err != nil { 118 | 119 | rr := types.Response{ 120 | Status: http.StatusInternalServerError, 121 | Message: err.Error(), 122 | } 123 | 124 | w.WriteHeader(http.StatusInternalServerError) 125 | json.NewEncoder(w).Encode(rr) 126 | 127 | return 128 | } 129 | 130 | w.WriteHeader(http.StatusOK) 131 | json.NewEncoder(w).Encode( 132 | types.Response{ 133 | Data: schemas.IdentitySchemas, 134 | Message: "Updated Identity Schemas", 135 | Status: http.StatusOK, 136 | }, 137 | ) 138 | } 139 | 140 | func (a *API) handleCreate(w http.ResponseWriter, r *http.Request) { 141 | w.Header().Set("Content-Type", "application/json") 142 | defer r.Body.Close() 143 | body, err := io.ReadAll(r.Body) 144 | 145 | if err != nil { 146 | w.WriteHeader(http.StatusBadRequest) 147 | json.NewEncoder(w).Encode( 148 | types.Response{ 149 | Message: "Error parsing request payload", 150 | Status: http.StatusBadRequest, 151 | }, 152 | ) 153 | 154 | return 155 | } 156 | 157 | schema := new(kClient.IdentitySchemaContainer) 158 | if err := json.Unmarshal(body, schema); err != nil { 159 | w.WriteHeader(http.StatusBadRequest) 160 | json.NewEncoder(w).Encode( 161 | types.Response{ 162 | Message: "Error parsing JSON payload", 163 | Status: http.StatusBadRequest, 164 | }, 165 | ) 166 | 167 | return 168 | 169 | } 170 | 171 | schemas, err := a.service.CreateSchema(r.Context(), schema) 172 | 173 | if err != nil { 174 | status := http.StatusInternalServerError 175 | 176 | if schemas != nil { 177 | status = http.StatusConflict 178 | } 179 | 180 | rr := types.Response{ 181 | Status: status, 182 | Message: err.Error(), 183 | } 184 | 185 | w.WriteHeader(status) 186 | json.NewEncoder(w).Encode(rr) 187 | 188 | return 189 | } 190 | 191 | w.WriteHeader(http.StatusOK) 192 | json.NewEncoder(w).Encode( 193 | types.Response{ 194 | Data: schemas.IdentitySchemas, 195 | Message: "Created Identity Schemas", 196 | Status: http.StatusOK, 197 | }, 198 | ) 199 | } 200 | 201 | func (a *API) handleRemove(w http.ResponseWriter, r *http.Request) { 202 | w.Header().Set("Content-Type", "application/json") 203 | id := chi.URLParam(r, "id") 204 | 205 | err := a.service.DeleteSchema(r.Context(), id) 206 | 207 | if err != nil { 208 | 209 | rr := types.Response{ 210 | Status: http.StatusInternalServerError, 211 | Message: err.Error(), 212 | } 213 | 214 | w.WriteHeader(http.StatusInternalServerError) 215 | json.NewEncoder(w).Encode(rr) 216 | 217 | return 218 | } 219 | 220 | w.WriteHeader(http.StatusOK) 221 | json.NewEncoder(w).Encode( 222 | types.Response{ 223 | Data: nil, 224 | Message: "Identity Schemas deleted", 225 | Status: http.StatusOK, 226 | }, 227 | ) 228 | } 229 | 230 | // TODO @shipperizer encapsulate kClient.GenericError into a service error to remove library dependency 231 | func (a *API) error(e *kClient.GenericError) types.Response { 232 | r := types.Response{ 233 | Status: http.StatusInternalServerError, 234 | } 235 | 236 | if e.Reason != nil { 237 | r.Message = *e.Reason 238 | } 239 | 240 | if e.Code != nil { 241 | r.Status = int(*e.Code) 242 | } 243 | 244 | return r 245 | 246 | } 247 | 248 | func NewAPI(service ServiceInterface, logger logging.LoggerInterface) *API { 249 | a := new(API) 250 | 251 | a.service = service 252 | 253 | a.logger = logger 254 | 255 | return a 256 | } 257 | -------------------------------------------------------------------------------- /pkg/identities/handlers.go: -------------------------------------------------------------------------------- 1 | package identities 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/canonical/identity-platform-admin-ui/internal/http/types" 9 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 10 | "github.com/go-chi/chi/v5" 11 | kClient "github.com/ory/kratos-client-go" 12 | ) 13 | 14 | // CreateIdentityRequest is used as a proxy struct 15 | type CreateIdentityRequest struct { 16 | kClient.CreateIdentityBody 17 | } 18 | 19 | // UpdateIdentityRequest is used as a proxy struct 20 | type UpdateIdentityRequest struct { 21 | kClient.UpdateIdentityBody 22 | } 23 | 24 | type API struct { 25 | service ServiceInterface 26 | baseURL string 27 | 28 | logger logging.LoggerInterface 29 | } 30 | 31 | func (a *API) RegisterEndpoints(mux *chi.Mux) { 32 | mux.Get("/api/v0/identities", a.handleList) 33 | mux.Get("/api/v0/identities/{id}", a.handleDetail) 34 | mux.Post("/api/v0/identities", a.handleCreate) 35 | mux.Put("/api/v0/identities/{id}", a.handleUpdate) 36 | // mux.Patch("/api/v0/identities/{id}", a.handlePartialUpdate) 37 | mux.Delete("/api/v0/identities/{id}", a.handleRemove) 38 | // mux.Delete("/api/v0/identities/{id}/sessions", a.handleSessionRemove) 39 | // mux.Delete("/api/v0/identities/{id}/credentials/{type}", a.handleCrededntialRemove) 40 | } 41 | 42 | func (a *API) handleList(w http.ResponseWriter, r *http.Request) { 43 | w.Header().Set("Content-Type", "application/json") 44 | 45 | pagination := types.ParsePagination(r.URL.Query()) 46 | 47 | credID := r.URL.Query().Get("credID") 48 | 49 | ids, err := a.service.ListIdentities(r.Context(), pagination.Page, pagination.Size, credID) 50 | 51 | if err != nil { 52 | rr := a.error(ids.Error) 53 | 54 | w.WriteHeader(rr.Status) 55 | json.NewEncoder(w).Encode(rr) 56 | 57 | return 58 | } 59 | 60 | w.WriteHeader(http.StatusOK) 61 | json.NewEncoder(w).Encode( 62 | types.Response{ 63 | Data: ids.Identities, 64 | Meta: pagination, 65 | Message: "List of identities", 66 | Status: http.StatusOK, 67 | }, 68 | ) 69 | } 70 | 71 | func (a *API) handleDetail(w http.ResponseWriter, r *http.Request) { 72 | w.Header().Set("Content-Type", "application/json") 73 | credID := chi.URLParam(r, "id") 74 | 75 | ids, err := a.service.GetIdentity(r.Context(), credID) 76 | 77 | if err != nil { 78 | rr := a.error(ids.Error) 79 | 80 | w.WriteHeader(rr.Status) 81 | json.NewEncoder(w).Encode(rr) 82 | 83 | return 84 | } 85 | 86 | w.WriteHeader(http.StatusOK) 87 | json.NewEncoder(w).Encode( 88 | types.Response{ 89 | Data: ids.Identities, 90 | Message: "Identity detail", 91 | Status: http.StatusOK, 92 | }, 93 | ) 94 | } 95 | 96 | func (a *API) handleCreate(w http.ResponseWriter, r *http.Request) { 97 | w.Header().Set("Content-Type", "application/json") 98 | 99 | defer r.Body.Close() 100 | body, err := io.ReadAll(r.Body) 101 | 102 | if err != nil { 103 | w.WriteHeader(http.StatusBadRequest) 104 | json.NewEncoder(w).Encode( 105 | types.Response{ 106 | Message: "Error parsing request payload", 107 | Status: http.StatusBadRequest, 108 | }, 109 | ) 110 | 111 | return 112 | } 113 | 114 | identity := new(CreateIdentityRequest) 115 | if err := json.Unmarshal(body, identity); err != nil { 116 | w.WriteHeader(http.StatusBadRequest) 117 | json.NewEncoder(w).Encode( 118 | types.Response{ 119 | Message: "Error parsing JSON payload", 120 | Status: http.StatusBadRequest, 121 | }, 122 | ) 123 | 124 | return 125 | 126 | } 127 | 128 | ids, err := a.service.CreateIdentity(r.Context(), &identity.CreateIdentityBody) 129 | 130 | if err != nil { 131 | rr := a.error(ids.Error) 132 | 133 | w.WriteHeader(rr.Status) 134 | json.NewEncoder(w).Encode(rr) 135 | 136 | return 137 | } 138 | 139 | w.WriteHeader(http.StatusCreated) 140 | json.NewEncoder(w).Encode( 141 | types.Response{ 142 | Data: ids.Identities, 143 | Message: "Created identity", 144 | Status: http.StatusCreated, 145 | }, 146 | ) 147 | } 148 | 149 | func (a *API) handleUpdate(w http.ResponseWriter, r *http.Request) { 150 | w.Header().Set("Content-Type", "application/json") 151 | 152 | credID := chi.URLParam(r, "id") 153 | 154 | defer r.Body.Close() 155 | body, err := io.ReadAll(r.Body) 156 | 157 | if err != nil { 158 | w.WriteHeader(http.StatusBadRequest) 159 | json.NewEncoder(w).Encode( 160 | types.Response{ 161 | Message: "Error parsing request payload", 162 | Status: http.StatusBadRequest, 163 | }, 164 | ) 165 | 166 | return 167 | } 168 | 169 | identity := new(UpdateIdentityRequest) 170 | if err := json.Unmarshal(body, identity); err != nil { 171 | w.WriteHeader(http.StatusBadRequest) 172 | json.NewEncoder(w).Encode( 173 | types.Response{ 174 | Message: "Error parsing JSON payload", 175 | Status: http.StatusBadRequest, 176 | }, 177 | ) 178 | 179 | return 180 | 181 | } 182 | 183 | ids, err := a.service.UpdateIdentity(r.Context(), credID, &identity.UpdateIdentityBody) 184 | 185 | if err != nil { 186 | rr := a.error(ids.Error) 187 | 188 | w.WriteHeader(rr.Status) 189 | json.NewEncoder(w).Encode(rr) 190 | 191 | return 192 | } 193 | 194 | w.WriteHeader(http.StatusOK) 195 | json.NewEncoder(w).Encode( 196 | types.Response{ 197 | Data: ids.Identities, 198 | Message: "Updated identity", 199 | Status: http.StatusOK, 200 | }, 201 | ) 202 | } 203 | 204 | func (a *API) handleRemove(w http.ResponseWriter, r *http.Request) { 205 | w.Header().Set("Content-Type", "application/json") 206 | credID := chi.URLParam(r, "id") 207 | 208 | identities, err := a.service.DeleteIdentity(r.Context(), credID) 209 | 210 | if err != nil { 211 | rr := a.error(identities.Error) 212 | 213 | w.WriteHeader(rr.Status) 214 | json.NewEncoder(w).Encode(rr) 215 | 216 | return 217 | } 218 | 219 | w.WriteHeader(http.StatusOK) 220 | json.NewEncoder(w).Encode( 221 | types.Response{ 222 | Data: identities.Identities, 223 | Message: "Identity Deleted", 224 | Status: http.StatusOK, 225 | }, 226 | ) 227 | } 228 | 229 | // TODO @shipperizer encapsulate kClient.GenericError into a service error to remove library dependency 230 | func (a *API) error(e *kClient.GenericError) types.Response { 231 | r := types.Response{ 232 | Status: http.StatusInternalServerError, 233 | } 234 | 235 | if e.Reason != nil { 236 | r.Message = *e.Reason 237 | } 238 | 239 | if e.Code != nil { 240 | r.Status = int(*e.Code) 241 | } 242 | 243 | return r 244 | 245 | } 246 | 247 | func NewAPI(service ServiceInterface, logger logging.LoggerInterface) *API { 248 | a := new(API) 249 | 250 | a.service = service 251 | 252 | a.logger = logger 253 | 254 | return a 255 | } 256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Identity Platform Admin UI 2 | 3 | [![codecov](https://codecov.io/gh/canonical/identity-platform-admin-ui/branch/main/graph/badge.svg?token=Aloh6MWghg)](https://codecov.io/gh/canonical/identity-platform-admin-ui) 4 | [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/canonical/identity-platform-admin-ui/badge)](https://securityscorecards.dev/viewer/?platform=github.com&org=canonical&repo=identity-platform-admin-ui) 5 | ![GitHub tag (latest SemVer pre-release)](https://img.shields.io/github/v/tag/canonical/identity-platform-admin-ui) 6 | [![CI](https://github.com/canonical/identity-platform-admin-ui/actions/workflows/ci.yaml/badge.svg)](https://github.com/canonical/identity-platform-admin-ui/actions/workflows/ci.yaml) 7 | [![Go Reference](https://pkg.go.dev/badge/github.com/canonical/identity-platform-admin-ui.svg)](https://pkg.go.dev/github.com/canonical/identity-platform-admin-ui) 8 | 9 | This is the Admin UI for the Canonical Identity Platform. 10 | 11 | 12 | 13 | ## Environment Variables 14 | 15 | - `OTEL_GRPC_ENDPOINT`: address of the open telemetry grpc endpoint, used for tracing 16 | - `OTEL_HTTP_ENDPOINT`: address of the open telemetry http endpoint, used for tracing (grpc endpoint takes precedence) 17 | - `TRACING_ENABLED`: flag enabling tracing 18 | - `LOG_LEVEL`: log level, one of `info`,`warn`,`error`,`debug`, defaults to `error` 19 | - `LOG_FILE`: file where to dump logs, defaults to `log.txt` 20 | - `PORT `: http server port, defaults to `8080` 21 | - `DEBUG`: debugging flag for hydra and kratos clients 22 | - `KRATOS_PUBLIC_URL`: Kratos public endpoints address 23 | - `KRATOS_ADMIN_URL`: Kratos admin endpoints address 24 | - `HYDRA_ADMIN_URL`: Hydra admin endpoints address 25 | - `IDP_CONFIGMAP_NAME`: name of the k8s config map containing Identity Providers 26 | - `IDP_CONFIGMAP_NAMESPACE`: namespace of the k8s config map containing Identity Providers 27 | - `SCHEMAS_CONFIGMAP_NAME`: name of the k8s config map containing Identity Schemas 28 | - `SCHEMAS_CONFIGMAP_NAMESPACE`: namespace of the k8s config map containing Identity Schemas 29 | 30 | 31 | ## Development setup 32 | 33 | As a requirement, please make sure to: 34 | * have `rockcraft`, `skopeo`, `yq`, [`skaffold`](https://github.com/GoogleContainerTools/skaffold), [`container-structure-test`](https://github.com/GoogleContainerTools/container-structure-test) and `docker` installed 35 | * microk8s `registry` addon is enabled and operating at `localhost:32000` 36 | * `GNU make` is available on the path (and installed) 37 | 38 | 39 | Simply run `make dev` to get a working environment in k8s 40 | 41 | 42 | Below an exaple of how to query some endpoints 43 | 44 | 45 | ```shell 46 | shipperizer in ~/shipperizer/identity-platform-admin-ui on IAM-515 ● λ http :8000/api/v0/identities 47 | HTTP/1.1 200 OK 48 | Content-Length: 86 49 | Content-Type: application/json 50 | Date: Wed, 11 Oct 2023 10:05:37 GMT 51 | Vary: Origin 52 | 53 | { 54 | "_meta": { 55 | "page": 1, 56 | "size": 100 57 | }, 58 | "data": [], 59 | "message": "List of identities", 60 | "status": 200 61 | } 62 | ``` 63 | 64 | 65 | ```shell 66 | shipperizer in ~/shipperizer/identity-platform-admin-ui on IAM-515 ● λ http :8000/api/v0/idps 67 | HTTP/1.1 200 OK 68 | Content-Length: 1520 69 | Content-Type: application/json 70 | Date: Wed, 11 Oct 2023 10:05:43 GMT 71 | Vary: Origin 72 | 73 | { 74 | "_meta": null, 75 | "data": [ 76 | { 77 | "apple_private_key": "", 78 | "apple_private_key_id": "", 79 | "apple_team_id": "", 80 | "auth_url": "", 81 | "client_id": "af675f35-3bd7-4515-88e2-b8032e315f6f", 82 | "client_secret": "3y38Q~aslkdhaskjhd~W0xWDB.123u98asd", 83 | "id": "microsoft_af675f353bd7451588e2b8032e315f6f", 84 | "issuer_url": "", 85 | "label": "", 86 | "mapper_url": "file:///etc/config/kratos/microsoft_schema.jsonnet", 87 | "microsoft_tenant": "e1574293-28de-4e94-87d5-b61c76fc14e1", 88 | "provider": "microsoft", 89 | "requested_claims": null, 90 | "scope": [ 91 | "profile", 92 | "email", 93 | "address", 94 | "phone" 95 | ], 96 | "subject_source": "", 97 | "token_url": "" 98 | }, 99 | { 100 | "apple_private_key": "", 101 | "apple_private_key_id": "", 102 | "apple_team_id": "", 103 | "auth_url": "", 104 | "client_id": "18fa2999-e6c9-475a-a495-15d933d8e8ce", 105 | "client_secret": "3y38Q~aslkdhaskjhd~W0xWDB.123u98asd", 106 | "id": "google_18fa2999e6c9475aa49515d933d8e8ce", 107 | "issuer_url": "", 108 | "label": "", 109 | "mapper_url": "file:///etc/config/kratos/google_schema.jsonnet", 110 | "microsoft_tenant": "", 111 | "provider": "google", 112 | "requested_claims": null, 113 | "scope": [ 114 | "profile", 115 | "email", 116 | "address", 117 | "phone" 118 | ], 119 | "subject_source": "", 120 | "token_url": "" 121 | }, 122 | { 123 | "apple_private_key": "", 124 | "apple_private_key_id": "", 125 | "apple_team_id": "", 126 | "auth_url": "", 127 | "client_id": "18fa2999-e6c9-475a-a495-89d941d8e1zy", 128 | "client_secret": "3y38Q~aslkdhaskjhd~W0xWDB.123u98asd", 129 | "id": "aws_18fa2999e6c9475aa49589d941d8e1zy", 130 | "issuer_url": "", 131 | "label": "", 132 | "mapper_url": "file:///etc/config/kratos/google_schema.jsonnet", 133 | "microsoft_tenant": "", 134 | "provider": "aws", 135 | "requested_claims": null, 136 | "scope": [ 137 | "profile", 138 | "email", 139 | "address", 140 | "phone" 141 | ], 142 | "subject_source": "", 143 | "token_url": "" 144 | } 145 | ], 146 | "message": "List of IDPs", 147 | "status": 200 148 | } 149 | ``` 150 | 151 | ```shell 152 | shipperizer in ~/shipperizer/identity-platform-admin-ui on IAM-515 ● λ http :8000/api/v0/clients 153 | HTTP/1.1 200 OK 154 | Content-Length: 316 155 | Content-Type: application/json 156 | Date: Wed, 11 Oct 2023 10:05:47 GMT 157 | Vary: Origin 158 | 159 | { 160 | "_links": { 161 | "first": "/api/v0/clients?page=eyJvZmZzZXQiOiIwIiwidiI6Mn0&size=200", 162 | "next": "/api/v0/clients?page=eyJvZmZzZXQiOiIyMDAiLCJ2IjoyfQ&size=200", 163 | "prev": "/api/v0/clients?page=eyJvZmZzZXQiOiItMjAwIiwidiI6Mn0&size=200" 164 | }, 165 | "_meta": { 166 | "total_count": "0" 167 | }, 168 | "data": [], 169 | "message": "List of clients", 170 | "status": 200 171 | } 172 | ``` 173 | -------------------------------------------------------------------------------- /pkg/clients/handlers.go: -------------------------------------------------------------------------------- 1 | package clients 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | 10 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 11 | "github.com/canonical/identity-platform-admin-ui/internal/responses" 12 | "github.com/go-chi/chi/v5" 13 | ) 14 | 15 | type API struct { 16 | service ServiceInterface 17 | 18 | logger logging.LoggerInterface 19 | } 20 | 21 | type PaginationLinksResponse struct { 22 | First string `json:"first,omitempty"` 23 | Last string `json:"last,omitempty"` 24 | Prev string `json:"prev,omitempty"` 25 | Next string `json:"next,omitempty"` 26 | } 27 | 28 | func (a *API) RegisterEndpoints(mux *chi.Mux) { 29 | mux.Get("/api/v0/clients", a.ListClients) 30 | mux.Post("/api/v0/clients", a.CreateClient) 31 | mux.Get("/api/v0/clients/{id}", a.GetClient) 32 | mux.Put("/api/v0/clients/{id}", a.UpdateClient) 33 | mux.Delete("/api/v0/clients/{id}", a.DeleteClient) 34 | } 35 | 36 | func (a *API) WriteJSONResponse(w http.ResponseWriter, data interface{}, msg string, status int, links interface{}, meta interface{}) { 37 | w.Header().Set("Content-Type", "application/json") 38 | w.WriteHeader(status) 39 | 40 | r := new(responses.Response) 41 | r.Data = data 42 | r.Message = msg 43 | r.Status = status 44 | r.Links = links 45 | r.Meta = meta 46 | 47 | err := json.NewEncoder(w).Encode(r) 48 | if err != nil { 49 | a.logger.Errorf("Unexpected error: %s", err) 50 | w.WriteHeader(http.StatusInternalServerError) 51 | return 52 | } 53 | } 54 | 55 | func (a *API) GetClient(w http.ResponseWriter, r *http.Request) { 56 | clientId := chi.URLParam(r, "id") 57 | 58 | res, e := a.service.GetClient(r.Context(), clientId) 59 | if e != nil { 60 | a.logger.Errorf("Unexpected error: %s", e) 61 | a.WriteJSONResponse(w, nil, "Unexpected internal error", http.StatusInternalServerError, nil, nil) 62 | return 63 | } 64 | if res.ServiceError != nil { 65 | a.WriteJSONResponse(w, res.ServiceError, "Failed to get client", res.ServiceError.StatusCode, nil, nil) 66 | return 67 | } 68 | 69 | a.WriteJSONResponse(w, res.Resp, "Client info", http.StatusOK, nil, nil) 70 | } 71 | 72 | func (a *API) DeleteClient(w http.ResponseWriter, r *http.Request) { 73 | clientId := chi.URLParam(r, "id") 74 | 75 | res, e := a.service.DeleteClient(r.Context(), clientId) 76 | if e != nil { 77 | a.logger.Errorf("Unexpected error: %s", e) 78 | a.WriteJSONResponse(w, nil, "Unexpected internal error", http.StatusInternalServerError, nil, nil) 79 | return 80 | } 81 | if res.ServiceError != nil { 82 | a.WriteJSONResponse(w, res.ServiceError, "Failed to delete client", res.ServiceError.StatusCode, nil, nil) 83 | return 84 | } 85 | 86 | a.WriteJSONResponse(w, "", "Client deleted", http.StatusOK, nil, nil) 87 | } 88 | 89 | func (a *API) CreateClient(w http.ResponseWriter, r *http.Request) { 90 | // TODO @nsklikas: Limit request params? 91 | json_data, err := io.ReadAll(r.Body) 92 | if err != nil { 93 | a.WriteJSONResponse(w, nil, "Failed to parse request body", http.StatusBadRequest, nil, nil) 94 | return 95 | } 96 | c, err := a.service.UnmarshalClient(json_data) 97 | if err != nil { 98 | a.logger.Debugf("Failed to unmarshal JSON: %s", err) 99 | a.WriteJSONResponse(w, nil, "Failed to parse request body", http.StatusBadRequest, nil, nil) 100 | return 101 | } 102 | 103 | res, e := a.service.CreateClient(r.Context(), c) 104 | if e != nil { 105 | a.logger.Errorf("Unexpected error: %s", e) 106 | a.WriteJSONResponse(w, nil, "Unexpected internal error", http.StatusInternalServerError, nil, nil) 107 | return 108 | } 109 | if res.ServiceError != nil { 110 | a.WriteJSONResponse(w, res.ServiceError, "Failed to create client", res.ServiceError.StatusCode, nil, nil) 111 | return 112 | } 113 | 114 | a.WriteJSONResponse(w, res.Resp, "Created client", http.StatusCreated, nil, nil) 115 | } 116 | 117 | func (a *API) UpdateClient(w http.ResponseWriter, r *http.Request) { 118 | clientId := chi.URLParam(r, "id") 119 | 120 | json_data, err := io.ReadAll(r.Body) 121 | if err != nil { 122 | a.logger.Debugf("Failed to read response body: %s", err) 123 | a.WriteJSONResponse(w, nil, "Failed to parse request body", http.StatusBadRequest, nil, nil) 124 | return 125 | } 126 | // TODO @nsklikas: Limit request params? 127 | c, err := a.service.UnmarshalClient(json_data) 128 | if err != nil { 129 | a.logger.Debugf("Failed to unmarshal JSON: %s", err) 130 | a.WriteJSONResponse(w, nil, "Failed to parse request body", http.StatusBadRequest, nil, nil) 131 | return 132 | } 133 | c.SetClientId(clientId) 134 | 135 | res, e := a.service.UpdateClient(r.Context(), c) 136 | if e != nil { 137 | a.logger.Errorf("Unexpected error: %s", e) 138 | a.WriteJSONResponse(w, nil, "Unexpected internal error", http.StatusInternalServerError, nil, nil) 139 | return 140 | } 141 | if res.ServiceError != nil { 142 | a.WriteJSONResponse(w, res.ServiceError, "Failed to update client", res.ServiceError.StatusCode, nil, nil) 143 | return 144 | } 145 | 146 | a.WriteJSONResponse(w, res.Resp, "Updated client", http.StatusOK, nil, nil) 147 | } 148 | 149 | func (a *API) ListClients(w http.ResponseWriter, r *http.Request) { 150 | req, err := a.parseListClientsRequest(r) 151 | if err != nil { 152 | a.WriteJSONResponse(w, nil, "Failed to parse request", http.StatusBadRequest, nil, nil) 153 | return 154 | } 155 | 156 | res, e := a.service.ListClients(r.Context(), req) 157 | if e != nil { 158 | a.logger.Errorf("Unexpected error: %s", e) 159 | a.WriteJSONResponse(w, nil, "Unexpected internal error", http.StatusInternalServerError, nil, nil) 160 | return 161 | } 162 | if res.ServiceError != nil { 163 | a.WriteJSONResponse(w, res.ServiceError, "Failed to fetch clients", res.ServiceError.StatusCode, nil, nil) 164 | return 165 | } 166 | 167 | var links PaginationLinksResponse 168 | if res.Links != nil { 169 | links = PaginationLinksResponse{ 170 | First: a.convertLinkToUrl(res.Links.First, r.RequestURI), 171 | Last: a.convertLinkToUrl(res.Links.Last, r.RequestURI), 172 | Next: a.convertLinkToUrl(res.Links.Next, r.RequestURI), 173 | Prev: a.convertLinkToUrl(res.Links.Prev, r.RequestURI), 174 | } 175 | } 176 | 177 | a.WriteJSONResponse(w, res.Resp, "List of clients", http.StatusOK, links, res.Meta) 178 | } 179 | 180 | func (a *API) parseListClientsRequest(r *http.Request) (*ListClientsRequest, error) { 181 | q := r.URL.Query() 182 | 183 | cn := q.Get("client_name") 184 | owner := q.Get("owner") 185 | page := q.Get("page") 186 | s := q.Get("size") 187 | 188 | var size int = 200 189 | if s != "" { 190 | var err error 191 | size, err = strconv.Atoi(s) 192 | if err != nil { 193 | return nil, err 194 | } 195 | } 196 | return NewListClientsRequest(cn, owner, page, size), nil 197 | } 198 | 199 | func (a *API) convertLinkToUrl(l PaginationMeta, u string) string { 200 | if l.Page == "" { 201 | return "" 202 | } 203 | uu, err := url.Parse(u) 204 | if err != nil { 205 | a.logger.Fatal("Failed to parse URL: ", u) 206 | } 207 | 208 | q := uu.Query() 209 | q.Set("page", l.Page) 210 | q.Set("size", strconv.Itoa(l.Size)) 211 | uu.RawQuery = q.Encode() 212 | return uu.String() 213 | } 214 | 215 | func NewAPI(service ServiceInterface, logger logging.LoggerInterface) *API { 216 | a := new(API) 217 | 218 | a.service = service 219 | 220 | a.logger = logger 221 | 222 | return a 223 | } 224 | -------------------------------------------------------------------------------- /pkg/schemas/service.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 11 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 12 | kClient "github.com/ory/kratos-client-go" 13 | "go.opentelemetry.io/otel/trace" 14 | metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | coreV1 "k8s.io/client-go/kubernetes/typed/core/v1" 16 | ) 17 | 18 | const DEFAULT_SCHEMA = "default.schema" 19 | 20 | type Config struct { 21 | Name string 22 | Namespace string 23 | K8s coreV1.CoreV1Interface 24 | Kratos kClient.IdentityApi 25 | } 26 | 27 | type IdentitySchemaData struct { 28 | IdentitySchemas []kClient.IdentitySchemaContainer 29 | Error *kClient.GenericError 30 | } 31 | 32 | // TODO @shipperizer verify during integration test if this is actually the format 33 | type KratosError struct { 34 | Error *kClient.GenericError `json:"error,omitempty"` 35 | } 36 | 37 | type Service struct { 38 | cmName string 39 | cmNamespace string 40 | 41 | k8s coreV1.CoreV1Interface 42 | kratos kClient.IdentityApi 43 | 44 | tracer trace.Tracer 45 | monitor monitoring.MonitorInterface 46 | logger logging.LoggerInterface 47 | } 48 | 49 | func (s *Service) parseError(ctx context.Context, r *http.Response) *kClient.GenericError { 50 | ctx, span := s.tracer.Start(ctx, "schemas.Service.parseError") 51 | defer span.End() 52 | 53 | gerr := KratosError{Error: kClient.NewGenericErrorWithDefaults()} 54 | 55 | defer r.Body.Close() 56 | body, _ := io.ReadAll(r.Body) 57 | 58 | if err := json.Unmarshal(body, &gerr); err != nil { 59 | gerr.Error.SetMessage("unable to parse kratos error response") 60 | gerr.Error.SetCode(http.StatusInternalServerError) 61 | } 62 | 63 | return gerr.Error 64 | } 65 | 66 | func (s *Service) ListSchemas(ctx context.Context, page, size int64) (*IdentitySchemaData, error) { 67 | ctx, span := s.tracer.Start(ctx, "schemas.Service.ListSchemas") 68 | defer span.End() 69 | 70 | schemas, rr, err := s.kratos.ListIdentitySchemasExecute( 71 | s.kratos.ListIdentitySchemas(ctx).Page(page).PerPage(size), 72 | ) 73 | 74 | data := new(IdentitySchemaData) 75 | 76 | if err != nil { 77 | s.logger.Error(err) 78 | data.Error = s.parseError(ctx, rr) 79 | } 80 | 81 | data.IdentitySchemas = schemas 82 | 83 | // TODO @shipperizer check if schemas is defaulting to empty slice inside kratos-client 84 | if data.IdentitySchemas == nil { 85 | data.IdentitySchemas = make([]kClient.IdentitySchemaContainer, 0) 86 | } 87 | 88 | return data, err 89 | } 90 | 91 | func (s *Service) GetSchema(ctx context.Context, ID string) (*IdentitySchemaData, error) { 92 | ctx, span := s.tracer.Start(ctx, "schemas.Service.GetSchema") 93 | defer span.End() 94 | 95 | schema, rr, err := s.kratos.GetIdentitySchemaExecute( 96 | s.kratos.GetIdentitySchema(ctx, ID), 97 | ) 98 | 99 | data := new(IdentitySchemaData) 100 | 101 | if err != nil { 102 | s.logger.Error(err) 103 | data.Error = s.parseError(ctx, rr) 104 | } 105 | 106 | if schema != nil { 107 | data.IdentitySchemas = []kClient.IdentitySchemaContainer{ 108 | {Schema: schema, Id: &ID}, 109 | } 110 | } else { 111 | data.IdentitySchemas = []kClient.IdentitySchemaContainer{} 112 | } 113 | 114 | return data, err 115 | } 116 | 117 | func (s *Service) EditSchema(ctx context.Context, ID string, data *kClient.IdentitySchemaContainer) (*IdentitySchemaData, error) { 118 | ctx, span := s.tracer.Start(ctx, "schemas.Service.EditSchema") 119 | defer span.End() 120 | 121 | cm, err := s.k8s.ConfigMaps(s.cmNamespace).Get(ctx, s.cmName, metaV1.GetOptions{}) 122 | 123 | if err != nil { 124 | s.logger.Error(err.Error()) 125 | return nil, err 126 | } 127 | 128 | i := new(IdentitySchemaData) 129 | 130 | // TODO @shipperizer DEFAULT_SCHEMA doesn't return here, but might be worth making it more explicit 131 | schemas := s.schemas(cm.Data) 132 | 133 | if _, ok := schemas[ID]; !ok { 134 | i.IdentitySchemas = []kClient.IdentitySchemaContainer{} 135 | 136 | return i, fmt.Errorf("schema with ID %s not found", ID) 137 | } 138 | 139 | rawSchema, err := json.Marshal(data.Schema) 140 | 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | cm.Data[ID] = string(rawSchema) 146 | 147 | if _, err = s.k8s.ConfigMaps(s.cmNamespace).Update(ctx, cm, metaV1.UpdateOptions{}); err != nil { 148 | 149 | return nil, err 150 | } 151 | 152 | i.IdentitySchemas = []kClient.IdentitySchemaContainer{*data} 153 | 154 | return i, nil 155 | 156 | } 157 | 158 | func (s *Service) CreateSchema(ctx context.Context, data *kClient.IdentitySchemaContainer) (*IdentitySchemaData, error) { 159 | ctx, span := s.tracer.Start(ctx, "schemas.Service.CreateSchema") 160 | defer span.End() 161 | 162 | cm, err := s.k8s.ConfigMaps(s.cmNamespace).Get(ctx, s.cmName, metaV1.GetOptions{}) 163 | 164 | if err != nil { 165 | s.logger.Error(err.Error()) 166 | return nil, err 167 | } 168 | i := new(IdentitySchemaData) 169 | 170 | schemas := s.schemas(cm.Data) 171 | 172 | if _, ok := schemas[*data.Id]; ok { 173 | i.IdentitySchemas = []kClient.IdentitySchemaContainer{} 174 | 175 | return i, fmt.Errorf("schema with same ID already exists") 176 | } 177 | 178 | rawSchema, err := json.Marshal(data.Schema) 179 | 180 | if err != nil { 181 | return nil, err 182 | } 183 | 184 | cm.Data[*data.Id] = string(rawSchema) 185 | 186 | if _, err = s.k8s.ConfigMaps(s.cmNamespace).Update(ctx, cm, metaV1.UpdateOptions{}); err != nil { 187 | 188 | return nil, err 189 | } 190 | 191 | i.IdentitySchemas = []kClient.IdentitySchemaContainer{*data} 192 | 193 | return i, nil 194 | } 195 | 196 | func (s *Service) DeleteSchema(ctx context.Context, ID string) error { 197 | ctx, span := s.tracer.Start(ctx, "schemas.Service.DeleteSchema") 198 | defer span.End() 199 | 200 | cm, err := s.k8s.ConfigMaps(s.cmNamespace).Get(ctx, s.cmName, metaV1.GetOptions{}) 201 | 202 | if err != nil { 203 | s.logger.Error(err.Error()) 204 | return err 205 | } 206 | 207 | if ID == DEFAULT_SCHEMA { 208 | return fmt.Errorf("default schema %s cannot be deleted as ", ID) 209 | } 210 | 211 | if _, ok := cm.Data[ID]; !ok { 212 | return fmt.Errorf("schema with ID %s not found", ID) 213 | } 214 | 215 | delete(cm.Data, ID) 216 | 217 | if _, err = s.k8s.ConfigMaps(s.cmNamespace).Update(ctx, cm, metaV1.UpdateOptions{}); err != nil { 218 | 219 | return err 220 | } 221 | 222 | return nil 223 | 224 | } 225 | 226 | func (s *Service) schemas(schemas map[string]string) map[string]*kClient.IdentitySchemaContainer { 227 | 228 | schemaConfig := make(map[string]*kClient.IdentitySchemaContainer) 229 | 230 | for key, rawSchema := range schemas { 231 | // skip if special key 232 | if key == DEFAULT_SCHEMA { 233 | continue 234 | } 235 | 236 | schema := make(map[string]interface{}) 237 | 238 | err := json.Unmarshal([]byte(rawSchema), &schema) 239 | 240 | if err != nil { 241 | s.logger.Errorf("failed unmarshalling %s - %v", rawSchema, err) 242 | return nil 243 | } 244 | 245 | schemaConfig[key] = &kClient.IdentitySchemaContainer{Id: &key, Schema: schema} 246 | } 247 | 248 | return schemaConfig 249 | } 250 | 251 | // TODO @shipperizer analyze if providers IDs need to be what we use for path or if filename is the right one 252 | func NewService(config *Config, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Service { 253 | s := new(Service) 254 | 255 | if config == nil { 256 | panic("empty config for schemas service") 257 | } 258 | 259 | s.kratos = config.Kratos 260 | s.k8s = config.K8s 261 | s.cmName = config.Name 262 | s.cmNamespace = config.Namespace 263 | 264 | s.monitor = monitor 265 | s.tracer = tracer 266 | s.logger = logger 267 | 268 | return s 269 | } 270 | -------------------------------------------------------------------------------- /pkg/clients/service.go: -------------------------------------------------------------------------------- 1 | package clients 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "regexp" 10 | "strconv" 11 | 12 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 13 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 14 | hClient "github.com/ory/hydra-client-go/v2" 15 | "go.opentelemetry.io/otel/trace" 16 | ) 17 | 18 | type PaginationLinksMeta struct { 19 | First PaginationMeta `json:"first,omitempty"` 20 | Last PaginationMeta `json:"last,omitempty"` 21 | Prev PaginationMeta `json:"prev,omitempty"` 22 | Next PaginationMeta `json:"next,omitempty"` 23 | } 24 | 25 | type PaginationMeta struct { 26 | Page string `json:"page,omitempty"` 27 | Size int `json:"size,omitempty"` 28 | } 29 | 30 | type ListClientsRequest struct { 31 | PaginationMeta 32 | Owner string `json:"owner,omitempty"` 33 | ClientName string `json:"client_name,omitempty"` 34 | } 35 | 36 | type ErrorOAuth2 struct { 37 | Error string `json:"error,omitempty"` 38 | ErrorDescription string `json:"error_description,omitempty"` 39 | StatusCode int `json:"-"` 40 | } 41 | 42 | type ServiceResponse struct { 43 | Links *PaginationLinksMeta 44 | ServiceError *ErrorOAuth2 45 | Resp interface{} 46 | Meta map[string]string 47 | } 48 | 49 | type Service struct { 50 | hydra HydraClientInterface 51 | 52 | linksRegex *regexp.Regexp 53 | 54 | tracer trace.Tracer 55 | monitor monitoring.MonitorInterface 56 | logger logging.LoggerInterface 57 | } 58 | 59 | func (s *Service) GetClient(ctx context.Context, clientID string) (*ServiceResponse, error) { 60 | ctx, span := s.tracer.Start(ctx, "hydra.OAuth2Api.GetOAuth2Client") 61 | defer span.End() 62 | 63 | ret := NewServiceResponse() 64 | 65 | c, resp, err := s.hydra.OAuth2Api(). 66 | GetOAuth2Client(ctx, clientID). 67 | Execute() 68 | 69 | if err != nil { 70 | se, err := s.parseServiceError(resp) 71 | if err != nil { 72 | return nil, err 73 | } 74 | ret.ServiceError = se 75 | } 76 | ret.Resp = c 77 | return ret, nil 78 | } 79 | 80 | func (s *Service) DeleteClient(ctx context.Context, clientID string) (*ServiceResponse, error) { 81 | ctx, span := s.tracer.Start(ctx, "hydra.OAuth2Api.DeleteOAuth2Client") 82 | defer span.End() 83 | 84 | ret := NewServiceResponse() 85 | 86 | resp, err := s.hydra.OAuth2Api(). 87 | DeleteOAuth2Client(ctx, clientID). 88 | Execute() 89 | 90 | if err != nil { 91 | se, err := s.parseServiceError(resp) 92 | if err != nil { 93 | return nil, err 94 | } 95 | ret.ServiceError = se 96 | } 97 | return ret, nil 98 | } 99 | 100 | func (s *Service) CreateClient(ctx context.Context, client *hClient.OAuth2Client) (*ServiceResponse, error) { 101 | ctx, span := s.tracer.Start(ctx, "hydra.OAuth2Api.CreateOAuth2Client") 102 | defer span.End() 103 | 104 | ret := NewServiceResponse() 105 | 106 | c, resp, err := s.hydra.OAuth2Api(). 107 | CreateOAuth2Client(ctx). 108 | OAuth2Client(*client). 109 | Execute() 110 | 111 | if err != nil { 112 | se, err := s.parseServiceError(resp) 113 | if err != nil { 114 | return nil, err 115 | } 116 | ret.ServiceError = se 117 | } 118 | ret.Resp = c 119 | return ret, nil 120 | } 121 | 122 | func (s *Service) UpdateClient(ctx context.Context, client *hClient.OAuth2Client) (*ServiceResponse, error) { 123 | ctx, span := s.tracer.Start(ctx, "hydra.OAuth2Api.SetOAuth2Client") 124 | defer span.End() 125 | 126 | ret := NewServiceResponse() 127 | 128 | c, resp, err := s.hydra.OAuth2Api(). 129 | SetOAuth2Client(ctx, *client.ClientId). 130 | OAuth2Client(*client). 131 | Execute() 132 | 133 | if err != nil { 134 | se, err := s.parseServiceError(resp) 135 | if err != nil { 136 | return nil, err 137 | } 138 | ret.ServiceError = se 139 | } 140 | ret.Resp = c 141 | return ret, nil 142 | } 143 | 144 | func (s *Service) ListClients(ctx context.Context, cs *ListClientsRequest) (*ServiceResponse, error) { 145 | ctx, span := s.tracer.Start(ctx, "hydra.OAuth2Api.ListOAuth2Clients") 146 | defer span.End() 147 | 148 | ret := NewServiceResponse() 149 | 150 | c, resp, err := s.hydra.OAuth2Api().ListOAuth2Clients(ctx). 151 | ClientName(cs.ClientName). 152 | Owner(cs.Owner). 153 | PageSize(int64(cs.Size)). 154 | PageToken(cs.Page). 155 | Execute() 156 | 157 | if err != nil { 158 | se, err := s.parseServiceError(resp) 159 | if err != nil { 160 | return nil, err 161 | } 162 | ret.ServiceError = se 163 | } 164 | ret.Resp = c 165 | 166 | l := resp.Header.Get("Link") 167 | if l != "" { 168 | ret.Links = s.parseLinks(l) 169 | } 170 | 171 | ret.Meta["total_count"] = resp.Header.Get("X-Total-Count") 172 | 173 | return ret, nil 174 | } 175 | func (s *Service) UnmarshalClient(data []byte) (*hClient.OAuth2Client, error) { 176 | c := hClient.NewOAuth2Client() 177 | err := json.Unmarshal(data, c) 178 | if err != nil { 179 | return nil, err 180 | } 181 | return c, nil 182 | } 183 | 184 | func (s *Service) parseLinks(ls string) *PaginationLinksMeta { 185 | p := new(PaginationLinksMeta) 186 | links := s.linksRegex.FindAllStringSubmatch(ls, -1) 187 | for _, link := range links { 188 | l := link[1] 189 | t := link[2] 190 | if l == "" { 191 | continue 192 | } 193 | 194 | parsedURL, err := url.Parse(l) 195 | if err != nil { 196 | s.logger.Errorf("Failed to parse: %s, not a URL: %s", l, err) 197 | continue 198 | } 199 | q := parsedURL.Query() 200 | size, _ := strconv.Atoi(q["page_size"][0]) 201 | if err != nil { 202 | s.logger.Errorf("Failed to parse: %s, not an int", err) 203 | continue 204 | } 205 | 206 | switch t { 207 | case "first": 208 | p.First = PaginationMeta{q["page_token"][0], size} 209 | case "last": 210 | p.Last = PaginationMeta{q["page_token"][0], size} 211 | case "prev": 212 | p.Prev = PaginationMeta{q["page_token"][0], size} 213 | case "next": 214 | p.Next = PaginationMeta{q["page_token"][0], size} 215 | default: 216 | // We should never end up here 217 | s.logger.Warn("Unexpected Links header format: ", ls) 218 | } 219 | } 220 | return p 221 | } 222 | 223 | func (s *Service) parseServiceError(r *http.Response) (*ErrorOAuth2, error) { 224 | // The hydra client does not return any errors, we need to parse the response body and create our 225 | // own objects. 226 | // Should we use our objects instead of reusing the ones from the sdk? 227 | se := new(ErrorOAuth2) 228 | 229 | if r == nil { 230 | s.logger.Debugf("Got no response from hydra service") 231 | se.Error = "internal_server_error" 232 | se.ErrorDescription = "Failed to call hydra service" 233 | se.StatusCode = http.StatusInternalServerError 234 | return se, nil 235 | } 236 | 237 | json_data, err := io.ReadAll(r.Body) 238 | if err != nil { 239 | s.logger.Debugf("Failed to read response body: %s", err) 240 | return se, err 241 | } 242 | err = json.Unmarshal(json_data, se) 243 | if err != nil { 244 | s.logger.Debugf("Failed to unmarshal JSON: %s", err) 245 | return se, err 246 | } 247 | se.StatusCode = r.StatusCode 248 | 249 | return se, nil 250 | } 251 | 252 | func NewService(hydra HydraClientInterface, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Service { 253 | s := new(Service) 254 | 255 | s.linksRegex = regexp.MustCompile(`<(?P[^>]*)>; rel="(?P\w*)"`) 256 | s.hydra = hydra 257 | 258 | s.monitor = monitor 259 | s.tracer = tracer 260 | s.logger = logger 261 | 262 | return s 263 | } 264 | 265 | func NewServiceResponse() *ServiceResponse { 266 | sr := new(ServiceResponse) 267 | sr.Meta = make(map[string]string) 268 | return sr 269 | } 270 | 271 | func NewListClientsRequest(cn, owner, page string, size int) *ListClientsRequest { 272 | return &ListClientsRequest{ 273 | ClientName: cn, 274 | Owner: owner, 275 | PaginationMeta: PaginationMeta{ 276 | Page: page, 277 | Size: size, 278 | }, 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /pkg/idp/service.go: -------------------------------------------------------------------------------- 1 | package idp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/canonical/identity-platform-admin-ui/internal/logging" 9 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 10 | "github.com/google/uuid" 11 | "go.opentelemetry.io/otel/trace" 12 | "gopkg.in/yaml.v3" 13 | metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | coreV1 "k8s.io/client-go/kubernetes/typed/core/v1" 15 | ) 16 | 17 | type Config struct { 18 | Name string 19 | Namespace string 20 | KeyName string 21 | K8s coreV1.CoreV1Interface 22 | } 23 | 24 | type Service struct { 25 | cmName string 26 | cmNamespace string 27 | keyName string 28 | 29 | k8s coreV1.CoreV1Interface 30 | 31 | tracer trace.Tracer 32 | monitor monitoring.MonitorInterface 33 | logger logging.LoggerInterface 34 | } 35 | 36 | func (s *Service) ListResources(ctx context.Context) ([]*Configuration, error) { 37 | ctx, span := s.tracer.Start(ctx, "idp.Service.ListResources") 38 | defer span.End() 39 | 40 | cm, err := s.k8s.ConfigMaps(s.cmNamespace).Get(ctx, s.cmName, metaV1.GetOptions{}) 41 | 42 | if err != nil { 43 | s.logger.Error(err.Error()) 44 | return nil, err 45 | } 46 | 47 | return s.idpConfiguration(cm.Data), nil 48 | 49 | } 50 | 51 | func (s *Service) GetResource(ctx context.Context, providerID string) ([]*Configuration, error) { 52 | ctx, span := s.tracer.Start(ctx, "idp.Service.GetResource") 53 | defer span.End() 54 | 55 | cm, err := s.k8s.ConfigMaps(s.cmNamespace).Get(ctx, s.cmName, metaV1.GetOptions{}) 56 | 57 | if err != nil { 58 | s.logger.Error(err.Error()) 59 | return nil, err 60 | } 61 | 62 | idps := s.idpConfiguration(cm.Data) 63 | 64 | if idps == nil { 65 | return nil, nil 66 | } 67 | 68 | // TODO @shipperizer find a better way to index the idps 69 | for _, idp := range idps { 70 | if idp.ID == providerID { 71 | return []*Configuration{idp}, nil 72 | } 73 | } 74 | return []*Configuration{}, nil 75 | } 76 | 77 | func (s *Service) EditResource(ctx context.Context, providerID string, data *Configuration) ([]*Configuration, error) { 78 | ctx, span := s.tracer.Start(ctx, "idp.Service.EditResource") 79 | defer span.End() 80 | 81 | cm, err := s.k8s.ConfigMaps(s.cmNamespace).Get(ctx, s.cmName, metaV1.GetOptions{}) 82 | 83 | if err != nil { 84 | s.logger.Error(err.Error()) 85 | return nil, err 86 | } 87 | 88 | idps := s.idpConfiguration(cm.Data) 89 | 90 | if idps == nil { 91 | return nil, nil 92 | } 93 | 94 | var idp *Configuration 95 | // TODO @shipperizer find a better way to index the idps 96 | for _, i := range idps { 97 | if i.ID == providerID { 98 | i = s.mergeConfiguration(i, data) 99 | idp = i 100 | } 101 | } 102 | 103 | if idp == nil { 104 | return []*Configuration{}, fmt.Errorf("provider with ID %s not found", providerID) 105 | } 106 | 107 | rawIdps, err := json.Marshal(idps) 108 | 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | cm.Data[s.keyName] = string(rawIdps) 114 | 115 | if _, err = s.k8s.ConfigMaps(s.cmNamespace).Update(ctx, cm, metaV1.UpdateOptions{}); err != nil { 116 | 117 | return nil, err 118 | } 119 | 120 | return []*Configuration{idp}, nil 121 | 122 | } 123 | 124 | func (s *Service) CreateResource(ctx context.Context, data *Configuration) ([]*Configuration, error) { 125 | ctx, span := s.tracer.Start(ctx, "idp.Service.CreateResource") 126 | defer span.End() 127 | 128 | cm, err := s.k8s.ConfigMaps(s.cmNamespace).Get(ctx, s.cmName, metaV1.GetOptions{}) 129 | 130 | if err != nil { 131 | s.logger.Error(err.Error()) 132 | return nil, err 133 | } 134 | 135 | idps := s.idpConfiguration(cm.Data) 136 | 137 | if idps == nil { 138 | return nil, nil 139 | } 140 | 141 | var idp *Configuration 142 | // TODO @shipperizer find a better way to index the idps 143 | for _, i := range idps { 144 | if i.ID == data.ID { 145 | return idps, fmt.Errorf("provider with same ID already exists") 146 | } 147 | } 148 | 149 | idps = append(idps, data) 150 | 151 | rawIdps, err := json.Marshal(idps) 152 | 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | cm.Data[s.keyName] = string(rawIdps) 158 | 159 | if _, err = s.k8s.ConfigMaps(s.cmNamespace).Update(ctx, cm, metaV1.UpdateOptions{}); err != nil { 160 | 161 | return nil, err 162 | } 163 | 164 | return []*Configuration{idp}, nil 165 | } 166 | 167 | func (s *Service) DeleteResource(ctx context.Context, providerID string) error { 168 | ctx, span := s.tracer.Start(ctx, "idp.Service.DeleteResource") 169 | defer span.End() 170 | 171 | cm, err := s.k8s.ConfigMaps(s.cmNamespace).Get(ctx, s.cmName, metaV1.GetOptions{}) 172 | 173 | if err != nil { 174 | s.logger.Error(err.Error()) 175 | return err 176 | } 177 | 178 | var found bool 179 | idps := s.idpConfiguration(cm.Data) 180 | 181 | if idps == nil { 182 | return nil 183 | } 184 | 185 | newIdps := make([]*Configuration, 0) 186 | 187 | // TODO @shipperizer find a better way to index the idps 188 | for _, i := range idps { 189 | 190 | if i.ID == providerID { 191 | found = true 192 | } else { 193 | newIdps = append(newIdps, i) 194 | } 195 | } 196 | 197 | if !found { 198 | return fmt.Errorf("provider with ID %s not found", providerID) 199 | } 200 | 201 | rawIdps, err := json.Marshal(newIdps) 202 | 203 | if err != nil { 204 | return err 205 | } 206 | 207 | cm.Data[s.keyName] = string(rawIdps) 208 | 209 | if _, err = s.k8s.ConfigMaps(s.cmNamespace).Update(ctx, cm, metaV1.UpdateOptions{}); err != nil { 210 | 211 | return err 212 | } 213 | 214 | return nil 215 | 216 | } 217 | 218 | // TODO @shipperizer ugly but safe, other way is to json/yaml Marshal/Unmarshal and use omitempty 219 | func (s *Service) mergeConfiguration(base, update *Configuration) *Configuration { 220 | if update.Provider != "" { 221 | base.Provider = update.Provider 222 | } 223 | 224 | if update.Label != "" { 225 | base.Provider = update.Provider 226 | } 227 | 228 | if update.ClientID != "" { 229 | base.Provider = update.Provider 230 | } 231 | 232 | if update.ClientSecret != "" { 233 | base.ClientSecret = update.ClientSecret 234 | } 235 | 236 | if update.IssuerURL != "" { 237 | base.IssuerURL = update.IssuerURL 238 | } 239 | 240 | if update.AuthURL != "" { 241 | base.AuthURL = update.AuthURL 242 | } 243 | 244 | if update.TokenURL != "" { 245 | base.TokenURL = update.TokenURL 246 | } 247 | 248 | if update.Tenant != "" { 249 | base.Tenant = update.Tenant 250 | } 251 | 252 | if update.SubjectSource != "" { 253 | base.SubjectSource = update.SubjectSource 254 | } 255 | 256 | if update.TeamId != "" { 257 | base.TeamId = update.TeamId 258 | } 259 | 260 | if update.PrivateKeyId != "" { 261 | base.PrivateKeyId = update.PrivateKeyId 262 | } 263 | 264 | if update.PrivateKey != "" { 265 | base.PrivateKey = update.PrivateKey 266 | } 267 | 268 | if update.Scope != nil && len(update.Scope) > 0 { 269 | base.Scope = update.Scope 270 | } 271 | 272 | if update.Mapper != "" { 273 | base.Mapper = update.Mapper 274 | } 275 | 276 | if update.RequestedClaims != nil { 277 | base.RequestedClaims = update.RequestedClaims 278 | } 279 | 280 | return base 281 | } 282 | 283 | func (s *Service) idpConfiguration(idps map[string]string) []*Configuration { 284 | 285 | idpConfig := make([]*Configuration, 0) 286 | 287 | rawIdps, ok := idps[s.keyName] 288 | 289 | if !ok { 290 | s.logger.Errorf("failed to find key %s in configMap %v", s.keyName, idps) 291 | return nil 292 | } 293 | 294 | err := yaml.Unmarshal([]byte(rawIdps), &idpConfig) 295 | 296 | if err != nil { 297 | s.logger.Errorf("failed unmarshalling %s - %v", rawIdps, err) 298 | return nil 299 | } 300 | 301 | return idpConfig 302 | } 303 | 304 | func (s *Service) keyIDMapper(id, namespace string) string { 305 | return uuid.NewSHA1(uuid.Nil, []byte(fmt.Sprintf("%s.%s", id, namespace))).String() 306 | } 307 | 308 | // TODO @shipperizer analyze if providers IDs need to be what we use for path or if filename is the right one 309 | func NewService(config *Config, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Service { 310 | s := new(Service) 311 | 312 | if config == nil { 313 | panic("empty config for IDP service") 314 | } 315 | 316 | s.k8s = config.K8s 317 | s.cmName = config.Name 318 | s.cmNamespace = config.Namespace 319 | // TODO @shipperizer fetch it from the config.KeyName 320 | s.keyName = "idps.yaml" 321 | 322 | s.monitor = monitor 323 | s.tracer = tracer 324 | s.logger = logger 325 | 326 | return s 327 | } 328 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /pkg/clients/handlers_test.go: -------------------------------------------------------------------------------- 1 | package clients 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | reflect "reflect" 10 | "testing" 11 | 12 | "github.com/canonical/identity-platform-admin-ui/internal/responses" 13 | "github.com/go-chi/chi/v5" 14 | "github.com/golang/mock/gomock" 15 | hClient "github.com/ory/hydra-client-go/v2" 16 | ) 17 | 18 | //go:generate mockgen -build_flags=--mod=mod -package clients -destination ./mock_logger.go -source=../../internal/logging/interfaces.go 19 | //go:generate mockgen -build_flags=--mod=mod -package clients -destination ./mock_clients.go -source=./interfaces.go 20 | 21 | func TestHandleGetClientSuccess(t *testing.T) { 22 | ctrl := gomock.NewController(t) 23 | defer ctrl.Finish() 24 | 25 | mockLogger := NewMockLoggerInterface(ctrl) 26 | mockService := NewMockServiceInterface(ctrl) 27 | 28 | const clientId = "client_id" 29 | 30 | c := hClient.NewOAuth2Client() 31 | c.SetClientId(clientId) 32 | resp := NewServiceResponse() 33 | resp.Resp = c 34 | 35 | req := httptest.NewRequest(http.MethodGet, "/api/v0/clients/"+clientId, nil) 36 | w := httptest.NewRecorder() 37 | 38 | mockService.EXPECT().GetClient(gomock.Any(), clientId).Return(resp, nil) 39 | 40 | mux := chi.NewMux() 41 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 42 | 43 | mux.ServeHTTP(w, req) 44 | 45 | res := w.Result() 46 | if res.StatusCode != http.StatusOK { 47 | t.Fatalf("expected HTTP status code 200 got %v", res.StatusCode) 48 | } 49 | 50 | data, err := ioutil.ReadAll(res.Body) 51 | defer res.Body.Close() 52 | 53 | if err != nil { 54 | t.Fatalf("expected error to be nil got %v", err) 55 | } 56 | 57 | r := new(responses.Response) 58 | expectedData, _ := c.MarshalJSON() 59 | if err := json.Unmarshal(data, r); err != nil { 60 | t.Fatalf("expected error to be nil got %v", err) 61 | } 62 | rr, _ := json.Marshal(r.Data) 63 | 64 | if r.Status != http.StatusOK { 65 | t.Fatal("expected status to be 200, got: ", r.Status) 66 | } 67 | if !reflect.DeepEqual(rr, expectedData) { 68 | t.Fatalf("expected data to be %+v, got: %+v", expectedData, rr) 69 | } 70 | } 71 | 72 | func TestHandleGetClientServiceError(t *testing.T) { 73 | ctrl := gomock.NewController(t) 74 | defer ctrl.Finish() 75 | 76 | mockLogger := NewMockLoggerInterface(ctrl) 77 | mockService := NewMockServiceInterface(ctrl) 78 | 79 | const clientId = "client_id" 80 | 81 | errResp := new(ErrorOAuth2) 82 | errResp.Error = "Unable to locate the resource" 83 | resp := NewServiceResponse() 84 | resp.ServiceError = errResp 85 | resp.ServiceError.StatusCode = http.StatusNotFound 86 | 87 | req := httptest.NewRequest(http.MethodGet, "/api/v0/clients/"+clientId, nil) 88 | w := httptest.NewRecorder() 89 | 90 | mockService.EXPECT().GetClient(gomock.Any(), clientId).Return(resp, nil) 91 | 92 | mux := chi.NewMux() 93 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 94 | 95 | mux.ServeHTTP(w, req) 96 | 97 | res := w.Result() 98 | if res.StatusCode != http.StatusNotFound { 99 | t.Fatalf("expected HTTP status code 404 got %v", res.StatusCode) 100 | } 101 | 102 | data, err := ioutil.ReadAll(res.Body) 103 | defer res.Body.Close() 104 | 105 | if err != nil { 106 | t.Fatalf("expected error to be nil got %v", err) 107 | } 108 | 109 | r := new(responses.Response) 110 | expectedData, _ := json.Marshal(errResp) 111 | if err := json.Unmarshal(data, r); err != nil { 112 | t.Fatalf("expected error to be nil got %v", err) 113 | } 114 | rr, _ := json.Marshal(r.Data) 115 | 116 | if r.Status != http.StatusNotFound { 117 | t.Fatal("expected status to be 404, got: ", r.Status) 118 | } 119 | if !reflect.DeepEqual(rr, expectedData) { 120 | t.Fatalf("expected data to be %+v, got: %+v", expectedData, rr) 121 | } 122 | } 123 | 124 | func TestHandleDeleteClientSuccess(t *testing.T) { 125 | ctrl := gomock.NewController(t) 126 | defer ctrl.Finish() 127 | 128 | mockLogger := NewMockLoggerInterface(ctrl) 129 | mockService := NewMockServiceInterface(ctrl) 130 | 131 | const clientId = "client_id" 132 | 133 | resp := NewServiceResponse() 134 | 135 | req := httptest.NewRequest(http.MethodDelete, "/api/v0/clients/"+clientId, nil) 136 | w := httptest.NewRecorder() 137 | 138 | mockService.EXPECT().DeleteClient(gomock.Any(), clientId).Return(resp, nil) 139 | 140 | mux := chi.NewMux() 141 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 142 | 143 | mux.ServeHTTP(w, req) 144 | 145 | res := w.Result() 146 | if res.StatusCode != http.StatusOK { 147 | t.Fatalf("expected HTTP status code 200 got %v", res.StatusCode) 148 | } 149 | 150 | data, err := ioutil.ReadAll(res.Body) 151 | defer res.Body.Close() 152 | 153 | if err != nil { 154 | t.Fatalf("expected error to be nil got %v", err) 155 | } 156 | 157 | r := new(responses.Response) 158 | if err := json.Unmarshal(data, r); err != nil { 159 | t.Fatalf("expected error to be nil got %v", err) 160 | } 161 | if r.Status != http.StatusOK { 162 | t.Fatal("expected status to be 200, got: ", r.Status) 163 | } 164 | } 165 | 166 | func TestHandleDeleteClientServiceError(t *testing.T) { 167 | ctrl := gomock.NewController(t) 168 | defer ctrl.Finish() 169 | 170 | mockLogger := NewMockLoggerInterface(ctrl) 171 | mockService := NewMockServiceInterface(ctrl) 172 | 173 | const clientId = "client_id" 174 | 175 | errResp := new(ErrorOAuth2) 176 | errResp.Error = "Unable to locate the resource" 177 | resp := NewServiceResponse() 178 | resp.ServiceError = errResp 179 | resp.ServiceError.StatusCode = http.StatusNotFound 180 | 181 | req := httptest.NewRequest(http.MethodDelete, "/api/v0/clients/"+clientId, nil) 182 | w := httptest.NewRecorder() 183 | 184 | mockService.EXPECT().DeleteClient(gomock.Any(), clientId).Return(resp, nil) 185 | 186 | mux := chi.NewMux() 187 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 188 | 189 | mux.ServeHTTP(w, req) 190 | 191 | res := w.Result() 192 | if res.StatusCode != http.StatusNotFound { 193 | t.Fatalf("expected HTTP status code 404 got %v", res.StatusCode) 194 | } 195 | 196 | data, err := ioutil.ReadAll(res.Body) 197 | defer res.Body.Close() 198 | 199 | if err != nil { 200 | t.Fatalf("expected error to be nil got %v", err) 201 | } 202 | 203 | r := new(responses.Response) 204 | expectedData, _ := json.Marshal(errResp) 205 | if err := json.Unmarshal(data, r); err != nil { 206 | t.Fatalf("expected error to be nil got %v", err) 207 | } 208 | rr, _ := json.Marshal(r.Data) 209 | 210 | if r.Status != http.StatusNotFound { 211 | t.Fatal("expected status to be 404, got: ", r.Status) 212 | } 213 | if !reflect.DeepEqual(rr, expectedData) { 214 | t.Fatalf("expected data to be %+v, got: %+v", expectedData, rr) 215 | } 216 | } 217 | 218 | func TestHandleCreateClientSuccess(t *testing.T) { 219 | ctrl := gomock.NewController(t) 220 | defer ctrl.Finish() 221 | 222 | mockLogger := NewMockLoggerInterface(ctrl) 223 | mockService := NewMockServiceInterface(ctrl) 224 | 225 | c := hClient.NewOAuth2Client() 226 | resp := NewServiceResponse() 227 | resp.Resp = c 228 | 229 | req := httptest.NewRequest(http.MethodPost, "/api/v0/clients", nil) 230 | w := httptest.NewRecorder() 231 | 232 | mockService.EXPECT().UnmarshalClient(gomock.Any()).Return(c, nil) 233 | mockService.EXPECT().CreateClient(gomock.Any(), c).Return(resp, nil) 234 | 235 | mux := chi.NewMux() 236 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 237 | 238 | mux.ServeHTTP(w, req) 239 | 240 | res := w.Result() 241 | if res.StatusCode != http.StatusCreated { 242 | t.Fatalf("expected HTTP status code 201 got %v", res.StatusCode) 243 | } 244 | 245 | data, err := ioutil.ReadAll(res.Body) 246 | defer res.Body.Close() 247 | 248 | if err != nil { 249 | t.Fatalf("expected error to be nil got %v", err) 250 | } 251 | 252 | r := new(responses.Response) 253 | if err := json.Unmarshal(data, r); err != nil { 254 | t.Fatalf("expected error to be nil got %v", err) 255 | } 256 | if r.Status != http.StatusCreated { 257 | t.Fatal("expected status to be 201, got: ", r.Status) 258 | } 259 | } 260 | 261 | func TestHandleCreateClientServiceError(t *testing.T) { 262 | ctrl := gomock.NewController(t) 263 | defer ctrl.Finish() 264 | 265 | mockLogger := NewMockLoggerInterface(ctrl) 266 | mockService := NewMockServiceInterface(ctrl) 267 | 268 | c := hClient.NewOAuth2Client() 269 | errResp := new(ErrorOAuth2) 270 | errResp.Error = "Some error happened" 271 | resp := NewServiceResponse() 272 | resp.ServiceError = errResp 273 | resp.ServiceError.StatusCode = http.StatusBadRequest 274 | 275 | req := httptest.NewRequest(http.MethodPost, "/api/v0/clients", nil) 276 | w := httptest.NewRecorder() 277 | 278 | mockService.EXPECT().UnmarshalClient(gomock.Any()).Return(c, nil) 279 | mockService.EXPECT().CreateClient(gomock.Any(), c).Return(resp, nil) 280 | 281 | mux := chi.NewMux() 282 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 283 | 284 | mux.ServeHTTP(w, req) 285 | 286 | res := w.Result() 287 | if res.StatusCode != http.StatusBadRequest { 288 | t.Fatalf("expected HTTP status code 400 got %v", res.StatusCode) 289 | } 290 | 291 | data, err := ioutil.ReadAll(res.Body) 292 | defer res.Body.Close() 293 | 294 | if err != nil { 295 | t.Fatalf("expected error to be nil got %v", err) 296 | } 297 | 298 | r := new(responses.Response) 299 | expectedData, _ := json.Marshal(errResp) 300 | if err := json.Unmarshal(data, r); err != nil { 301 | t.Fatalf("expected error to be nil got %v", err) 302 | } 303 | rr, _ := json.Marshal(r.Data) 304 | 305 | if r.Status != http.StatusBadRequest { 306 | t.Fatal("expected status to be 400, got: ", r.Status) 307 | } 308 | if !reflect.DeepEqual(rr, expectedData) { 309 | t.Fatalf("expected data to be %+v, got: %+v", expectedData, rr) 310 | } 311 | } 312 | 313 | func TestHandleCreateClientBadRequest(t *testing.T) { 314 | ctrl := gomock.NewController(t) 315 | defer ctrl.Finish() 316 | 317 | mockLogger := NewMockLoggerInterface(ctrl) 318 | mockService := NewMockServiceInterface(ctrl) 319 | 320 | req := httptest.NewRequest(http.MethodPost, "/api/v0/clients", nil) 321 | w := httptest.NewRecorder() 322 | 323 | mockLogger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Times(1) 324 | mockService.EXPECT().UnmarshalClient(gomock.Any()).Return(nil, fmt.Errorf("error")) 325 | 326 | mux := chi.NewMux() 327 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 328 | 329 | mux.ServeHTTP(w, req) 330 | 331 | res := w.Result() 332 | if res.StatusCode != http.StatusBadRequest { 333 | t.Fatalf("expected HTTP status code 400 got %v", res.StatusCode) 334 | } 335 | } 336 | 337 | func TestHandleUpdateClientSuccess(t *testing.T) { 338 | ctrl := gomock.NewController(t) 339 | defer ctrl.Finish() 340 | 341 | mockLogger := NewMockLoggerInterface(ctrl) 342 | mockService := NewMockServiceInterface(ctrl) 343 | 344 | const clientId = "client_id" 345 | 346 | c := hClient.NewOAuth2Client() 347 | c.SetClientId(clientId) 348 | resp := NewServiceResponse() 349 | resp.Resp = c 350 | 351 | req := httptest.NewRequest(http.MethodPut, "/api/v0/clients/"+clientId, nil) 352 | w := httptest.NewRecorder() 353 | 354 | mockService.EXPECT().UnmarshalClient(gomock.Any()).Return(c, nil) 355 | mockService.EXPECT().UpdateClient(gomock.Any(), c).Return(resp, nil) 356 | 357 | mux := chi.NewMux() 358 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 359 | 360 | mux.ServeHTTP(w, req) 361 | 362 | res := w.Result() 363 | if res.StatusCode != http.StatusOK { 364 | t.Fatalf("expected HTTP status code 200 got %v", res.StatusCode) 365 | } 366 | 367 | data, err := ioutil.ReadAll(res.Body) 368 | defer res.Body.Close() 369 | 370 | if err != nil { 371 | t.Fatalf("expected error to be nil got %v", err) 372 | } 373 | 374 | r := new(responses.Response) 375 | if err := json.Unmarshal(data, r); err != nil { 376 | t.Fatalf("expected error to be nil got %v", err) 377 | } 378 | if r.Status != http.StatusOK { 379 | t.Fatal("expected status to be 200, got: ", r.Status) 380 | } 381 | } 382 | 383 | func TestHandleUpdateClientServiceError(t *testing.T) { 384 | ctrl := gomock.NewController(t) 385 | defer ctrl.Finish() 386 | 387 | mockLogger := NewMockLoggerInterface(ctrl) 388 | mockService := NewMockServiceInterface(ctrl) 389 | 390 | const clientId = "client_id" 391 | 392 | c := hClient.NewOAuth2Client() 393 | errResp := new(ErrorOAuth2) 394 | errResp.Error = "Some error happened" 395 | resp := NewServiceResponse() 396 | resp.ServiceError = errResp 397 | resp.ServiceError.StatusCode = http.StatusBadRequest 398 | 399 | req := httptest.NewRequest(http.MethodPut, "/api/v0/clients/"+clientId, nil) 400 | w := httptest.NewRecorder() 401 | 402 | mockService.EXPECT().UnmarshalClient(gomock.Any()).Return(c, nil) 403 | mockService.EXPECT().UpdateClient(gomock.Any(), c).Return(resp, nil) 404 | 405 | mux := chi.NewMux() 406 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 407 | 408 | mux.ServeHTTP(w, req) 409 | 410 | res := w.Result() 411 | if res.StatusCode != http.StatusBadRequest { 412 | t.Fatalf("expected HTTP status code 400 got %v", res.StatusCode) 413 | } 414 | 415 | data, err := ioutil.ReadAll(res.Body) 416 | defer res.Body.Close() 417 | 418 | if err != nil { 419 | t.Fatalf("expected error to be nil got %v", err) 420 | } 421 | 422 | r := new(responses.Response) 423 | expectedData, _ := json.Marshal(errResp) 424 | if err := json.Unmarshal(data, r); err != nil { 425 | t.Fatalf("expected error to be nil got %v", err) 426 | } 427 | rr, _ := json.Marshal(r.Data) 428 | 429 | if r.Status != http.StatusBadRequest { 430 | t.Fatal("expected status to be 404, got: ", r.Status) 431 | } 432 | if !reflect.DeepEqual(rr, expectedData) { 433 | t.Fatalf("expected data to be %+v, got: %+v", expectedData, rr) 434 | } 435 | } 436 | 437 | func TestHandleUpdateClientBadRequest(t *testing.T) { 438 | ctrl := gomock.NewController(t) 439 | defer ctrl.Finish() 440 | 441 | mockLogger := NewMockLoggerInterface(ctrl) 442 | mockService := NewMockServiceInterface(ctrl) 443 | 444 | const clientId = "client_id" 445 | req := httptest.NewRequest(http.MethodPut, "/api/v0/clients/"+clientId, nil) 446 | w := httptest.NewRecorder() 447 | 448 | mockLogger.EXPECT().Debugf(gomock.Any(), gomock.Any()).Times(1) 449 | mockService.EXPECT().UnmarshalClient(gomock.Any()).Return(nil, fmt.Errorf("error")) 450 | 451 | mux := chi.NewMux() 452 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 453 | 454 | mux.ServeHTTP(w, req) 455 | 456 | res := w.Result() 457 | if res.StatusCode != http.StatusBadRequest { 458 | t.Fatalf("expected HTTP status code 400 got %v", res.StatusCode) 459 | } 460 | } 461 | 462 | func TestHandleListClientsSuccess(t *testing.T) { 463 | ctrl := gomock.NewController(t) 464 | defer ctrl.Finish() 465 | 466 | mockLogger := NewMockLoggerInterface(ctrl) 467 | mockService := NewMockServiceInterface(ctrl) 468 | 469 | const clientId = "client_id" 470 | 471 | c := hClient.NewOAuth2Client() 472 | c.SetClientId(clientId) 473 | resp := NewServiceResponse() 474 | resp.Resp = []*OAuth2Client{c} 475 | 476 | page := "10" 477 | size := "10" 478 | listReq := NewListClientsRequest("", "", page, 10) 479 | 480 | req := httptest.NewRequest(http.MethodGet, "/api/v0/clients", nil) 481 | q := req.URL.Query() 482 | q.Set("page", page) 483 | q.Set("size", size) 484 | req.URL.RawQuery = q.Encode() 485 | w := httptest.NewRecorder() 486 | 487 | mockService.EXPECT().ListClients(gomock.Any(), listReq).Return(resp, nil) 488 | 489 | mux := chi.NewMux() 490 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 491 | 492 | mux.ServeHTTP(w, req) 493 | 494 | res := w.Result() 495 | if res.StatusCode != http.StatusOK { 496 | t.Fatalf("expected HTTP status code 200 got %v", res.StatusCode) 497 | } 498 | 499 | data, err := ioutil.ReadAll(res.Body) 500 | defer res.Body.Close() 501 | 502 | if err != nil { 503 | t.Fatalf("expected error to be nil got %v", err) 504 | } 505 | 506 | r := new(responses.Response) 507 | if err := json.Unmarshal(data, r); err != nil { 508 | t.Fatalf("expected error to be nil got %v", err) 509 | } 510 | if r.Status != http.StatusOK { 511 | t.Fatal("expected status to be 200, got: ", r.Status) 512 | } 513 | } 514 | 515 | func TestHandleListClientServiceError(t *testing.T) { 516 | ctrl := gomock.NewController(t) 517 | defer ctrl.Finish() 518 | 519 | mockLogger := NewMockLoggerInterface(ctrl) 520 | mockService := NewMockServiceInterface(ctrl) 521 | 522 | errResp := new(ErrorOAuth2) 523 | errResp.Error = "Some error happened" 524 | resp := NewServiceResponse() 525 | resp.ServiceError = errResp 526 | resp.ServiceError.StatusCode = http.StatusBadRequest 527 | 528 | page := "10" 529 | size := "10" 530 | listReq := NewListClientsRequest("", "", page, 10) 531 | 532 | req := httptest.NewRequest(http.MethodGet, "/api/v0/clients", nil) 533 | q := req.URL.Query() 534 | q.Set("page", page) 535 | q.Set("size", size) 536 | req.URL.RawQuery = q.Encode() 537 | w := httptest.NewRecorder() 538 | 539 | mockService.EXPECT().ListClients(gomock.Any(), listReq).Return(resp, nil) 540 | 541 | mux := chi.NewMux() 542 | NewAPI(mockService, mockLogger).RegisterEndpoints(mux) 543 | 544 | mux.ServeHTTP(w, req) 545 | 546 | res := w.Result() 547 | if res.StatusCode != http.StatusBadRequest { 548 | t.Fatalf("expected HTTP status code 400 got %v", res.StatusCode) 549 | } 550 | 551 | data, err := ioutil.ReadAll(res.Body) 552 | defer res.Body.Close() 553 | 554 | if err != nil { 555 | t.Fatalf("expected error to be nil got %v", err) 556 | } 557 | 558 | r := new(responses.Response) 559 | expectedData, _ := json.Marshal(errResp) 560 | if err := json.Unmarshal(data, r); err != nil { 561 | t.Fatalf("expected error to be nil got %v", err) 562 | } 563 | rr, _ := json.Marshal(r.Data) 564 | 565 | if r.Status != http.StatusBadRequest { 566 | t.Fatal("expected status to be 400, got: ", r.Status) 567 | } 568 | if !reflect.DeepEqual(rr, expectedData) { 569 | t.Fatalf("expected data to be %+v, got: %+v", expectedData, rr) 570 | } 571 | } 572 | -------------------------------------------------------------------------------- /pkg/clients/service_test.go: -------------------------------------------------------------------------------- 1 | package clients 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | reflect "reflect" 10 | "testing" 11 | 12 | "github.com/canonical/identity-platform-admin-ui/internal/monitoring" 13 | "github.com/golang/mock/gomock" 14 | hClient "github.com/ory/hydra-client-go/v2" 15 | "go.opentelemetry.io/otel/trace" 16 | ) 17 | 18 | //go:generate mockgen -build_flags=--mod=mod -package clients -destination ./mock_logger.go -source=../../internal/logging/interfaces.go 19 | //go:generate mockgen -build_flags=--mod=mod -package clients -destination ./mock_clients.go -source=./interfaces.go 20 | //go:generate mockgen -build_flags=--mod=mod -package clients -destination ./mock_monitor.go -source=../../internal/monitoring/interfaces.go 21 | //go:generate mockgen -build_flags=--mod=mod -package clients -destination ./mock_tracing.go go.opentelemetry.io/otel/trace Tracer 22 | //go:generate mockgen -build_flags=--mod=mod -package clients -destination ./mock_hydra.go github.com/ory/hydra-client-go/v2 OAuth2Api 23 | 24 | func TestGetClientSuccess(t *testing.T) { 25 | ctrl := gomock.NewController(t) 26 | defer ctrl.Finish() 27 | 28 | mockLogger := NewMockLoggerInterface(ctrl) 29 | mockHydra := NewMockHydraClientInterface(ctrl) 30 | mockTracer := NewMockTracer(ctrl) 31 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 32 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 33 | 34 | const clientId = "client_id" 35 | 36 | c := hClient.NewOAuth2Client() 37 | c.SetClientId(clientId) 38 | clientReq := hClient.OAuth2ApiGetOAuth2ClientRequest{ 39 | ApiService: mockHydraOAuth2Api, 40 | } 41 | 42 | ctx := context.Background() 43 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 44 | mockHydraOAuth2Api.EXPECT().GetOAuth2Client(gomock.Any(), clientId).Times(1).Return(clientReq) 45 | mockHydraOAuth2Api.EXPECT().GetOAuth2ClientExecute(gomock.Any()).Times(1).Return(c, new(http.Response), nil) 46 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.GetOAuth2Client").Times(1).Return(nil, trace.SpanFromContext(ctx)) 47 | 48 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).GetClient(ctx, clientId) 49 | 50 | if resp.ServiceError != nil { 51 | t.Fatal("expected serviceError to be nil, got: ", resp.ServiceError) 52 | } 53 | if !reflect.DeepEqual(resp.Resp, c) { 54 | t.Fatalf("expected data to be %+v, got: %+v", c, resp.Resp) 55 | } 56 | if err != nil { 57 | t.Fatalf("expected error to be nil not %v", err) 58 | } 59 | } 60 | 61 | func TestGetClientFails(t *testing.T) { 62 | ctrl := gomock.NewController(t) 63 | defer ctrl.Finish() 64 | 65 | mockLogger := NewMockLoggerInterface(ctrl) 66 | mockHydra := NewMockHydraClientInterface(ctrl) 67 | mockTracer := NewMockTracer(ctrl) 68 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 69 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 70 | 71 | const clientId = "client_id" 72 | 73 | errResp := hClient.NewErrorOAuth2() 74 | errResp.SetError("error") 75 | errResp.SetErrorDescription("Some error happened") 76 | clientReq := hClient.OAuth2ApiGetOAuth2ClientRequest{ 77 | ApiService: mockHydraOAuth2Api, 78 | } 79 | errJson, _ := errResp.MarshalJSON() 80 | serviceResp := &http.Response{ 81 | Body: io.NopCloser(bytes.NewBuffer(errJson)), 82 | } 83 | 84 | ctx := context.Background() 85 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 86 | mockHydraOAuth2Api.EXPECT().GetOAuth2Client(gomock.Any(), clientId).Times(1).Return(clientReq) 87 | mockHydraOAuth2Api.EXPECT().GetOAuth2ClientExecute(gomock.Any()).Times(1).Return(nil, serviceResp, fmt.Errorf("error")) 88 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.GetOAuth2Client").Times(1).Return(nil, trace.SpanFromContext(ctx)) 89 | 90 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).GetClient(ctx, clientId) 91 | expectedError := new(ErrorOAuth2) 92 | expectedError.Error = *errResp.Error 93 | expectedError.ErrorDescription = *errResp.ErrorDescription 94 | 95 | if !reflect.DeepEqual(resp.ServiceError, expectedError) { 96 | t.Fatalf("expected data to be %+v, got: %+v", errResp, resp.ServiceError) 97 | } 98 | if err != nil { 99 | t.Fatalf("expected error to be nil not %v", err) 100 | } 101 | } 102 | 103 | func TestDeleteClientSuccess(t *testing.T) { 104 | ctrl := gomock.NewController(t) 105 | defer ctrl.Finish() 106 | 107 | mockLogger := NewMockLoggerInterface(ctrl) 108 | mockHydra := NewMockHydraClientInterface(ctrl) 109 | mockTracer := NewMockTracer(ctrl) 110 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 111 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 112 | 113 | const clientId = "client_id" 114 | 115 | c := hClient.NewOAuth2Client() 116 | c.SetClientId(clientId) 117 | clientReq := hClient.OAuth2ApiDeleteOAuth2ClientRequest{ 118 | ApiService: mockHydraOAuth2Api, 119 | } 120 | 121 | ctx := context.Background() 122 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 123 | mockHydraOAuth2Api.EXPECT().DeleteOAuth2Client(gomock.Any(), clientId).Times(1).Return(clientReq) 124 | mockHydraOAuth2Api.EXPECT().DeleteOAuth2ClientExecute(gomock.Any()).Times(1).Return(new(http.Response), nil) 125 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.DeleteOAuth2Client").Times(1).Return(nil, trace.SpanFromContext(ctx)) 126 | 127 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).DeleteClient(ctx, clientId) 128 | 129 | if resp.ServiceError != nil { 130 | t.Fatal("expected serviceError to be nil, got: ", resp.ServiceError) 131 | } 132 | if resp.Resp != nil { 133 | t.Fatalf("expected data to be %+v, got: %+v", c, resp.Resp) 134 | } 135 | if err != nil { 136 | t.Fatalf("expected error to be nil not %v", err) 137 | } 138 | } 139 | 140 | func TestDeleteClientFails(t *testing.T) { 141 | ctrl := gomock.NewController(t) 142 | defer ctrl.Finish() 143 | 144 | mockLogger := NewMockLoggerInterface(ctrl) 145 | mockHydra := NewMockHydraClientInterface(ctrl) 146 | mockTracer := NewMockTracer(ctrl) 147 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 148 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 149 | 150 | const clientId = "client_id" 151 | 152 | errResp := hClient.NewErrorOAuth2() 153 | errResp.SetError("error") 154 | errResp.SetErrorDescription("Some error happened") 155 | clientReq := hClient.OAuth2ApiDeleteOAuth2ClientRequest{ 156 | ApiService: mockHydraOAuth2Api, 157 | } 158 | errJson, _ := errResp.MarshalJSON() 159 | serviceResp := &http.Response{ 160 | Body: io.NopCloser(bytes.NewBuffer(errJson)), 161 | StatusCode: 400, 162 | } 163 | 164 | ctx := context.Background() 165 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 166 | mockHydraOAuth2Api.EXPECT().DeleteOAuth2Client(gomock.Any(), clientId).Times(1).Return(clientReq) 167 | mockHydraOAuth2Api.EXPECT().DeleteOAuth2ClientExecute(gomock.Any()).Times(1).Return(serviceResp, fmt.Errorf("error")) 168 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.DeleteOAuth2Client").Times(1).Return(nil, trace.SpanFromContext(ctx)) 169 | 170 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).DeleteClient(ctx, clientId) 171 | expectedError := new(ErrorOAuth2) 172 | expectedError.Error = *errResp.Error 173 | expectedError.ErrorDescription = *errResp.ErrorDescription 174 | expectedError.StatusCode = serviceResp.StatusCode 175 | 176 | if !reflect.DeepEqual(resp.ServiceError, expectedError) { 177 | t.Fatalf("expected data to be %+v, got: %+v", errResp, resp.ServiceError) 178 | } 179 | if err != nil { 180 | t.Fatalf("expected error to be nil not %v", err) 181 | } 182 | } 183 | 184 | func TestCreateClientSuccess(t *testing.T) { 185 | ctrl := gomock.NewController(t) 186 | defer ctrl.Finish() 187 | 188 | mockLogger := NewMockLoggerInterface(ctrl) 189 | mockHydra := NewMockHydraClientInterface(ctrl) 190 | mockTracer := NewMockTracer(ctrl) 191 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 192 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 193 | 194 | c := hClient.NewOAuth2Client() 195 | clientReq := hClient.OAuth2ApiCreateOAuth2ClientRequest{ 196 | ApiService: mockHydraOAuth2Api, 197 | } 198 | 199 | ctx := context.Background() 200 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 201 | mockHydraOAuth2Api.EXPECT().CreateOAuth2Client(gomock.Any()).Times(1).Return(clientReq) 202 | mockHydraOAuth2Api.EXPECT().CreateOAuth2ClientExecute(gomock.Any()).Times(1).Return(c, new(http.Response), nil) 203 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.CreateOAuth2Client").Times(1).Return(nil, trace.SpanFromContext(ctx)) 204 | 205 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).CreateClient(ctx, c) 206 | 207 | if resp.ServiceError != nil { 208 | t.Fatal("expected serviceError to be nil, got: ", resp.ServiceError) 209 | } 210 | if !reflect.DeepEqual(resp.Resp, c) { 211 | t.Fatalf("expected data to be %+v, got: %+v", c, resp.Resp) 212 | } 213 | if err != nil { 214 | t.Fatalf("expected error to be nil not %v", err) 215 | } 216 | } 217 | 218 | func TestCreateClientFails(t *testing.T) { 219 | ctrl := gomock.NewController(t) 220 | defer ctrl.Finish() 221 | 222 | mockLogger := NewMockLoggerInterface(ctrl) 223 | mockHydra := NewMockHydraClientInterface(ctrl) 224 | mockTracer := NewMockTracer(ctrl) 225 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 226 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 227 | 228 | c := hClient.NewOAuth2Client() 229 | errResp := hClient.NewErrorOAuth2() 230 | errResp.SetError("error") 231 | errResp.SetErrorDescription("Some error happened") 232 | clientReq := hClient.OAuth2ApiCreateOAuth2ClientRequest{ 233 | ApiService: mockHydraOAuth2Api, 234 | } 235 | errJson, _ := errResp.MarshalJSON() 236 | serviceResp := &http.Response{ 237 | Body: io.NopCloser(bytes.NewBuffer(errJson)), 238 | StatusCode: 404, 239 | } 240 | 241 | ctx := context.Background() 242 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 243 | mockHydraOAuth2Api.EXPECT().CreateOAuth2Client(gomock.Any()).Times(1).Return(clientReq) 244 | mockHydraOAuth2Api.EXPECT().CreateOAuth2ClientExecute(gomock.Any()).Times(1).Return(nil, serviceResp, fmt.Errorf("error")) 245 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.CreateOAuth2Client").Times(1).Return(nil, trace.SpanFromContext(ctx)) 246 | 247 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).CreateClient(ctx, c) 248 | expectedError := new(ErrorOAuth2) 249 | expectedError.Error = *errResp.Error 250 | expectedError.ErrorDescription = *errResp.ErrorDescription 251 | expectedError.StatusCode = serviceResp.StatusCode 252 | 253 | if !reflect.DeepEqual(resp.ServiceError, expectedError) { 254 | t.Fatalf("expected data to be %+v, got: %+v", errResp, resp.ServiceError) 255 | } 256 | if err != nil { 257 | t.Fatalf("expected error to be nil not %v", err) 258 | } 259 | } 260 | 261 | func TestUpdateClientSuccess(t *testing.T) { 262 | ctrl := gomock.NewController(t) 263 | defer ctrl.Finish() 264 | 265 | mockLogger := NewMockLoggerInterface(ctrl) 266 | mockHydra := NewMockHydraClientInterface(ctrl) 267 | mockTracer := NewMockTracer(ctrl) 268 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 269 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 270 | 271 | const clientId = "client_id" 272 | c := hClient.NewOAuth2Client() 273 | c.SetClientId(clientId) 274 | clientReq := hClient.OAuth2ApiSetOAuth2ClientRequest{ 275 | ApiService: mockHydraOAuth2Api, 276 | } 277 | 278 | ctx := context.Background() 279 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 280 | mockHydraOAuth2Api.EXPECT().SetOAuth2Client(gomock.Any(), clientId).Times(1).Return(clientReq) 281 | mockHydraOAuth2Api.EXPECT().SetOAuth2ClientExecute(gomock.Any()).Times(1).Return(c, new(http.Response), nil) 282 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.SetOAuth2Client").Times(1).Return(nil, trace.SpanFromContext(ctx)) 283 | 284 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).UpdateClient(ctx, c) 285 | 286 | if resp.ServiceError != nil { 287 | t.Fatal("expected serviceError to be nil, got: ", resp.ServiceError) 288 | } 289 | if !reflect.DeepEqual(resp.Resp, c) { 290 | t.Fatalf("expected data to be %+v, got: %+v", c, resp.Resp) 291 | } 292 | if err != nil { 293 | t.Fatalf("expected error to be nil not %v", err) 294 | } 295 | } 296 | 297 | func TestUpdateClientFails(t *testing.T) { 298 | ctrl := gomock.NewController(t) 299 | defer ctrl.Finish() 300 | 301 | mockLogger := NewMockLoggerInterface(ctrl) 302 | mockHydra := NewMockHydraClientInterface(ctrl) 303 | mockTracer := NewMockTracer(ctrl) 304 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 305 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 306 | 307 | const clientId = "client_id" 308 | c := hClient.NewOAuth2Client() 309 | c.SetClientId(clientId) 310 | errResp := hClient.NewErrorOAuth2() 311 | errResp.SetError("error") 312 | errResp.SetErrorDescription("Some error happened") 313 | clientReq := hClient.OAuth2ApiSetOAuth2ClientRequest{ 314 | ApiService: mockHydraOAuth2Api, 315 | } 316 | errJson, _ := errResp.MarshalJSON() 317 | serviceResp := &http.Response{ 318 | Body: io.NopCloser(bytes.NewBuffer(errJson)), 319 | StatusCode: 404, 320 | } 321 | 322 | ctx := context.Background() 323 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 324 | mockHydraOAuth2Api.EXPECT().SetOAuth2Client(gomock.Any(), clientId).Times(1).Return(clientReq) 325 | mockHydraOAuth2Api.EXPECT().SetOAuth2ClientExecute(gomock.Any()).Times(1).Return(nil, serviceResp, fmt.Errorf("error")) 326 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.SetOAuth2Client").Times(1).Return(nil, trace.SpanFromContext(ctx)) 327 | 328 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).UpdateClient(ctx, c) 329 | expectedError := new(ErrorOAuth2) 330 | expectedError.Error = *errResp.Error 331 | expectedError.ErrorDescription = *errResp.ErrorDescription 332 | expectedError.StatusCode = serviceResp.StatusCode 333 | 334 | if !reflect.DeepEqual(resp.ServiceError, expectedError) { 335 | t.Fatalf("expected data to be %+v, got: %+v", errResp, resp.ServiceError) 336 | } 337 | if err != nil { 338 | t.Fatalf("expected error to be nil not %v", err) 339 | } 340 | } 341 | 342 | func TestListClientSuccess(t *testing.T) { 343 | ctrl := gomock.NewController(t) 344 | defer ctrl.Finish() 345 | 346 | mockLogger := NewMockLoggerInterface(ctrl) 347 | mockHydra := NewMockHydraClientInterface(ctrl) 348 | mockTracer := NewMockTracer(ctrl) 349 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 350 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 351 | 352 | const clientId = "client_id" 353 | c := hClient.NewOAuth2Client() 354 | c.SetClientId(clientId) 355 | cs := []OAuth2Client{*c} 356 | clientReq := hClient.OAuth2ApiListOAuth2ClientsRequest{ 357 | ApiService: mockHydraOAuth2Api, 358 | } 359 | size := 10 360 | page := "10" 361 | listReq := NewListClientsRequest("", "", page, size) 362 | 363 | ctx := context.Background() 364 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.ListOAuth2Clients").Times(1).Return(nil, trace.SpanFromContext(ctx)) 365 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 366 | mockHydraOAuth2Api.EXPECT().ListOAuth2Clients(gomock.Any()).Times(1).Return(clientReq) 367 | mockHydraOAuth2Api.EXPECT().ListOAuth2ClientsExecute(gomock.Any()).Times(1).DoAndReturn( 368 | func(r hClient.OAuth2ApiListOAuth2ClientsRequest) ([]OAuth2Client, *http.Response, error) { 369 | if _size := (*int)(reflect.ValueOf(r).FieldByName("pageSize").UnsafePointer()); *_size != size { 370 | t.Fatalf("expected id to be %v, got %v", size, *_size) 371 | } 372 | if _page := (*string)(reflect.ValueOf(r).FieldByName("pageToken").UnsafePointer()); *_page != page { 373 | t.Fatalf("expected id to be %s, got %s", page, *_page) 374 | } 375 | return cs, new(http.Response), nil 376 | }, 377 | ) 378 | 379 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).ListClients(ctx, listReq) 380 | 381 | if resp.ServiceError != nil { 382 | t.Fatal("expected serviceError to be nil, got: ", resp.ServiceError) 383 | } 384 | if !reflect.DeepEqual(resp.Resp, cs) { 385 | t.Fatalf("expected data to be %+v, got: %+v", cs, resp.Resp) 386 | } 387 | if err != nil { 388 | t.Fatalf("expected error to be nil not %v", err) 389 | } 390 | } 391 | 392 | func TestListClientFails(t *testing.T) { 393 | ctrl := gomock.NewController(t) 394 | defer ctrl.Finish() 395 | 396 | mockLogger := NewMockLoggerInterface(ctrl) 397 | mockHydra := NewMockHydraClientInterface(ctrl) 398 | mockTracer := NewMockTracer(ctrl) 399 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 400 | mockHydraOAuth2Api := NewMockOAuth2Api(ctrl) 401 | 402 | errResp := hClient.NewErrorOAuth2() 403 | errResp.SetError("error") 404 | errResp.SetErrorDescription("Some error happened") 405 | clientReq := hClient.OAuth2ApiListOAuth2ClientsRequest{ 406 | ApiService: mockHydraOAuth2Api, 407 | } 408 | errJson, _ := errResp.MarshalJSON() 409 | serviceResp := &http.Response{ 410 | Body: io.NopCloser(bytes.NewBuffer(errJson)), 411 | StatusCode: 404, 412 | } 413 | listReq := NewListClientsRequest("", "", "1", 10) 414 | 415 | ctx := context.Background() 416 | mockHydra.EXPECT().OAuth2Api().Times(1).Return(mockHydraOAuth2Api) 417 | mockHydraOAuth2Api.EXPECT().ListOAuth2Clients(gomock.Any()).Times(1).Return(clientReq) 418 | mockHydraOAuth2Api.EXPECT().ListOAuth2ClientsExecute(gomock.Any()).Times(1).Return(nil, serviceResp, fmt.Errorf("error")) 419 | mockTracer.EXPECT().Start(ctx, "hydra.OAuth2Api.ListOAuth2Clients").Times(1).Return(nil, trace.SpanFromContext(ctx)) 420 | 421 | resp, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).ListClients(ctx, listReq) 422 | expectedError := new(ErrorOAuth2) 423 | expectedError.Error = *errResp.Error 424 | expectedError.ErrorDescription = *errResp.ErrorDescription 425 | expectedError.StatusCode = serviceResp.StatusCode 426 | 427 | if !reflect.DeepEqual(resp.ServiceError, expectedError) { 428 | t.Fatalf("expected data to be %+v, got: %+v", errResp, resp.ServiceError) 429 | } 430 | if err != nil { 431 | t.Fatalf("expected error to be nil not %v", err) 432 | } 433 | } 434 | 435 | func TestUnmarshalClient(t *testing.T) { 436 | ctrl := gomock.NewController(t) 437 | defer ctrl.Finish() 438 | 439 | mockLogger := NewMockLoggerInterface(ctrl) 440 | mockHydra := NewMockHydraClientInterface(ctrl) 441 | mockTracer := NewMockTracer(ctrl) 442 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 443 | 444 | c := hClient.NewOAuth2Client() 445 | jsonBody, _ := c.MarshalJSON() 446 | 447 | cc, err := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).UnmarshalClient(jsonBody) 448 | if !reflect.DeepEqual(cc, c) { 449 | t.Fatalf("expected flow to be %+v not %+v", c, cc) 450 | } 451 | if err != nil { 452 | t.Fatalf("expected error to be nil not %v", err) 453 | } 454 | } 455 | 456 | func TestParseLinks(t *testing.T) { 457 | ctrl := gomock.NewController(t) 458 | defer ctrl.Finish() 459 | 460 | mockLogger := NewMockLoggerInterface(ctrl) 461 | mockHydra := NewMockHydraClientInterface(ctrl) 462 | mockTracer := NewMockTracer(ctrl) 463 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 464 | 465 | nextToken := "next_token" 466 | lastToken := "last_token" 467 | size := 10 468 | links := "; rel=\"next\",; rel=\"last\"" 469 | 470 | l := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).parseLinks(links) 471 | expectedLinks := new(PaginationLinksMeta) 472 | expectedLinks.Next = PaginationMeta{nextToken, size} 473 | expectedLinks.Last = PaginationMeta{lastToken, size} 474 | 475 | if !reflect.DeepEqual(l, expectedLinks) { 476 | t.Fatalf("expected data to be %+v, got: %+v", expectedLinks, l) 477 | } 478 | } 479 | 480 | func TestParseServiceError(t *testing.T) { 481 | ctrl := gomock.NewController(t) 482 | defer ctrl.Finish() 483 | 484 | mockLogger := NewMockLoggerInterface(ctrl) 485 | mockHydra := NewMockHydraClientInterface(ctrl) 486 | mockTracer := NewMockTracer(ctrl) 487 | mockMonitor := monitoring.NewMockMonitorInterface(ctrl) 488 | 489 | errorMsg := "error" 490 | errorDescription := "Some error happened" 491 | statusCode := 400 492 | errResp := hClient.NewErrorOAuth2() 493 | errResp.SetError(errorMsg) 494 | errResp.SetErrorDescription(errorDescription) 495 | errJson, _ := errResp.MarshalJSON() 496 | serviceResp := &http.Response{ 497 | Body: io.NopCloser(bytes.NewBuffer(errJson)), 498 | StatusCode: statusCode, 499 | } 500 | err, _ := NewService(mockHydra, mockTracer, mockMonitor, mockLogger).parseServiceError(serviceResp) 501 | 502 | if err.Error != errorMsg { 503 | t.Fatalf("expected error to be %+v, got: %+v", errorMsg, err.Error) 504 | } 505 | if err.ErrorDescription != errorDescription { 506 | t.Fatalf("expected error description to be %+v, got: %+v", errorDescription, err.ErrorDescription) 507 | } 508 | if err.StatusCode != statusCode { 509 | t.Fatalf("expected status code to be %+v, got: %+v", statusCode, err.StatusCode) 510 | } 511 | } 512 | -------------------------------------------------------------------------------- /pkg/identities/service_test.go: -------------------------------------------------------------------------------- 1 | package identities 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | reflect "reflect" 10 | "testing" 11 | 12 | gomock "github.com/golang/mock/gomock" 13 | kClient "github.com/ory/kratos-client-go" 14 | "go.opentelemetry.io/otel/trace" 15 | ) 16 | 17 | //go:generate mockgen -build_flags=--mod=mod -package identities -destination ./mock_logger.go -source=../../internal/logging/interfaces.go 18 | //go:generate mockgen -build_flags=--mod=mod -package identities -destination ./mock_interfaces.go -source=./interfaces.go 19 | //go:generate mockgen -build_flags=--mod=mod -package identities -destination ./mock_monitor.go -source=../../internal/monitoring/interfaces.go 20 | //go:generate mockgen -build_flags=--mod=mod -package identities -destination ./mock_tracing.go go.opentelemetry.io/otel/trace Tracer 21 | //go:generate mockgen -build_flags=--mod=mod -package identities -destination ./mock_kratos.go github.com/ory/kratos-client-go IdentityApi 22 | 23 | func TestListIdentitiesSuccess(t *testing.T) { 24 | ctrl := gomock.NewController(t) 25 | defer ctrl.Finish() 26 | 27 | mockLogger := NewMockLoggerInterface(ctrl) 28 | mockTracer := NewMockTracer(ctrl) 29 | mockMonitor := NewMockMonitorInterface(ctrl) 30 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 31 | 32 | ctx := context.Background() 33 | 34 | identityRequest := kClient.IdentityApiListIdentitiesRequest{ 35 | ApiService: mockKratosIdentityApi, 36 | } 37 | 38 | identities := make([]kClient.Identity, 0) 39 | 40 | for i := 0; i < 10; i++ { 41 | identities = append(identities, *kClient.NewIdentity(fmt.Sprintf("test-%v", i), "test.json", "https://test.com/test.json", map[string]string{"name": "name"})) 42 | } 43 | 44 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.ListIdentities").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 45 | mockKratosIdentityApi.EXPECT().ListIdentities(ctx).Times(1).Return(identityRequest) 46 | mockKratosIdentityApi.EXPECT().ListIdentitiesExecute(gomock.Any()).Times(1).DoAndReturn( 47 | func(r kClient.IdentityApiListIdentitiesRequest) ([]kClient.Identity, *http.Response, error) { 48 | 49 | // use reflect as attributes are private, also are pointers so need to cast it multiple times 50 | if page := (*int64)(reflect.ValueOf(r).FieldByName("page").UnsafePointer()); *page != 2 { 51 | t.Fatalf("expected page as 2, got %v", *page) 52 | } 53 | 54 | if pageSize := (*int64)(reflect.ValueOf(r).FieldByName("perPage").UnsafePointer()); *pageSize != 10 { 55 | t.Fatalf("expected page size as 10, got %v", *pageSize) 56 | } 57 | 58 | if credID := (*string)(reflect.ValueOf(r).FieldByName("credentialsIdentifier").UnsafePointer()); credID != nil { 59 | t.Fatalf("expected credential id to be empty, got %v", *credID) 60 | } 61 | 62 | return identities, new(http.Response), nil 63 | }, 64 | ) 65 | 66 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).ListIdentities(ctx, 2, 10, "") 67 | 68 | if !reflect.DeepEqual(ids.Identities, identities) { 69 | t.Fatalf("expected identities to be %v not %v", identities, ids.Identities) 70 | } 71 | if err != nil { 72 | t.Fatalf("expected error to be nil not %v", err) 73 | } 74 | } 75 | 76 | func TestListIdentitiesFails(t *testing.T) { 77 | ctrl := gomock.NewController(t) 78 | defer ctrl.Finish() 79 | 80 | mockLogger := NewMockLoggerInterface(ctrl) 81 | mockTracer := NewMockTracer(ctrl) 82 | mockMonitor := NewMockMonitorInterface(ctrl) 83 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 84 | 85 | ctx := context.Background() 86 | 87 | identityRequest := kClient.IdentityApiListIdentitiesRequest{ 88 | ApiService: mockKratosIdentityApi, 89 | } 90 | 91 | identities := make([]kClient.Identity, 0) 92 | 93 | mockLogger.EXPECT().Error(gomock.Any()).Times(1) 94 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.ListIdentities").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 95 | mockKratosIdentityApi.EXPECT().ListIdentities(ctx).Times(1).Return(identityRequest) 96 | mockKratosIdentityApi.EXPECT().ListIdentitiesExecute(gomock.Any()).Times(1).DoAndReturn( 97 | func(r kClient.IdentityApiListIdentitiesRequest) ([]kClient.Identity, *http.Response, error) { 98 | 99 | // use reflect as attributes are private, also are pointers so need to cast it multiple times 100 | if page := (*int64)(reflect.ValueOf(r).FieldByName("page").UnsafePointer()); *page != 2 { 101 | t.Fatalf("expected page as 2, got %v", *page) 102 | } 103 | 104 | if pageSize := (*int64)(reflect.ValueOf(r).FieldByName("perPage").UnsafePointer()); *pageSize != 10 { 105 | t.Fatalf("expected page size as 10, got %v", *pageSize) 106 | } 107 | 108 | if credID := (*string)(reflect.ValueOf(r).FieldByName("credentialsIdentifier").UnsafePointer()); *credID != "test" { 109 | t.Fatalf("expected credential id to be test, got %v", *credID) 110 | } 111 | 112 | rr := httptest.NewRecorder() 113 | rr.Header().Set("Content-Type", "application/json") 114 | rr.WriteHeader(http.StatusInternalServerError) 115 | 116 | json.NewEncoder(rr).Encode( 117 | map[string]interface{}{ 118 | "error": map[string]interface{}{ 119 | "code": http.StatusInternalServerError, 120 | "debug": "--------", 121 | "details": map[string]interface{}{}, 122 | "id": "string", 123 | "message": "error", 124 | "reason": "error", 125 | "request": "d7ef54b1-ec15-46e6-bccb-524b82c035e6", 126 | "status": "Not Found", 127 | }, 128 | }, 129 | ) 130 | 131 | return identities, rr.Result(), fmt.Errorf("error") 132 | }, 133 | ) 134 | 135 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).ListIdentities(ctx, 2, 10, "test") 136 | 137 | if !reflect.DeepEqual(ids.Identities, identities) { 138 | t.Fatalf("expected identities to be empty not %v", ids.Identities) 139 | } 140 | 141 | if ids.Error == nil { 142 | t.Fatal("expected ids.Error to be not nil") 143 | } 144 | 145 | if *ids.Error.Code != http.StatusInternalServerError { 146 | t.Fatalf("expected code to be %v not %v", http.StatusInternalServerError, *ids.Error.Code) 147 | } 148 | 149 | if err == nil { 150 | t.Fatal("expected error to be not nil") 151 | } 152 | } 153 | 154 | func TestGetIdentitySuccess(t *testing.T) { 155 | ctrl := gomock.NewController(t) 156 | defer ctrl.Finish() 157 | 158 | mockLogger := NewMockLoggerInterface(ctrl) 159 | mockTracer := NewMockTracer(ctrl) 160 | mockMonitor := NewMockMonitorInterface(ctrl) 161 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 162 | 163 | ctx := context.Background() 164 | credID := "test-1" 165 | 166 | identityRequest := kClient.IdentityApiGetIdentityRequest{ 167 | ApiService: mockKratosIdentityApi, 168 | } 169 | 170 | identity := kClient.NewIdentity(credID, "test.json", "https://test.com/test.json", map[string]string{"name": "name"}) 171 | 172 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.GetIdentity").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 173 | mockKratosIdentityApi.EXPECT().GetIdentity(ctx, credID).Times(1).Return(identityRequest) 174 | mockKratosIdentityApi.EXPECT().GetIdentityExecute(gomock.Any()).Times(1).Return(identity, new(http.Response), nil) 175 | 176 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).GetIdentity(ctx, credID) 177 | 178 | if !reflect.DeepEqual(ids.Identities, []kClient.Identity{*identity}) { 179 | t.Fatalf("expected identities to be %v not %v", *identity, ids.Identities) 180 | } 181 | if err != nil { 182 | t.Fatalf("expected error to be nil not %v", err) 183 | } 184 | } 185 | 186 | func TestGetIdentityFails(t *testing.T) { 187 | ctrl := gomock.NewController(t) 188 | defer ctrl.Finish() 189 | 190 | mockLogger := NewMockLoggerInterface(ctrl) 191 | mockTracer := NewMockTracer(ctrl) 192 | mockMonitor := NewMockMonitorInterface(ctrl) 193 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 194 | 195 | ctx := context.Background() 196 | credID := "test" 197 | 198 | identityRequest := kClient.IdentityApiGetIdentityRequest{ 199 | ApiService: mockKratosIdentityApi, 200 | } 201 | 202 | mockLogger.EXPECT().Error(gomock.Any()).Times(1) 203 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.GetIdentity").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 204 | mockKratosIdentityApi.EXPECT().GetIdentity(ctx, credID).Times(1).Return(identityRequest) 205 | mockKratosIdentityApi.EXPECT().GetIdentityExecute(gomock.Any()).Times(1).DoAndReturn( 206 | func(r kClient.IdentityApiGetIdentityRequest) (*kClient.Identity, *http.Response, error) { 207 | rr := httptest.NewRecorder() 208 | rr.Header().Set("Content-Type", "application/json") 209 | rr.WriteHeader(http.StatusNotFound) 210 | 211 | json.NewEncoder(rr).Encode( 212 | map[string]interface{}{ 213 | "error": map[string]interface{}{ 214 | "code": http.StatusNotFound, 215 | "debug": "--------", 216 | "details": map[string]interface{}{}, 217 | "id": "string", 218 | "message": "error", 219 | "reason": "error", 220 | "request": "d7ef54b1-ec15-46e6-bccb-524b82c035e6", 221 | "status": "Not Found", 222 | }, 223 | }, 224 | ) 225 | 226 | return nil, rr.Result(), fmt.Errorf("error") 227 | }, 228 | ) 229 | 230 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).GetIdentity(ctx, credID) 231 | 232 | if !reflect.DeepEqual(ids.Identities, make([]kClient.Identity, 0)) { 233 | t.Fatalf("expected identities to be empty not %v", ids.Identities) 234 | } 235 | 236 | if ids.Error == nil { 237 | t.Fatal("expected ids.Error to be not nil") 238 | } 239 | 240 | if *ids.Error.Code != int64(http.StatusNotFound) { 241 | t.Fatalf("expected code to be %v not %v", http.StatusNotFound, *ids.Error.Code) 242 | } 243 | 244 | if err == nil { 245 | t.Fatal("expected error to be not nil") 246 | } 247 | } 248 | 249 | func TestCreateIdentitySuccess(t *testing.T) { 250 | ctrl := gomock.NewController(t) 251 | defer ctrl.Finish() 252 | 253 | mockLogger := NewMockLoggerInterface(ctrl) 254 | mockTracer := NewMockTracer(ctrl) 255 | mockMonitor := NewMockMonitorInterface(ctrl) 256 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 257 | 258 | ctx := context.Background() 259 | 260 | identityRequest := kClient.IdentityApiCreateIdentityRequest{ 261 | ApiService: mockKratosIdentityApi, 262 | } 263 | 264 | identity := kClient.NewIdentity("test", "test.json", "https://test.com/test.json", map[string]string{"name": "name"}) 265 | credentials := kClient.NewIdentityWithCredentialsWithDefaults() 266 | identityBody := kClient.NewCreateIdentityBody("test.json", map[string]interface{}{"name": "name"}) 267 | identityBody.SetCredentials(*credentials) 268 | 269 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.CreateIdentity").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 270 | mockKratosIdentityApi.EXPECT().CreateIdentity(ctx).Times(1).Return(identityRequest) 271 | mockKratosIdentityApi.EXPECT().CreateIdentityExecute(gomock.Any()).Times(1).DoAndReturn( 272 | func(r kClient.IdentityApiCreateIdentityRequest) (*kClient.Identity, *http.Response, error) { 273 | 274 | // use reflect as attributes are private, also are pointers so need to cast it multiple times 275 | if IDBody := (*kClient.CreateIdentityBody)(reflect.ValueOf(r).FieldByName("createIdentityBody").UnsafePointer()); !reflect.DeepEqual(*IDBody, *identityBody) { 276 | t.Fatalf("expected body to be %v, got %v", identityBody, IDBody) 277 | } 278 | 279 | return identity, new(http.Response), nil 280 | }, 281 | ) 282 | 283 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).CreateIdentity(ctx, identityBody) 284 | 285 | if !reflect.DeepEqual(ids.Identities, []kClient.Identity{*identity}) { 286 | t.Fatalf("expected identities to be %v not %v", *identity, ids.Identities) 287 | } 288 | 289 | if err != nil { 290 | t.Fatalf("expected error to be nil not %v", err) 291 | } 292 | } 293 | 294 | func TestCreateIdentityFails(t *testing.T) { 295 | ctrl := gomock.NewController(t) 296 | defer ctrl.Finish() 297 | 298 | mockLogger := NewMockLoggerInterface(ctrl) 299 | mockTracer := NewMockTracer(ctrl) 300 | mockMonitor := NewMockMonitorInterface(ctrl) 301 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 302 | 303 | ctx := context.Background() 304 | 305 | identityRequest := kClient.IdentityApiCreateIdentityRequest{ 306 | ApiService: mockKratosIdentityApi, 307 | } 308 | 309 | credentials := kClient.NewIdentityWithCredentialsWithDefaults() 310 | identityBody := kClient.NewCreateIdentityBody("test.json", map[string]interface{}{"name": "name"}) 311 | identityBody.SetCredentials(*credentials) 312 | 313 | mockLogger.EXPECT().Error(gomock.Any()).Times(1) 314 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.CreateIdentity").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 315 | mockKratosIdentityApi.EXPECT().CreateIdentity(ctx).Times(1).Return(identityRequest) 316 | mockKratosIdentityApi.EXPECT().CreateIdentityExecute(gomock.Any()).Times(1).DoAndReturn( 317 | func(r kClient.IdentityApiCreateIdentityRequest) (*kClient.Identity, *http.Response, error) { 318 | rr := httptest.NewRecorder() 319 | rr.Header().Set("Content-Type", "application/json") 320 | rr.WriteHeader(http.StatusInternalServerError) 321 | 322 | json.NewEncoder(rr).Encode( 323 | map[string]interface{}{ 324 | "error": map[string]interface{}{ 325 | "code": http.StatusInternalServerError, 326 | "debug": "--------", 327 | "details": map[string]interface{}{}, 328 | "id": "string", 329 | "message": "error", 330 | "reason": "error", 331 | "request": "d7ef54b1-ec15-46e6-bccb-524b82c035e6", 332 | "status": "Internal Server Error", 333 | }, 334 | }, 335 | ) 336 | 337 | return nil, rr.Result(), fmt.Errorf("error") 338 | }, 339 | ) 340 | 341 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).CreateIdentity(ctx, identityBody) 342 | 343 | if !reflect.DeepEqual(ids.Identities, make([]kClient.Identity, 0)) { 344 | t.Fatalf("expected identities to be empty not %v", ids.Identities) 345 | } 346 | 347 | if ids.Error == nil { 348 | t.Fatal("expected ids.Error to be not nil") 349 | } 350 | 351 | if *ids.Error.Code != int64(http.StatusInternalServerError) { 352 | t.Fatalf("expected code to be %v not %v", http.StatusInternalServerError, *ids.Error.Code) 353 | } 354 | 355 | if err == nil { 356 | t.Fatal("expected error to be not nil") 357 | } 358 | } 359 | 360 | func TestUpdateIdentitySuccess(t *testing.T) { 361 | ctrl := gomock.NewController(t) 362 | defer ctrl.Finish() 363 | 364 | mockLogger := NewMockLoggerInterface(ctrl) 365 | mockTracer := NewMockTracer(ctrl) 366 | mockMonitor := NewMockMonitorInterface(ctrl) 367 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 368 | 369 | ctx := context.Background() 370 | 371 | identityRequest := kClient.IdentityApiUpdateIdentityRequest{ 372 | ApiService: mockKratosIdentityApi, 373 | } 374 | 375 | identity := kClient.NewIdentity("test", "test.json", "https://test.com/test.json", map[string]string{"name": "name"}) 376 | credentials := kClient.NewIdentityWithCredentialsWithDefaults() 377 | identityBody := kClient.NewUpdateIdentityBodyWithDefaults() 378 | identityBody.SetTraits(map[string]interface{}{"name": "name"}) 379 | identityBody.SetCredentials(*credentials) 380 | 381 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.UpdateIdentity").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 382 | mockKratosIdentityApi.EXPECT().UpdateIdentity(ctx, identity.Id).Times(1).Return(identityRequest) 383 | mockKratosIdentityApi.EXPECT().UpdateIdentityExecute(gomock.Any()).Times(1).DoAndReturn( 384 | func(r kClient.IdentityApiUpdateIdentityRequest) (*kClient.Identity, *http.Response, error) { 385 | 386 | // use reflect as attributes are private, also are pointers so need to cast it multiple times 387 | if IDBody := (*kClient.UpdateIdentityBody)(reflect.ValueOf(r).FieldByName("updateIdentityBody").UnsafePointer()); !reflect.DeepEqual(*IDBody, *identityBody) { 388 | t.Fatalf("expected body to be %v, got %v", identityBody, IDBody) 389 | } 390 | 391 | return identity, new(http.Response), nil 392 | }, 393 | ) 394 | 395 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).UpdateIdentity(ctx, identity.Id, identityBody) 396 | 397 | if !reflect.DeepEqual(ids.Identities, []kClient.Identity{*identity}) { 398 | t.Fatalf("expected identities to be %v not %v", *identity, ids.Identities) 399 | } 400 | 401 | if err != nil { 402 | t.Fatalf("expected error to be nil not %v", err) 403 | } 404 | } 405 | 406 | func TestUpdateIdentityFails(t *testing.T) { 407 | ctrl := gomock.NewController(t) 408 | defer ctrl.Finish() 409 | 410 | mockLogger := NewMockLoggerInterface(ctrl) 411 | mockTracer := NewMockTracer(ctrl) 412 | mockMonitor := NewMockMonitorInterface(ctrl) 413 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 414 | 415 | ctx := context.Background() 416 | 417 | credID := "test" 418 | 419 | identityRequest := kClient.IdentityApiUpdateIdentityRequest{ 420 | ApiService: mockKratosIdentityApi, 421 | } 422 | 423 | credentials := kClient.NewIdentityWithCredentialsWithDefaults() 424 | identityBody := kClient.NewUpdateIdentityBodyWithDefaults() 425 | identityBody.SetTraits(map[string]interface{}{"name": "name"}) 426 | identityBody.SetCredentials(*credentials) 427 | 428 | mockLogger.EXPECT().Error(gomock.Any()).Times(1) 429 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.UpdateIdentity").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 430 | mockKratosIdentityApi.EXPECT().UpdateIdentity(ctx, credID).Times(1).Return(identityRequest) 431 | mockKratosIdentityApi.EXPECT().UpdateIdentityExecute(gomock.Any()).Times(1).DoAndReturn( 432 | func(r kClient.IdentityApiUpdateIdentityRequest) (*kClient.Identity, *http.Response, error) { 433 | rr := httptest.NewRecorder() 434 | rr.Header().Set("Content-Type", "application/json") 435 | rr.WriteHeader(http.StatusConflict) 436 | 437 | json.NewEncoder(rr).Encode( 438 | map[string]interface{}{ 439 | "error": map[string]interface{}{ 440 | "code": http.StatusConflict, 441 | "debug": "--------", 442 | "details": map[string]interface{}{}, 443 | "id": "string", 444 | "message": "error", 445 | "reason": "error", 446 | "request": "d7ef54b1-ec15-46e6-bccb-524b82c035e6", 447 | "status": "Conflict", 448 | }, 449 | }, 450 | ) 451 | 452 | return nil, rr.Result(), fmt.Errorf("error") 453 | }, 454 | ) 455 | 456 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).UpdateIdentity(ctx, credID, identityBody) 457 | 458 | if !reflect.DeepEqual(ids.Identities, make([]kClient.Identity, 0)) { 459 | t.Fatalf("expected identities to be empty not %v", ids.Identities) 460 | } 461 | 462 | if ids.Error == nil { 463 | t.Fatal("expected ids.Error to be not nil") 464 | } 465 | 466 | if *ids.Error.Code != int64(http.StatusConflict) { 467 | t.Fatalf("expected code to be %v not %v", http.StatusConflict, *ids.Error.Code) 468 | } 469 | 470 | if err == nil { 471 | t.Fatal("expected error to be not nil") 472 | } 473 | } 474 | 475 | func TestDeleteIdentitySuccess(t *testing.T) { 476 | ctrl := gomock.NewController(t) 477 | defer ctrl.Finish() 478 | 479 | mockLogger := NewMockLoggerInterface(ctrl) 480 | mockTracer := NewMockTracer(ctrl) 481 | mockMonitor := NewMockMonitorInterface(ctrl) 482 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 483 | 484 | ctx := context.Background() 485 | credID := "test-1" 486 | 487 | identityRequest := kClient.IdentityApiDeleteIdentityRequest{ 488 | ApiService: mockKratosIdentityApi, 489 | } 490 | 491 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.DeleteIdentity").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 492 | mockKratosIdentityApi.EXPECT().DeleteIdentity(ctx, credID).Times(1).Return(identityRequest) 493 | mockKratosIdentityApi.EXPECT().DeleteIdentityExecute(gomock.Any()).Times(1).Return(new(http.Response), nil) 494 | 495 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).DeleteIdentity(ctx, credID) 496 | 497 | if len(ids.Identities) > 0 { 498 | t.Fatalf("invalid result, expected no identities, got %v", ids.Identities) 499 | } 500 | 501 | if err != nil { 502 | t.Fatalf("expected error to be nil not %v", err) 503 | } 504 | } 505 | 506 | func TestDeleteIdentityFails(t *testing.T) { 507 | ctrl := gomock.NewController(t) 508 | defer ctrl.Finish() 509 | 510 | mockLogger := NewMockLoggerInterface(ctrl) 511 | mockTracer := NewMockTracer(ctrl) 512 | mockMonitor := NewMockMonitorInterface(ctrl) 513 | mockKratosIdentityApi := NewMockIdentityApi(ctrl) 514 | 515 | ctx := context.Background() 516 | credID := "test-1" 517 | 518 | identityRequest := kClient.IdentityApiDeleteIdentityRequest{ 519 | ApiService: mockKratosIdentityApi, 520 | } 521 | 522 | mockLogger.EXPECT().Error(gomock.Any()).Times(1) 523 | mockTracer.EXPECT().Start(ctx, "kratos.IdentityApi.DeleteIdentity").Times(1).Return(ctx, trace.SpanFromContext(ctx)) 524 | mockKratosIdentityApi.EXPECT().DeleteIdentity(ctx, credID).Times(1).Return(identityRequest) 525 | mockKratosIdentityApi.EXPECT().DeleteIdentityExecute(gomock.Any()).Times(1).DoAndReturn( 526 | func(r kClient.IdentityApiDeleteIdentityRequest) (*http.Response, error) { 527 | rr := httptest.NewRecorder() 528 | rr.Header().Set("Content-Type", "application/json") 529 | rr.WriteHeader(http.StatusNotFound) 530 | 531 | json.NewEncoder(rr).Encode( 532 | map[string]interface{}{ 533 | "error": map[string]interface{}{ 534 | "code": http.StatusNotFound, 535 | "debug": "--------", 536 | "details": map[string]interface{}{}, 537 | "id": "string", 538 | "message": "error", 539 | "reason": "error", 540 | "request": "d7ef54b1-ec15-46e6-bccb-524b82c035e6", 541 | "status": "Not Found", 542 | }, 543 | }, 544 | ) 545 | 546 | return rr.Result(), fmt.Errorf("error") 547 | }, 548 | ) 549 | 550 | ids, err := NewService(mockKratosIdentityApi, mockTracer, mockMonitor, mockLogger).DeleteIdentity(ctx, credID) 551 | 552 | if !reflect.DeepEqual(ids.Identities, make([]kClient.Identity, 0)) { 553 | t.Fatalf("expected identities to be empty not %v", ids.Identities) 554 | } 555 | 556 | if ids.Error == nil { 557 | t.Fatal("expected ids.Error to be not nil") 558 | } 559 | 560 | if *ids.Error.Code != int64(http.StatusNotFound) { 561 | t.Fatalf("expected code to be %v not %v", http.StatusNotFound, *ids.Error.Code) 562 | } 563 | 564 | if err == nil { 565 | t.Fatal("expected error to be not nil") 566 | } 567 | } 568 | --------------------------------------------------------------------------------