├── 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 | [](https://codecov.io/gh/canonical/identity-platform-admin-ui)
4 | [](https://securityscorecards.dev/viewer/?platform=github.com&org=canonical&repo=identity-platform-admin-ui)
5 | 
6 | [](https://github.com/canonical/identity-platform-admin-ui/actions/workflows/ci.yaml)
7 | [](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 |
--------------------------------------------------------------------------------