From 27ed97bcadeff61f10aae4aebab04b5644776c2b Mon Sep 17 00:00:00 2001 From: Denis Zakharov Date: Tue, 13 Feb 2024 13:03:05 +0300 Subject: [PATCH] Initial commit --- .github/workflows/golangci-lint.yml | 26 + .github/workflows/unit-tests.yml | 27 + .golangci.yml | 110 ++++ CONTRIBUTING.md | 6 + LICENCE | 201 +++++++ Makefile | 16 + README.md | 70 +++ docs/README.md | 5 + docs/errors.md | 33 ++ examples/README.md | 118 ++++ .../README.md | 176 ++++++ .../main.go | 119 ++++ .../README.md | 83 +++ .../create-list-update-delete-secrets/main.go | 67 +++ go.mod | 15 + go.sum | 16 + internal/auth/auth.go | 33 ++ internal/httpclient/httpclient.go | 155 +++++ secretsmanager.go | 124 ++++ secretsmanagererrors/secretsmanagererrors.go | 113 ++++ service/certs/certs.go | 402 +++++++++++++ service/certs/certs_test.go | 532 ++++++++++++++++++ .../certs/fixtures/cert-response-data.json | 40 ++ .../certs/fixtures/certs-response-data.json | 43 ++ service/certs/model.go | 90 +++ .../fixtures/secret-response-data.json | 9 + .../fixtures/secrets-response-data.json | 20 + service/secrets/model.go | 40 ++ service/secrets/secrets.go | 186 ++++++ service/secrets/secrets_test.go | 282 ++++++++++ 30 files changed, 3157 insertions(+) create mode 100644 .github/workflows/golangci-lint.yml create mode 100644 .github/workflows/unit-tests.yml create mode 100644 .golangci.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENCE create mode 100644 Makefile create mode 100644 README.md create mode 100644 docs/README.md create mode 100644 docs/errors.md create mode 100644 examples/README.md create mode 100644 examples/create-addconsumers-update-get-certs/README.md create mode 100644 examples/create-addconsumers-update-get-certs/main.go create mode 100644 examples/create-list-update-delete-secrets/README.md create mode 100644 examples/create-list-update-delete-secrets/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth.go create mode 100644 internal/httpclient/httpclient.go create mode 100644 secretsmanager.go create mode 100644 secretsmanagererrors/secretsmanagererrors.go create mode 100644 service/certs/certs.go create mode 100644 service/certs/certs_test.go create mode 100644 service/certs/fixtures/cert-response-data.json create mode 100644 service/certs/fixtures/certs-response-data.json create mode 100644 service/certs/model.go create mode 100644 service/secrets/fixtures/secret-response-data.json create mode 100644 service/secrets/fixtures/secrets-response-data.json create mode 100644 service/secrets/model.go create mode 100644 service/secrets/secrets.go create mode 100644 service/secrets/secrets_test.go diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..441e5d8 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,26 @@ +name: golangci-lint + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5.0.0 + with: + go-version: "1.21" + + - name: Lint using golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: v1.55.2 + working-directory: ./ \ No newline at end of file diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..6cfaf85 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,27 @@ +name: test and upload coverage to Codecov + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Go + uses: actions/setup-go@v5.0.0 + with: + go-version: "1.21" + + - name: Run coverage + run: go test -race -coverprofile=coverage.out -covermode=atomic + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..2ca1e93 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,110 @@ +--- +linters: + presets: + - bugs # bugs detection + - comment # comments analysis + - complexity # code complexity analysis + - error # error handling analysis + - format # code formatting + - metalinter # linter that contains multiple rules or multiple linters + - performance # performance + - unused # Checks Go code for unused constants, variables, functions and types. + + enable: + - asciicheck # Checks that all code identifiers does not have non-ASCII symbols in the name. + - containedctx # Detects too much false positives around (*http.Request).Context() + - dogsled # Checks assignments with too many blank identifiers (e.g. x, , , _, := f()). + - dupl # Detects code clone. It's recommended to use + - errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error. + - forcetypeassert # Finds forced type assertions. + - gochecknoglobals # Checks that no globals in code. But we need global variables. For config or single-tone pattern realisation. + - gochecknoinits # Checks that no inits functions in app. Init functions have some side effects. But we need init function for correct app initialize. + - goconst # Finds repeated strings that could be replaced by a constant. + - godox # Detects for "TODO" or "FIXME" comments. + - gomoddirectives # Manages the use of 'replace', 'retract', and 'excludes' directives in go.mod. + - goprintffuncname # Checks that printf-like functions are named with f at the end. + - gosimple # Detects areas in Go source code that can be simplified. + - lll # Reports long lines + - makezero # Finds slice declarations with non-zero initial length. + - nakedret # Checks that functions with naked returns are not longer than a maximum size (can be zero). + - nolintlint # Go static analysis tool to find ill-formed or insufficiently explained //nolint directives for golangci-lint + - predeclared # Finds code that shadows one of Go's predeclared identifiers. + - promlinter # Checks Prometheus metrics naming via promlint. + - stylecheck # Stylecheck is a replacement for golint. + - tagliatelle # Requires struct fields and json description to be the same. Need to rename many of json. + - thelper # Detects golang test helpers without t.Helper() + - tparallel # Detects inappropriate usage of t.Parallel() method in your Go test codes. + - unconvert # Remove unnecessary type conversions. + - wastedassign # Finds wasted assignment statements. + - whitespace # Checks for unnecessary newlines at the start and end of functions, if, for, etc. + + disable: + - contextcheck # Detects too much false positives around (*http.Request).Context() + - maligned # Deprecated: performance — superseded by govet(fieldalignment) + - scopelint # Deprecated: performance — superseded by exportloopref + +linters-settings: + dogsled: + max-blank-identifiers: 3 + + errorlint: + errorf: true + + exhaustive: + default-signifies-exhaustive: true + + funlen: + lines: 100 + statements: 60 + + gci: + sections: + - standard + - default + - prefix(github.com/selectel/secretsmanager) + + godot: + scope: declarations + exclude: + - '^ @' + + goimports: + local-prefixes: github.com/selectel/secretsmanager + + lll: + tab-width: 4 + + nolintlint: + allow-leading-space: false + + tagliatelle: + case: + use-field-name: true + rules: + json: snake + yaml: snake + + tagalign: + sort: false # puts `example` tag before more important tag `json` + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + exclude-rules: + - path: _test\.go + linters: + - dupl + - goerr113 + - forcetypeassert + - gochecknoglobals + + - path: _test\.go + text: "fieldalignment" + linters: + - govet + + - source: "^//go:generate " + linters: + - lll + +... \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..beca399 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,6 @@ +# Contributing + +> [!IMPORTANT] +> Before creating a PR please create an issue consider opening an issue for larger changes to get feedback on the idea from the team. Pull requests are welcome for any changes. + +If your change touches parts of the Secrets Manager SDK internals, make sure that all the examples in the examples/ folder continue to run correctly. \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..359b6b7 --- /dev/null +++ b/LICENCE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Selectel Ltd. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..063afe2 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: all fmt tidy lint test +all: fmt tidy lint test + +fmt: + go fmt ./... + +tidy: + go mod tidy -v + +lint: + golangci-lint run + +test: + go clean -testcache + go test -v ./... + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da7db9 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# secretsmanager-go: Go SDK for Secrets Manager API +[![Go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/selectel/secretsmanager-go/) +[![Go Report Card](https://goreportcard.com/badge/github.com/selectel/secretsmanager-go)](https://goreportcard.com/report/github.com/selectel/secretsmanager-go/) +[![codecov](https://codecov.io/gh/selectel/secretsmanager-go/branch/main/graph/badge.svg)](https://codecov.io/gh/selectel/secretsmanager-go) + + +> [!NOTE] +> Secrets Manager SDK implements the Secrets Manager API facilitating the tasks of storing, managing and revoking secrets/certificates. + +## Documentation +> [!IMPORTANT] +> The Go library documentation is available at [go.dev](https://pkg.go.dev/github.com/selectel/secretsmanager-go/). + +## Getting started +### Install +```sh +go get github.com/selectel/secretsmanager-go +``` + +## Authentication + +To work with Selectel API: +- You're gonna need a Selectel account. +- [Keystone Token](https://developers.selectel.com/docs/control-panel/authorization/#keystone-token) + +## Usage +> [!IMPORTANT] +> At the moment you need to pass a **Valid** Keystone Token to use it. + +```go +package main + +import ( + "context" + "log" + "fmt" + + "github.com/selectel/secretsmanager" +) + +// Setting Up your project KeystoneToken +const KeystoneToken = "gAAAAAB..." + +func main() { + // Setting Up Client + cl, err := secretsmanager.New( + secretsmanager.WithAuthOpts(&secretsmanager.AuthOpts{KeystoneToken: KeystoneToken}), + ) + if err != nil { + log.Fatal(err) + } + + // Prepare an empty context, for future requests. + ctx := context.Background() + + sc, err := cl.Secrets.List(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Retrived secrets: %+v\n", sc) + // ... +} +``` + +## Additional info +> [!NOTE] +> +> More examples available in [├─ 📁 examples](./examples) section. +> +> For advanced topics and guides [├─ 📁 docs](./docs) section. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6cd23b1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,5 @@ +# Advanced Concepts +> [!NOTE] +> Here are listed some of the concepts, which can help you to controll the SDK more precisely. + +- [Error Handling](./docs/errors.md) \ No newline at end of file diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..51deba7 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,33 @@ +# Error Handling +In generall, `secretsmanager-go` errors can be divided into two types: +- Server Errors: returned by the Secret Manager Service itself +- Client Errors: returned by the `secretsmanager-go` (this package) + +Any of these can be handled as a special type [secretsmanagererrors.Error](secretsmanagererrors/secretsmanagererrors.go): + +Below there's an example on how to handle these errors: + +> [!NOTE] +> You can use `errors.Is` to identify the type > of an error: +> +> ```go +> if err != nil { +> switch { +> case errors.Is(err, secretsmanagererrors.ErrClientNoAuthOpts): +> log.Fatalf("No rights: %s", err.Error()) +> } +> // ... +> } +> ``` + +> [!IMPORTANT] +> You can cast a returned error to `secretsmanagererrors.Error` with `errors.As` and get the specific info (description of an error, for example): +> ```go +> if err != nil { +> var smError *secretsmanagererrors.Error +> if errors.As(err, &smError) { +> log.Fatalf("SecretsManager Error! Description: %s", smError.Desc()) +> } +> // ... +> } +> ``` \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..0127f7d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,118 @@ +# secretsmanager-go +> [!IMPORTANT] +> Examples that cover various scenarios using secretsmanager-go. +> - [Secrets](#secrets) full flow of storing, managing and revoking secrets. +> - [Certificates](#certificates) various examples how you can manage certs. + +## Client configuration +First thing to be done is set up yor client: + +```go +package main + +import ( + "context" + + "github.com/selectel/secretsmanager" +) + +func main() { + // Setting up your project KeystoneToken + tk := "gAAAAAB..." + + // Setting Up Client + cl, err := secretsmanager.New( + secretsmanager.WithAuthOpts(&secretsmanager.AuthOpts{KeystoneToken: tk}), + ) + if err != nil { + panic(err) + } + + // Prepare an empty context, for future requests. + ctx := context.Background() + // ... +} +``` + +## Secrets +> To perform any operations with secrets you have to call a `Secrets` property on client. + +### Creating a Secret +Firstly, create a secret using pre-defiend `secrets.UserSecret` data structure. +```go +mySecret := secrets.UserSecret { + Key : "Zeliboba" + Description : "Full-bodied character on Sesame Street." + Value : "Gigantic-oak-tree" +} +``` +> [!NOTE] +> You dont have to mannualy encode `Value` into base64, sdk will do it for you. + +Finally, to Upload your secret into Selectel Secrets Manager service, you have to call a `Create` method providing a context and a model from above. + +```go +sc, err := cl.Secrets.Create(ctx, mySecret) +// ... +``` + +Expanded flow with secrets can be found in[📄 secrets.go](./secrets/secrets.go) + +## Certificates +> To perform any operations with certificates you have to call a `Certificates` property on client. + +### Creating a Secret +Firstly, create a secret using pre-defiend `certs.CreateCertificateRequest` data structure. + +```go +cert := ` +-----BEGIN CERTIFICATE----- +... +` + +pk := ` +-----BEGIN PRIVATE KEY----- +... +` + +myCert := certs.CreateCertificateRequest{ + Name: "Rust-Programming-Language", + Pem: certs.Pem{ + Certificates: []string{cert}, + PrivateKey: pk, + }, +} +``` + +Finally, to Upload your certificate into Selectel Secrets Manager service, you have to call a `Create` method providing a context and a model from above. + +```go +createdCrt, errCR := cl.Certificates.Create(ctx, myCert) +// ... +``` +As a result, you've got a response with SDK's model certificate, that has been just created and stored in Secrets Manager service: +```go +// createdCrt +{ + Consumers:[] + DNSNames:[] + ID:9d0206bb-3a4c-42f7-a2dc-487b255e7a5c + IssuedBy:{ + Country:[RU] + Locality:[] + SerialNumber: + StreetAddress:[] + } + Name:Rust-Programming + PrivateKey:{Type:RSA} + Serial:2c4ba60c7a43107bd0d6c79907dc915fdb028285 + Validity:{ + BasicConstraints:true + NotAfter:2034-01-06T08:37:43Z + NotBefore:2024-01-09T08:37:43Z + } + Version:1 +} +``` + +Expanded flow with secrets can be found in[📄 certs.go](./certs/certs.go) diff --git a/examples/create-addconsumers-update-get-certs/README.md b/examples/create-addconsumers-update-get-certs/README.md new file mode 100644 index 0000000..7b667db --- /dev/null +++ b/examples/create-addconsumers-update-get-certs/README.md @@ -0,0 +1,176 @@ +# Certificates Usage + +When working with certificates, you have to import the folowing modules: +```go +package main + +import ( + "github.com/selectel/secretsmanager" + "github.com/selectel/secretsmanager/service/certs" +) +``` + +> +> +> Example +>
+> +>
+> Basic Flow with Certificate in Secrets Manger +> +> ```go +> createdCrt, _ := cl.Certificates.Create(ctx, myCertificate) +> /* +> { +> Consumers:[], +> DNSNames:[], +> ID:9d0206bb-3a4c-42f7-a2dc-487b255e7a5c, +> IssuedBy:{ +> Country:[RU], +> Locality:[], +> SerialNumber: , +> StreetAddress:[], +> }, +> Name:Rust-Programming, +> PrivateKey:{Type:RSA}, +> Serial:2c4ba60c7a43107bd0d6c79907dc915fdb028285, +> Validity:{ +> BasicConstraints:true, +> NotAfter:2034-01-06T08:37:43Z, +> NotBefore:2024-01-09T08:37:43Z, +> }, +> Version:1, +> } +> */ +> +> // We can get it from Secrets Manger, by calling Get method +> gotCrt, _ := cl.Certificates.Get(ctx, createdCrt.ID) +> +> // To Delete it, simply call Delete method +> err := cl.Certificates.Delete(ctx, createdCrt.ID) +> +> // Now we can check that ther's no Certificates in Secrets Manger by List method, that shows all Certificates in project +> certs, err := cl.Certificates.List(ctx) +> ``` +>
+ + +> +> +> Example +>
+> +>
+> Adding/Deleting consumers into/from recently created certificate +> +> ```go +> consumers := certs.AddConsumersRequest{ +> Consumers: []certs.AddConsumer{ +> {ID: "01XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", Region: "ru-1", Type: "octavia-listener"}, +> {ID: "01XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", Region: "kz-4", Type: "octavia-listener"}, +> }, +> } +> +> _ = cl.Certificates.AddConsumers(ctx, crtID, consumers) +> gotCrt, _ := cl.Certificates.Get(ctx, crtID) +> gotCrt{ +> Consumers:[ +> {ID:"01XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", Region:"ru-1", Type:"octavia-listener"}, +> {ID:"01XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", Region:"kz-4", Type:"octavia-listener"} +> ], +> DNSNames:[], +> ... +> } +> +> // If you wish to Delete consumers from the certificate +> _ = cl.Certificates.RemoveConsumers(ctx, crtID, consumers) +> ``` +>
+ + + +> +> +> Example +>
+> +>
+> Updating Certificates' Name +> +> ```go +> err := cl.Certificates.UpdateName(ctx, crtID, "Rust-Programming-Language") +> if err != nil { +> log.Fatal(err) +> } +> gotCrt, _ := cl.Certificates.Get(ctx, crtID) +> fmt.Println(gotCrt.Name) +> // > "Rust-Programming-Language" +> ``` +>
+ +> +> +> Example +>
+> +>
+> Updating Certificate' Version +> +> Consider you wish to Update Certificate without Creating New One, for example add fresher certs +> ```go +> // Fill Update Structure +> updVersion := certs.UpdateCertificateVersionRequest{ +> Pem: certs.Pem{ +> Certificates: []string{DummyCert}, +> PrivateKey: DummyPrivateKey, +> }, +> } +> +> // Make an UpdateVersion Request +> err := cl.Certificates.UpdateVersion(ctx, crtID, updVersion) +> if err != nil { +> log.Fatal(err) +> } +> +> // Check result +> gotCrt, _ := cl.Certificates.Get(ctx, crtID) +> fmt.Println(gotCrt.Version) +> // > 2 +> ``` +>
+ + +> +> +> Example +>
+> +>
+> Get Certificate' CA Chain / PK / PKCS12 +> +> +> ```go +> // Get a public certs for certificate. +> gotPubCrt, _ := cl.Certificates.GetPublicCerts(ctx, crtID) +> fmt.Printf("%+v\n", gotPubCrt) +> /* +> -----BEGIN CERTIFICATE----- +> ... +> -----END CERTIFICATE----- +> */ +> +> // Get a private key for certificate. +> gotPK, _ := cl.Certificates.GetPrivateKey(ctx, crtID) +> fmt.Printf("%+v\n", gotPK) +> /* +> -----BEGIN PRIVATE KEY----- +> ... +> -----END PRIVATE KEY----- +> */ +> +> // Get a everything related to this certificate in PKCS#12 bundle. +> gotPKCS12, _ := cl.Certificates.GetPKCS12Bundle(ctx, crtID) +> fmt.Printf("%+v\n", gotPKCS12) +> // [48 130 9 131 2 1 3 48 130 9 79 6 9 42 134 .... +> ``` +>
\ No newline at end of file diff --git a/examples/create-addconsumers-update-get-certs/main.go b/examples/create-addconsumers-update-get-certs/main.go new file mode 100644 index 0000000..0c905cf --- /dev/null +++ b/examples/create-addconsumers-update-get-certs/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "fmt" + "log" + + "github.com/selectel/secretsmanager" + "github.com/selectel/secretsmanager/service/certs" +) + +// DummyCert is a cert in PEM format. +const DummyCert = ` +-----BEGIN CERTIFICATE----- +peh6PHUaY/+beb4fwNtthbs1NvtVXFUVPlxJaPFW6A== +-----END CERTIFICATE----- +` + +// DummyPrivateKey is a private key for cert above in PEM format. +const DummyPrivateKey = ` +-----BEGIN PRIVATE KEY----- +Q5g85kFYklrYDOcltZ48JPs= +-----END PRIVATE KEY----- +` + +func main() { + // Setting up your project KeystoneToken. + tk := "gAAAAAB..." + + // Setting Up Client. + cl, err := secretsmanager.New( + secretsmanager.WithAuthOpts(&secretsmanager.AuthOpts{KeystoneToken: tk}), + ) + if err != nil { + log.Fatal(err) + } + + // Prepare an empty context, for future requests. + ctx := context.Background() + + // Building your certificate. + stepikCert := certs.CreateCertificateRequest{ + Name: "Rust-Programming", + Pem: certs.Pem{ + Certificates: []string{DummyCert}, + PrivateKey: DummyPrivateKey, + }, + } + + // Uploading it into Secret Manager. + createdCrt, errCR := cl.Certificates.Create(ctx, stepikCert) + if err != nil { + log.Fatal(errCR) + } + fmt.Printf("SC: %+v\n", createdCrt) + + // Store ID we, we also could use ID field from createdCrt createdCrt.ID. + crtID := "9d0206bb-3a4c-42f7-a2dc-487b255e7a5c" + + // Adding consumers into recently created certificate. + consumers := certs.AddConsumersRequest{ + Consumers: []certs.AddConsumer{ + {ID: "01XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", Region: "ru-1", Type: "octavia-listener"}, + {ID: "01XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", Region: "kz-4", Type: "octavia-listener"}, + }, + } + + // Making a request for add them. + errCons := cl.Certificates.AddConsumers(ctx, crtID, consumers) + if errCons != nil { + log.Fatal(errCons) + } + + // Check the results by getting updated certificate by its ID. + gotCrt, errGet := cl.Certificates.Get(ctx, crtID) + if errGet != nil { + log.Fatal(errGet) + } + fmt.Printf("SC: %+v\n", gotCrt) + + // If we want to change cert name we can do the same. + errUN := cl.Certificates.UpdateName(ctx, crtID, "Rust-Programming-Language") + if errUN != nil { + log.Fatal(errUN) + } + + gotCrt, _ = cl.Certificates.Get(ctx, crtID) + fmt.Printf("%+v\n", gotCrt) + + // In case we want to update certVersion, for example add fresher certs: + updVer := certs.UpdateCertificateVersionRequest{ + Pem: certs.Pem{ + Certificates: []string{DummyCert}, + PrivateKey: DummyPrivateKey, + }, + } + + errUV := cl.Certificates.UpdateVersion(ctx, crtID, updVer) + if errUV != nil { + log.Fatal(errUV) + } + + // As a result you see the same crt with + // Rust-Programming-Language name and same ID however its Version is now 2. + gotCrt, _ = cl.Certificates.Get(ctx, crtID) + fmt.Printf("%+v\n", gotCrt.Version) + + // Get a public certs for certificate. + gotPubCrt, _ := cl.Certificates.GetPublicCerts(ctx, crtID) + fmt.Printf("%+v\n", gotPubCrt) + + // Get a private key for certificate. + gotPK, _ := cl.Certificates.GetPrivateKey(ctx, crtID) + fmt.Printf("%+v\n", gotPK) + + // Get a everything related to this certificate in PKCS#12 bundle. + gotPKCS12, _ := cl.Certificates.GetPKCS12Bundle(ctx, crtID) + fmt.Printf("%+v\n", gotPKCS12) +} diff --git a/examples/create-list-update-delete-secrets/README.md b/examples/create-list-update-delete-secrets/README.md new file mode 100644 index 0000000..5de9d6a --- /dev/null +++ b/examples/create-list-update-delete-secrets/README.md @@ -0,0 +1,83 @@ +# Secrets Usage + +When working with secrets, you have to import the folowing modules: +```go +package main + +import ( + "github.com/selectel/secretsmanager" + "github.com/selectel/secretsmanager/service/secrets" +) +``` + +> +> +> Example +>
+> +> +> Basic Flow with Secrets in Secrets Manger +> +> ```go +> // To upload a Secret into in Secrets Manger firstly, +> // we have to make a structure that describes it +> /// using secrets.UserSecret data structure +> mySecret := secrets.UserSecret{ +> Key: "John-Cena", +> Description: "nothing happened in tiananmen square 1989", +> Value: "Zǎo shang hǎo zhōng guó!", +> } +> +> // And creating it in Secrets Manger, call Create method +> err := cl.Secrets.Create(ctx, mySecret) +> +> // If you wich to retrive it from Secrets Manger, call Get method +> secret, err := cl.Secrets.Get(ctx, key) +> // Or List all stored Secrets +> secrets, errAll := cl.Secrets.List(ctx) +> // {Keys:[{Metadata:{CreatedAt:2024-01-29T10:19:34Z Description:nothing happened in tiananmen square 1989} Name:John-Cena Type:Secret} +> // To Delete it from Secrets Manger +> err := cl.Secrets.Delete(ctx, "John-Cena") +> ``` + +> +> +> Example +>
+>
+> Update Secret' description. +> +> ```go +> // To update Secret' description we have to +> // use same data structure with +> // filled Key and updated Description properties. +> updJС := secrets.UserSecret{ +> Key: "John-Cena", +> Description: "Xiàn zài wǒ yǒu bing chilling", +> } +> +> err := cl.Secrets.Update(ctx, updJС) +> if err != nil { +> log.Fatal(err) +> } +> ``` +>
+ +> +> +> Example +>
+>
+> Getting no existing Secret. +> +> ```go +> // Getting no existing key. +> gotNotFound, errNF := cl.Secrets.Get(ctx, "Super-Idol") +> if errNF != nil { +> log.Fatal(errNF) +> } +> fmt.Printf("%+v\n", gotNotFound) +> // 2024/01/29 13:37:30 secretsmanager-go: error — INCORRECT_REQUEST: not a secret +> // exit status 1 +> ``` +>
\ No newline at end of file diff --git a/examples/create-list-update-delete-secrets/main.go b/examples/create-list-update-delete-secrets/main.go new file mode 100644 index 0000000..3cc74c8 --- /dev/null +++ b/examples/create-list-update-delete-secrets/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + "log" + + "github.com/selectel/secretsmanager" + "github.com/selectel/secretsmanager/service/secrets" +) + +func main() { + // Setting up your project KeystoneToken. + tk := "gAAAAAB..." + + // Setting Up Client. + cl, err := secretsmanager.New( + secretsmanager.WithAuthOpts(&secretsmanager.AuthOpts{KeystoneToken: tk}), + ) + if err != nil { + log.Fatal(err) + } + + // Prepare an empty context, for future requests. + ctx := context.Background() + + // Creating your secret + mySecret := secrets.UserSecret{ + Key: "John-Cena", + Description: "nothing happened in tiananmen square 1989", + Value: "Zǎo shang hǎo zhōng guó!", + } + + // Uploading it into Secret Manager. + errCr := cl.Secrets.Create(ctx, mySecret) + if errCr != nil { + log.Fatal(errCr) + } + + // Recieve all secrets from Secret Manager. + gotAll, errAll := cl.Secrets.List(ctx) + if errAll != nil { + log.Fatal(errAll) + } + fmt.Printf("%+v\n", gotAll) + + // Update Secret Description. + updJС := secrets.UserSecret{ + Key: "John-Cena", + Description: "Xiàn zài wǒ yǒu bing chilling", + } + errUPD := cl.Secrets.Update(ctx, updJС) + if errUPD != nil { + log.Fatal(errUPD) + } + + gotAll, errAll = cl.Secrets.List(ctx) + if errAll != nil { + log.Fatal(errAll) + } + fmt.Printf("%+v\n", gotAll) + + errDl := cl.Secrets.Delete(ctx, "John-Cena") + if errDl != nil { + log.Fatal(errDl) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bc6ee28 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/selectel/secretsmanager + +go 1.21 + +require ( + github.com/h2non/gock v1.2.0 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8d23886 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..b653ae5 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,33 @@ +package auth + +import "github.com/selectel/secretsmanager/secretsmanagererrors" + +// Type — provides a general behaviour for all availbale Auth types; +// it is a bridge from AuthOpts, provided by user +// for retrieving KeystoneToken from GetKeystoneToken(). +// for example if we consider to add BasicAuth (login, pass) +// it should have a realization of GetKeystoneToken(). +type Type interface { + GetKeystoneToken() string +} + +func NewKeystoneTokenAuth(kst string) (Type, error) { + if len(kst) == 0 { + return nil, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrClientNoAuthOpts, + Desc: "provided KeystoneToken is empty", + } + } + + return &keystoneTokenAuth{kst: kst}, nil +} + +// KeystoneTokenAuth represents Keystone token authentication method. +// It conforms to Type interface. +type keystoneTokenAuth struct { + kst string +} + +func (ksa *keystoneTokenAuth) GetKeystoneToken() string { + return ksa.kst +} diff --git a/internal/httpclient/httpclient.go b/internal/httpclient/httpclient.go new file mode 100644 index 0000000..6e3b9b2 --- /dev/null +++ b/internal/httpclient/httpclient.go @@ -0,0 +1,155 @@ +package httpclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/selectel/secretsmanager/internal/auth" + "github.com/selectel/secretsmanager/secretsmanagererrors" +) + +const ( + // appName represents an application name. + appName = "knox-go" + + // appVersion represents an application version. + appVersion = "0.1.0" + + // userAgent contains a basic User-Agent that will be used in all requests. + userAgent = appName + "/" + appVersion +) + +const ( // defaultHTTPTimeout represents the default timeout (in seconds) for HTTP requests. + defaultHTTPTimeout = 120 + + // defaultMaxIdleConns represents the maximum number of idle (keep-alive) connections. + defaultMaxIdleConns = 100 + + // defaultIdleConnTimeout represents the maximum amount of time an idle (keep-alive) connection will remain + // idle before closing itself. + defaultIdleConnTimeout = 100 + + // defaultTLSHandshakeTimeout represents the default timeout (in seconds) for TLS handshake. + defaultTLSHandshakeTimeout = 60 + + // defaultExpectContinueTimeout represents the default amount of time to wait for a server's first + // response headers. + defaultExpectContinueTimeout = 1 +) + +type HTTPClient struct { + *http.Client + Auth auth.Type +} + +func New(auth auth.Type, httpClient *http.Client) *HTTPClient { + if httpClient == nil { + httpClient = newHTTPClient() + } + + return &HTTPClient{ + Client: httpClient, + Auth: auth, + } +} + +// DoRequest — is a helper method, to reduce repeated code. +func (cl *HTTPClient) DoRequest(ctx context.Context, method, url string, body io.Reader) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrInternalAppError, + Desc: err.Error(), + } + } + + req.Header.Set("X-Auth-Token", cl.Auth.GetKeystoneToken()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := cl.Do(req) + if err != nil { + return nil, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrInternalAppError, + Desc: err.Error(), + } + } + defer resp.Body.Close() + + err = hasBackendError(resp) + if err != nil { + return nil, err + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return []byte{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotReadBody, + Desc: err.Error(), + } + } + return respBody, nil +} + +// hasBackendError is a helper function to returning an error from backend +// if StatusCode is either StatusUnauthorized or >= 400. +func hasBackendError(resp *http.Response) error { + if resp.StatusCode == http.StatusUnauthorized { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrAuthTokenUnathorized, + Desc: secretsmanagererrors.AuthErrorCode, + } + } + + if resp.StatusCode >= 400 { + errBodyText, err := io.ReadAll(resp.Body) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotReadBody, + Desc: err.Error(), + } + } + + var er secretsmanagererrors.ErrResponse + err = json.Unmarshal(errBodyText, &er) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrInternalAppError, + Desc: err.Error(), + } + } + + if e := secretsmanagererrors.GetError(er.StatusText); e != nil { + return secretsmanagererrors.Error{Err: e, Desc: er.ErrorText} + } + + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrUnknown, + Desc: fmt.Sprintf("%s -- %s", er.StatusText, er.StatusText), + } + } + return nil +} + +// newHTTPClient returns a reference to an initialized and configured HTTP client. +func newHTTPClient() *http.Client { + return &http.Client{ + Timeout: defaultHTTPTimeout * time.Second, + Transport: newHTTPTransport(), + } +} + +// newHTTPTransport returns a reference to an initialized and configured HTTP transport. +func newHTTPTransport() *http.Transport { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: defaultMaxIdleConns, + IdleConnTimeout: defaultIdleConnTimeout * time.Second, + TLSHandshakeTimeout: defaultTLSHandshakeTimeout * time.Second, + ExpectContinueTimeout: defaultExpectContinueTimeout * time.Second, + } +} diff --git a/secretsmanager.go b/secretsmanager.go new file mode 100644 index 0000000..3c9b268 --- /dev/null +++ b/secretsmanager.go @@ -0,0 +1,124 @@ +package secretsmanager + +import ( + "net/http" + + "github.com/selectel/secretsmanager/internal/auth" + "github.com/selectel/secretsmanager/internal/httpclient" + "github.com/selectel/secretsmanager/secretsmanagererrors" + "github.com/selectel/secretsmanager/service/certs" + "github.com/selectel/secretsmanager/service/secrets" +) + +const ( + // URL for working with secrets. + defaultAPIURLSecrets = "https://cloud.api.selcloud.ru/secrets-manager/v1/" //nolint:gosec + + // URL for working with certificates. + defaultAPIURLUserCertificates = "https://cloud.api.selcloud.ru/certificate-manager/v1/" +) + +// Client — implements operations to work with the Secrets Manager API using the Keystone Token. +type Client struct { + Secrets *secrets.Service + Certificates *certs.Service + cfg *config +} + +type ClientOption func(*Client) + +// AuthOpts is a helper structure used during client initialization. +// Depending on the data passed in the structure +// (like Keystone Token or newer that will be added in future) +// the required authentication structure will be selected. +type AuthOpts struct { + KeystoneToken string +} + +// WithAuthOpts is a functional parameter for SecretsManagerClient, used to set on of implementations of AuthType. +func WithAuthOpts(authOpts *AuthOpts) ClientOption { + return func(c *Client) { + c.cfg.authOpts = authOpts + } +} + +func WithCustomURLSecrets(url string) ClientOption { + return func(c *Client) { + c.cfg.APIURLSecrets = url + } +} + +func WithCustomURLCertificates(url string) ClientOption { + return func(c *Client) { + c.cfg.APIURLUserCertificates = url + } +} + +func WithCustomHTTPClient(customHTTPClient *http.Client) ClientOption { + return func(c *Client) { + c.cfg.customHTTPClient = customHTTPClient + } +} + +type config struct { + APIURLSecrets string + APIURLUserCertificates string + + // AuthOpts contains data to authenticate against Selectel Secrets Manager API. + authOpts *AuthOpts + customHTTPClient *http.Client +} + +func defaultConfig() *config { + return &config{ + APIURLSecrets: defaultAPIURLSecrets, + APIURLUserCertificates: defaultAPIURLUserCertificates, + } +} + +func New(options ...ClientOption) (*Client, error) { + cl := &Client{ + cfg: defaultConfig(), + } + + for _, option := range options { + option(cl) + } + + auth, err := newAuth(cl.cfg.authOpts) + if err != nil { + return nil, err + } + + httpClient := httpclient.New(auth, cl.cfg.customHTTPClient) + + cl.Secrets = secrets.New(cl.cfg.APIURLSecrets, httpClient) + cl.Certificates = certs.New(cl.cfg.APIURLUserCertificates, httpClient) + + return cl, nil +} + +// newAuth — is a helper func, that checks if any of AuthOpts are passed into client +// and depending on given smcl.authOpts, decide which independent supported auth.Type to set. +func newAuth(authOpts *AuthOpts) (auth.Type, error) { + if authOpts == nil { + return nil, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrClientNoAuthOpts, + Desc: "provided AuthOpts is empty", + } + } + + var authType auth.Type + if len(authOpts.KeystoneToken) > 0 { + ksta, err := auth.NewKeystoneTokenAuth(authOpts.KeystoneToken) + if err != nil { + return nil, secretsmanagererrors.Error{ + Err: err, + Desc: err.Error(), + } + } + authType = ksta + } + + return authType, nil +} diff --git a/secretsmanagererrors/secretsmanagererrors.go b/secretsmanagererrors/secretsmanagererrors.go new file mode 100644 index 0000000..6104e7e --- /dev/null +++ b/secretsmanagererrors/secretsmanagererrors.go @@ -0,0 +1,113 @@ +package secretsmanagererrors + +import ( + "errors" + "fmt" +) + +const ( + // X-Auth-Token error code. + AuthErrorCode = "X-Auth-Token is unauthorized" +) + +var ( + // Errors for Auth. + ErrClientNoAuthOpts = errors.New("CLIENT_NO_AUTH_METHOD") + ErrAuthTokenUnathorized = errors.New("AUTH_TOKEN_UNAUTHORIZED") + + // Errors for Secrets Service. + ErrEmptySecretName = errors.New("EMPTY_SECRET_NAME") + ErrEmptySecretValue = errors.New("EMPTY_SECRET_DESC") + ErrCannotMarshalSecretBody = errors.New("CANNOT_MARSHAL_SECRET") + + // Errors for Certificates Service. + ErrEmptyCertificateID = errors.New("EMPTY_CERT_ID") + ErrEmptyCertificateName = errors.New("EMPTY_CERT_NAME") + ErrEmptyPEMCertificate = errors.New("EMPTY_CERT_PEM_CERT") + ErrEmptyPEMPrivateKey = errors.New("EMPTY_CERT_PEM_PK") + ErrCannotMarshalCertificateBody = errors.New("CANNOT_MARSHAL_CERT") + + // SDK Common Errors. + ErrInternalAppError = errors.New("INTERNAL_APP_ERROR") + ErrCannotDoRequest = errors.New("CANNOT_DO_REQUEST") + ErrCannotFormatEndpoint = errors.New("CANNOT_FORMAT_ENDPOINT") + ErrCannotReadBody = errors.New("CANNOT_READ_RESPONSE_BODY") + ErrCannotUnmarshalBody = errors.New("CANNOT_UNMARSHAL_JSON") + + // Errors from Backend. + ErrBadRequestStatusText = errors.New("INCORRECT_REQUEST") + ErrInternalErrorStatusText = errors.New("INTERNAL_SERVER_ERROR") + ErrUnauthorizedStatusText = errors.New("UNAUTHORIZED") + ErrForbiddenStatusText = errors.New("FORBIDDEN") + ErrOverQuotasStatusText = errors.New("OVER_QUOTAS") + ErrNotFoundStatusText = errors.New("NOT_FOUND") + ErrConflictStatusText = errors.New("CONFLICT") + ErrTooManyRequestsText = errors.New("TOO_MANY_REQUESTS") + ErrMethodNotAllowed = errors.New("NOT_ALLOWED") + + ErrUnknown = errors.New("UNKNOWN_ERROR") + + //nolint:gochecknoglobals + stringToError = map[string]error{ + ErrClientNoAuthOpts.Error(): ErrClientNoAuthOpts, + ErrAuthTokenUnathorized.Error(): ErrAuthTokenUnathorized, + + ErrEmptySecretName.Error(): ErrEmptySecretName, + ErrEmptySecretValue.Error(): ErrEmptySecretValue, + ErrCannotMarshalSecretBody.Error(): ErrCannotMarshalSecretBody, + + ErrEmptyCertificateID.Error(): ErrEmptyCertificateID, + ErrEmptyCertificateName.Error(): ErrEmptyCertificateName, + ErrEmptyPEMCertificate.Error(): ErrEmptyPEMCertificate, + ErrEmptyPEMPrivateKey.Error(): ErrEmptyPEMPrivateKey, + ErrCannotMarshalCertificateBody.Error(): ErrCannotMarshalCertificateBody, + + ErrInternalAppError.Error(): ErrInternalAppError, + ErrCannotDoRequest.Error(): ErrCannotDoRequest, + ErrCannotFormatEndpoint.Error(): ErrCannotFormatEndpoint, + ErrCannotReadBody.Error(): ErrCannotReadBody, + ErrCannotUnmarshalBody.Error(): ErrCannotUnmarshalBody, + + ErrBadRequestStatusText.Error(): ErrBadRequestStatusText, + ErrInternalErrorStatusText.Error(): ErrInternalErrorStatusText, + ErrUnauthorizedStatusText.Error(): ErrInternalErrorStatusText, + ErrForbiddenStatusText.Error(): ErrForbiddenStatusText, + ErrOverQuotasStatusText.Error(): ErrOverQuotasStatusText, + ErrNotFoundStatusText.Error(): ErrNotFoundStatusText, + ErrConflictStatusText.Error(): ErrConflictStatusText, + ErrTooManyRequestsText.Error(): ErrTooManyRequestsText, + ErrMethodNotAllowed.Error(): ErrMethodNotAllowed, + + ErrUnknown.Error(): ErrUnknown, + } +) + +// ErrResponse — represents error returned from Backend responsible +// for Secrets & Certs services. +type ErrResponse struct { + HTTPStatusCode int `json:"-"` + ErrorText string `json:"error_text,omitempty"` + StatusText string `json:"status_text"` +} + +func GetError(errorString string) error { + err, ok := stringToError[errorString] + if !ok { + return nil + } + return err +} + +// Error — an error returned by the Secrets Manager SDK to user. +type Error struct { + Err error + Desc string +} + +func (e Error) Error() string { + return fmt.Sprintf("secretsmanager-go: error — %s: %s", e.Err.Error(), e.Desc) +} + +func (e Error) Is(err error) bool { + return errors.Is(e.Err, err) +} diff --git a/service/certs/certs.go b/service/certs/certs.go new file mode 100644 index 0000000..c864436 --- /dev/null +++ b/service/certs/certs.go @@ -0,0 +1,402 @@ +package certs + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" + + "github.com/selectel/secretsmanager/internal/httpclient" + "github.com/selectel/secretsmanager/secretsmanagererrors" +) + +// Service implements Secrets Manager that is responsible for handling certificates operations. +type Service struct { + apiURLUserCertificates string + httpClient *httpclient.HTTPClient +} + +func New(url string, client *httpclient.HTTPClient) *Service { + return &Service{ + apiURLUserCertificates: url, + httpClient: client, + } +} + +func (s Service) Delete(ctx context.Context, id string) error { + if len(id) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + _, err = s.httpClient.DoRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return nil +} + +func (s Service) Get(ctx context.Context, id string) (Certificate, error) { + if len(id) == 0 { + return Certificate{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id) + if err != nil { + return Certificate{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + respBody, err := s.httpClient.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return Certificate{}, err //nolint:wrapcheck // DoRequest already wraps the error. + } + + var crt Certificate + err = json.Unmarshal(respBody, &crt) + if err != nil { + return Certificate{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotUnmarshalBody, + Desc: err.Error(), + } + } + return crt, nil +} + +func (s Service) UpdateVersion(ctx context.Context, id string, pem UpdateCertificateVersionRequest) error { + if len(id) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + err := validatePEM(pem.Pem) + if err != nil { + return err + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + marshalled, err := json.Marshal(pem) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotMarshalCertificateBody, + Desc: err.Error(), + } + } + + reqBody := bytes.NewReader(marshalled) + _, err = s.httpClient.DoRequest(ctx, http.MethodPost, endpoint, reqBody) + if err != nil { + return err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return nil +} + +func (s Service) UpdateName(ctx context.Context, id, name string) error { + if len(id) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + if len(name) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateName, + Desc: "trying to update a certificate with empty name", + } + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + nn := UpdateCertificateNameRequest{ + Name: name, + } + + marshalled, err := json.Marshal(nn) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotMarshalCertificateBody, + Desc: err.Error(), + } + } + + reqBody := bytes.NewReader(marshalled) + + _, err = s.httpClient.DoRequest(ctx, http.MethodPut, endpoint, reqBody) + if err != nil { + return err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return nil +} + +func (s Service) GetPublicCerts(ctx context.Context, id string) (string, error) { + if len(id) == 0 { + return "", secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id, "ca_chain") + if err != nil { + return "", secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + respBody, err := s.httpClient.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return string(respBody), nil +} + +func (s Service) RemoveConsumers(ctx context.Context, id string, consumers RemoveConsumersRequest) error { + if len(id) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id, "consumers") + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + marshalled, err := json.Marshal(consumers) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotMarshalCertificateBody, + Desc: err.Error(), + } + } + + reqBody := bytes.NewReader(marshalled) + + _, err = s.httpClient.DoRequest(ctx, http.MethodDelete, endpoint, reqBody) + if err != nil { + return err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return nil +} + +func (s Service) AddConsumers(ctx context.Context, id string, consumers AddConsumersRequest) error { + if len(id) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id, "consumers") + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + marshalled, err := json.Marshal(consumers) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotMarshalCertificateBody, + Desc: err.Error(), + } + } + + reqBody := bytes.NewReader(marshalled) + + _, err = s.httpClient.DoRequest(ctx, http.MethodPut, endpoint, reqBody) + if err != nil { + return err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return nil +} + +func (s Service) GetPKCS12Bundle(ctx context.Context, id string) ([]byte, error) { + if len(id) == 0 { + return []byte{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id, "p12") + if err != nil { + return []byte{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + respBody, err := s.httpClient.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return []byte{}, err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return respBody, nil +} + +func (s Service) GetPrivateKey(ctx context.Context, id string) (string, error) { + if len(id) == 0 { + return "", secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateID, + Desc: "empty certificate id", + } + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "cert", id, "private_key") + if err != nil { + return "", secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + respBody, err := s.httpClient.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return string(respBody), nil +} + +func (s Service) List(ctx context.Context) (GetCertificatesResponse, error) { + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "certs") + if err != nil { + return GetCertificatesResponse{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + respBody, err := s.httpClient.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return GetCertificatesResponse{}, err //nolint:wrapcheck // DoRequest already wraps the error. + } + + var crts GetCertificatesResponse + err = json.Unmarshal(respBody, &crts) + if err != nil { + return GetCertificatesResponse{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotUnmarshalBody, + Desc: err.Error(), + } + } + return crts, nil +} + +func (s Service) Create(ctx context.Context, ucr CreateCertificateRequest) (Certificate, error) { + err := validateCertificate(ucr) + if err != nil { + return Certificate{}, err + } + + endpoint, err := url.JoinPath(s.apiURLUserCertificates, "certs") + if err != nil { + return Certificate{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + marshalled, err := json.Marshal(ucr) + if err != nil { + return Certificate{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotMarshalCertificateBody, + Desc: err.Error(), + } + } + reqBody := bytes.NewReader(marshalled) + + respBody, err := s.httpClient.DoRequest(ctx, http.MethodPost, endpoint, reqBody) + if err != nil { + return Certificate{}, err //nolint:wrapcheck // DoRequest already wraps the error. + } + + var crt Certificate + err = json.Unmarshal(respBody, &crt) + if err != nil { + return Certificate{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotUnmarshalBody, + Desc: err.Error(), + } + } + return crt, nil +} + +func validateCertificate(ucr CreateCertificateRequest) error { + if len(ucr.Name) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyCertificateName, + Desc: "trying to create a certificate with empty name", + } + } + + err := validatePEM(ucr.Pem) + if err != nil { + return err + } + + return nil +} + +func validatePEM(pem Pem) error { + if len(pem.Certificates) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyPEMCertificate, + Desc: "trying to create a certificate with empty PEM certificate(s)", + } + } + + if len(pem.PrivateKey) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptyPEMPrivateKey, + Desc: "trying to create a certificate with empty PEM private key", + } + } + + return nil +} diff --git a/service/certs/certs_test.go b/service/certs/certs_test.go new file mode 100644 index 0000000..abe1bd9 --- /dev/null +++ b/service/certs/certs_test.go @@ -0,0 +1,532 @@ +package certs_test + +import ( + "bytes" + "context" + "net/http" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/suite" + + "github.com/selectel/secretsmanager/internal/auth" + "github.com/selectel/secretsmanager/internal/httpclient" + "github.com/selectel/secretsmanager/secretsmanagererrors" + "github.com/selectel/secretsmanager/service/certs" +) + +const ( + testDummyEndpoint = "http://example.com/" + testDummyID = "dummy-cert" +) + +const testDummyPEMCert = ` +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIULEumDHpDEHvQ1seZB9yRX9sCgoUwDQYJKoZIhvcNAQEL +BQAwNTELMAkGA1UEBhMCUlUxEzARBgNVBAgMClNvbWUtU3RhdGUxETAPBgNVBAoM +CFNlbGVjdGVsMB4XDTI0MDEwOTA4Mzc0M1oXDTM0MDEwNjA4Mzc0M1owNTELMAkG +A1UEBhMCUlUxEzARBgNVBAgMClNvbWUtU3RhdGUxETAPBgNVBAoMCFNlbGVjdGVs +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArpN0hZ9AHwKMaPUQP4Z0 +4abCDxpKO2bJsdw1PxHOpkdw23dS2bH+wHWPspin5rK9i/wqg1fqKYikbukfBkdG +WjHEpgHzjHuDER0dJ4iU8kD50kg64PaUHJ1fw2QfxmH7l/DNY+9poViqwJGpGCWp +MsRw1OFQhLZKNhkNIgFugFesaBYJHdXqf7JAx+2y7AZBFniFl1PPs7Xtjn9j7m8i +2WYc+1SgU8fI4uDhH+PxjIdNrwK5bC2xg68EXI0vSkyh6Ir74Va4FWW9tlsXpw3W +d4NOorzmkDeSknbruhBHmbucmoh2oTcojziB2qRrlU8JcfjETJglZklLyzbXlk/N +WwIDAQABo1MwUTAdBgNVHQ4EFgQU8RFMuHQ+rh0RYWYEmYozljJMrjQwHwYDVR0j +BBgwFoAU8RFMuHQ+rh0RYWYEmYozljJMrjQwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEATn/WaWDnmUnYD4enM4U0HCQE6k+TodcPt3oMw+K0tfMP +AKJkD+jJvqanH6ajZNWTgEmMoiEc6bv4D4/wsiSYSIjEQDOwTkVa1wYEXeXzYc5e +GsnXXOusgR9+F5GFV8p8qDt4hozNtEycLbfN3gJURPqEJcwn7aJIVPeoWEOI5wO9 +banExY6twbb91OAdW8aTkD3qicsfRpDiYHVDKqgvEJpGCTWONeUnfcKy7ni4ahov +PD3JcGkk8I+tbkM9gvxgKlXlGIHL3puskkusc5SxUSgDADLQwts5htT7TpOny7Dy +peh6PHUaY/+beb4fwNtthbs1NvtVXFUVPlxJaPFW6A== +-----END CERTIFICATE----- +` + +const testDummyPEMPrivateKey = ` +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCuk3SFn0AfAoxo +9RA/hnThpsIPGko7Zsmx3DU/Ec6mR3Dbd1LZsf7AdY+ymKfmsr2L/CqDV+opiKRu +6R8GR0ZaMcSmAfOMe4MRHR0niJTyQPnSSDrg9pQcnV/DZB/GYfuX8M1j72mhWKrA +kakYJakyxHDU4VCEtko2GQ0iAW6AV6xoFgkd1ep/skDH7bLsBkEWeIWXU8+zte2O +f2PubyLZZhz7VKBTx8ji4OEf4/GMh02vArlsLbGDrwRcjS9KTKHoivvhVrgVZb22 +WxenDdZ3g06ivOaQN5KSduu6EEeZu5yaiHahNyiPOIHapGuVTwlx+MRMmCVmSUvL +NteWT81bAgMBAAECggEAQCtITeN7BMsBhITr24XXSahrtXRy68G9CqkIU23+uSUS +aUFDjWx9WQ39a2bsdIKn5KAkmlHC61BkLLZ45mxlgjq/70tRVAaEZ1J9yG3OXfuf +OHm/VricOaZpMF+JxHh4q+FiBcVXXOzEGvOPpaYWOuh1FvLZD2cYASmVJ7ZCAV9d +AB7YXmOQtnNtbe7BKa7aPHuK7zeyflpbCmaUBLJ7GR6UYV/xjJjp5clKHP0kt0OB +E1gCveddwAVV7su/Oj1DEKI1w26fSBvmdVRf+pH4NddB1DYv2dr4scC/a2kTqZdn +U+CUwG1Zd/LtxdCHQKDn36tIXYTKuX51WZ4jq4RcJQKBgQDHtcVbF6U9RTL8M3zu +tMwyMTbbG6/2myupySYmOHzmV7XjqXCrbbMlSHQqBihE41XJo2ot8PETR6Q5mpWb +BKbAYfUZVf93cbNIj29qESqp5adlvrwW3cbyDlMa81ehk+kdkPwUvlR2fvZP7PZr +Om0eN6pFaK8ffCh6abbputwOlQKBgQDfyB4/0b8VTITkD6+DRkpbCOF0d+HJFJPr +p3K7Gf06FSL5gqTT2SXdlQufVIee/X7QMefKLS90c0JjSpzqFIkFH0GB4nOtc9jQ +sN3cEZjncWIVu1P493hBtx5Qb+oUVGBpGaDHk4hJgvUPx/t+NvNn1u/VwKHTHp5V +4h0RbJygLwKBgAEt5ptyGUyyUunAWBWExcvqFHvYvwJCylA3Wt1Q6hPmIrHUd1Db +1fn7Yow4+xXlDcWiDGd3C8VkX+jjK8z9iwqJyYu7wUVwS3G7PxouPcVBEOr95Fhy +ONGHGiCHnVXb7L169LIeqZsFhujT6mSZtLk/9OZyBs61yftnEmhw7Qm9AoGBAMzG +sD+QLP5NhjG31NEYykPhrYXJifhadz2mfguOrbWvz9Bo53HgfJD2qasETBKGP7w+ +XrAYhxtVuYNorIxbfEMOpgA3+8jWgKn/nxWZmMT5cVsXj7D8q7Pe4MOUlaxCxfKG +/CSE8arrRltJke6eVEBKZC/C1ZJ+qz9F6XmfXPgLAoGAQ6MZASJ2qlYMA9xqmg/r +/rMdjYdA9PBW3X0bcAa753ZltcZeV9Afrp+Mso8W5/c4fhUJ3mMgX1vGWnWcjnLP +O8MUlX0Jx5czcb29DPSAdXjQ/pKXFyaIgjELxgb7APR0S7uQ9l7Cnm0S1Bd+ve7p +Q5g85kFYklrYDOcltZ48JPs= +-----END PRIVATE KEY----- +` + +var testP12 = []byte{4, 2, 0, 6, 9} + +var testCert certs.Certificate = certs.Certificate{ + Consumers: []certs.Consumer{ + {ID: "0XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", Region: "ru-1", Type: "octavia-listener"}, + {ID: "1XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", Region: "kz-228", Type: "zeliboba-enjoyer"}, + }, + DNSNames: []string{"fishing.com"}, + ID: "9ddc1899-2a08-4bdb-9a74-4f88371d3533", + IssuedBy: certs.IssuedBy{ + Country: []string{"RU"}, + Locality: []string{"string"}, + SerialNumber: "string", + StreetAddress: []string{"string"}, + }, + Name: "Zeliboba", + PrivateKey: certs.PrivateKey{Type: "RSA"}, + Serial: "2c4ba60c7a43107bd0d6c79907dc915fdb028285", + Validity: certs.Validity{ + BasicConstraints: true, + NotBefore: "2024-01-09T08:37:43Z", + NotAfter: "2034-01-06T08:37:43Z", + }, + Version: 228, +} + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context. +type CertsSuite struct { + suite.Suite + service *certs.Service +} + +// Executes before each test case +// Make sure that same HTTPClient is set in both +// service & service mock before each test. +func (suite *CertsSuite) SetupTest() { + auth, err := auth.NewKeystoneTokenAuth("dummy") + suite.Require().NoError(err) + + httpClient := &http.Client{Timeout: 10 * time.Second} + suite.service = certs.New( + testDummyEndpoint, + httpclient.New(auth, httpClient), + ) + // http client, on the basis of which + // we will perform mocks during gock initialization. + gock.InterceptClient(httpClient) +} + +// Executes after each test case. +func (suite *CertsSuite) TearDownTest() { + // Verify that we don't have pending mocks + suite.Require().True(gock.IsDone()) + // Flush pending mocks after test execution. + gock.Off() + + suite.service = nil +} + +// TestSuiteCerts runs all suite tests. +func TestSuiteCerts(t *testing.T) { + suite.Run(t, new(CertsSuite)) +} + +func (suite *CertsSuite) TestList() { + dummyCerts := certs.GetCertificatesResponse{ + certs.Certificate{ + Consumers: []certs.Consumer{ + { + ID: "0XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + Region: "ru-1", + Type: "octavia-listener", + }, + { + ID: "1XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + Region: "ru-1", + Type: "octavia-listener", + }, + }, + DNSNames: []string{"fishing.com"}, + ID: "9ddc1899-2a08-4bdb-9a74-4f88371d3533", + IssuedBy: certs.IssuedBy{ + Country: []string{"RU"}, + Locality: []string{"string"}, + SerialNumber: "string", + StreetAddress: []string{"string"}, + }, + Name: "Zeliboba", + PrivateKey: certs.PrivateKey{Type: "RSA"}, + Serial: "2c4ba60c7a43107bd0d6c79907dc915fdb028285", + Validity: certs.Validity{ + BasicConstraints: true, + NotBefore: "2024-01-09T08:37:43Z", + NotAfter: "2034-01-06T08:37:43Z", + }, + Version: 228, + }, + } + + gock.New(testDummyEndpoint). + Get("/certs"). + Reply(http.StatusOK). + File("./fixtures/certs-response-data.json") + + ctx := context.Background() + res, err := suite.service.List(ctx) + suite.Require().NoError(err) + + suite.Equal(dummyCerts, res) +} + +func (suite *CertsSuite) TestGet() { + tests := map[string]struct { + certID string + expCert certs.Certificate + code int + expErr error + }{ + "Valid ID": { + testDummyID, + testCert, + http.StatusOK, + nil, + }, + "Empty ID": { + "", + certs.Certificate{}, + http.StatusBadRequest, + secretsmanagererrors.ErrEmptyCertificateID, + }, + } + + gock.New(testDummyEndpoint). + Get(testDummyID). + Reply(http.StatusOK). + File("./fixtures/cert-response-data.json") + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + got, err := suite.service.Get(ctx, test.certID) + + suite.Require().ErrorIs(err, test.expErr) + suite.Equal(test.expCert, got) + }) + } +} + +func (suite *CertsSuite) TestDelete() { + tests := map[string]struct { + certID string + code int + expErr error + }{ + "Valid ID": {testDummyID, http.StatusOK, nil}, + "Empty ID": {"", http.StatusBadRequest, secretsmanagererrors.ErrEmptyCertificateID}, + } + + gock.New(testDummyEndpoint). + Delete(testDummyID). + Reply(http.StatusOK) + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + err := suite.service.Delete(ctx, test.certID) + + suite.Require().ErrorIs(err, test.expErr) + }) + } +} + +func (suite *CertsSuite) TestCreate() { + tests := map[string]struct { + req certs.CreateCertificateRequest + expResp certs.Certificate + code int + expErr error + }{ + "Empty Cert Name": { + certs.CreateCertificateRequest{ + Name: "", + Pem: certs.Pem{}, + }, + certs.Certificate{}, + http.StatusBadRequest, + secretsmanagererrors.ErrEmptyCertificateName, + }, + "Valid Cert": { + certs.CreateCertificateRequest{ + Name: "Zeliboba", + Pem: certs.Pem{ + Certificates: []string{testDummyPEMCert}, + PrivateKey: testDummyPEMPrivateKey, + }, + }, + testCert, + http.StatusCreated, + nil, + }, + "Empty PEM Cert": { + certs.CreateCertificateRequest{ + Name: "NotEmptyName", + Pem: certs.Pem{}, + }, + certs.Certificate{}, + http.StatusBadRequest, + secretsmanagererrors.ErrEmptyPEMCertificate, + }, + "Empty PEM Private Key": { + certs.CreateCertificateRequest{ + Name: "NotEmptyName", + Pem: certs.Pem{Certificates: []string{testDummyPEMCert}}, + }, + certs.Certificate{}, + http.StatusBadRequest, + secretsmanagererrors.ErrEmptyPEMPrivateKey, + }, + } + + gock.New(testDummyEndpoint). + Post("certs"). + Reply(http.StatusOK). + File("./fixtures/cert-response-data.json") + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + got, err := suite.service.Create(ctx, test.req) + + suite.Require().ErrorIs(err, test.expErr) + suite.Equal(test.expResp, got) + }) + } +} + +func (suite *CertsSuite) TestUpdateVersion() { + tests := map[string]struct { + certID string + ucr certs.UpdateCertificateVersionRequest + code int + expErr error + }{ + "Valid Update": { + testDummyID, + certs.UpdateCertificateVersionRequest{ + Pem: certs.Pem{ + Certificates: []string{testDummyPEMCert}, + PrivateKey: testDummyPEMPrivateKey, + }, + }, + http.StatusOK, + nil, + }, + "Empty ID": { + "", + certs.UpdateCertificateVersionRequest{ + Pem: certs.Pem{ + Certificates: []string{testDummyPEMCert}, + PrivateKey: testDummyPEMPrivateKey, + }, + }, + http.StatusBadRequest, + secretsmanagererrors.ErrEmptyCertificateID, + }, + "Empty Certificates": { + testDummyID, + certs.UpdateCertificateVersionRequest{ + Pem: certs.Pem{ + Certificates: []string{}, + PrivateKey: testDummyPEMPrivateKey, + }, + }, + http.StatusBadRequest, + secretsmanagererrors.ErrEmptyPEMCertificate, + }, + "Empty PrivateKey": { + testDummyID, + certs.UpdateCertificateVersionRequest{ + Pem: certs.Pem{ + Certificates: []string{testDummyPEMCert}, + PrivateKey: "", + }, + }, + http.StatusBadRequest, + secretsmanagererrors.ErrEmptyPEMPrivateKey, + }, + } + + gock.New(testDummyEndpoint). + Post("cert/" + testDummyID) //nolint:goconst + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + err := suite.service.UpdateVersion(ctx, test.certID, test.ucr) + + suite.Require().ErrorIs(err, test.expErr) + }) + } +} + +func (suite *CertsSuite) TestUpdateName() { + tests := map[string]struct { + certID string + name string + code int + expErr error + }{ + "Valid name": {testDummyID, "Zeliboba", http.StatusNoContent, nil}, + "Empty name": {testDummyID, "", http.StatusInternalServerError, secretsmanagererrors.ErrEmptyCertificateName}, + "Empty ID": {"", "Zeliboba", http.StatusInternalServerError, secretsmanagererrors.ErrEmptyCertificateID}, + } + + gock.New(testDummyEndpoint). + Put("cert/" + testDummyID). + Reply(http.StatusOK) + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + err := suite.service.UpdateName(ctx, test.certID, test.name) + + suite.Require().ErrorIs(err, test.expErr) + }) + } +} + +func (suite *CertsSuite) TestRemoveConsumer() { + tests := map[string]struct { + certID string + consumers certs.RemoveConsumersRequest + code int + expErr error + }{ + "Valid New Remove": { + testDummyID, + certs.RemoveConsumersRequest{ + Consumers: []certs.RemoveConsumer{ + { + ID: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + Region: "ru-1", + Type: "zeliboba-speaker", + }, + }, + }, + http.StatusNoContent, + nil, + }, + + "Empty ID": { + "", + certs.RemoveConsumersRequest{}, + http.StatusNoContent, + secretsmanagererrors.ErrEmptyCertificateID, + }, + } + + gock.New(testDummyEndpoint). + Delete("cert/" + testDummyID + "/consumers"). + Reply(http.StatusOK) + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + + err := suite.service.RemoveConsumers(ctx, test.certID, test.consumers) + suite.Require().ErrorIs(err, test.expErr) + }) + } +} + +func (suite *CertsSuite) TestAddConsumer() { + tests := map[string]struct { + certID string + consumers certs.AddConsumersRequest + code int + expErr error + }{ + "Valid New Add": { + testDummyID, + certs.AddConsumersRequest{ + Consumers: []certs.AddConsumer{ + { + ID: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + Region: "ru-1", + Type: "zeliboba-speaker", + }, + }, + }, + http.StatusNoContent, + nil, + }, + "Empty ID": { + "", + certs.AddConsumersRequest{}, + http.StatusNoContent, + secretsmanagererrors.ErrEmptyCertificateID, + }, + } + + gock.New(testDummyEndpoint). + Put("cert/(.*)/consumers"). + Reply(http.StatusOK) + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + + err := suite.service.AddConsumers(ctx, test.certID, test.consumers) + suite.Require().ErrorIs(err, test.expErr) + }) + } +} + +func (suite *CertsSuite) TestGetPublicCerts() { + gock.New(testDummyEndpoint). + Get("cert/" + testDummyID + "/ca_chain"). + Reply(http.StatusOK). + BodyString(testDummyPEMCert) + + ctx := context.Background() + + got, err := suite.service.GetPublicCerts(ctx, testDummyID) + suite.Require().ErrorIs(err, nil) + suite.Equal(testDummyPEMCert, got) +} + +func (suite *CertsSuite) TestGetPrivateKey() { + gock.New(testDummyEndpoint). + Get("cert/" + testDummyID + "/private_key"). + Reply(http.StatusOK). + BodyString(testDummyPEMPrivateKey) + + ctx := context.Background() + + got, err := suite.service.GetPrivateKey(ctx, testDummyID) + suite.Require().ErrorIs(err, nil) + suite.Equal(testDummyPEMPrivateKey, got) +} + +func (suite *CertsSuite) TestGetPKCS12Bundle() { + gock.New(testDummyEndpoint). + Get("cert/" + testDummyID + "/p12"). + Reply(http.StatusOK). + Body(bytes.NewReader(testP12)) + + ctx := context.Background() + + got, err := suite.service.GetPKCS12Bundle(ctx, testDummyID) + suite.Require().ErrorIs(err, nil) + suite.Equal(testP12, got) +} diff --git a/service/certs/fixtures/cert-response-data.json b/service/certs/fixtures/cert-response-data.json new file mode 100644 index 0000000..b46cb10 --- /dev/null +++ b/service/certs/fixtures/cert-response-data.json @@ -0,0 +1,40 @@ +{ + "consumers": [ + { + "id": "0XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + "region": "ru-1", + "type": "octavia-listener" + }, + { "id": "1XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + "region": "kz-228", + "type": "zeliboba-enjoyer" + } + ], + "dns_names": [ + "fishing.com" + ], + "id": "9ddc1899-2a08-4bdb-9a74-4f88371d3533", + "issued_by": { + "country": [ + "RU" + ], + "locality": [ + "string" + ], + "serialNumber": "string", + "streetAddress": [ + "string" + ] + }, + "name": "Zeliboba", + "private_key": { + "type": "RSA" + }, + "serial": "2c4ba60c7a43107bd0d6c79907dc915fdb028285", + "validity": { + "basic_constraints": true, + "notAfter": "2034-01-06T08:37:43Z", + "notBefore": "2024-01-09T08:37:43Z" + }, + "version": 228 +} \ No newline at end of file diff --git a/service/certs/fixtures/certs-response-data.json b/service/certs/fixtures/certs-response-data.json new file mode 100644 index 0000000..c729d59 --- /dev/null +++ b/service/certs/fixtures/certs-response-data.json @@ -0,0 +1,43 @@ +[ + { + "consumers": [ + { + "id": "0XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + "region": "ru-1", + "type": "octavia-listener" + }, + { + "id": "1XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + "region": "ru-1", + "type": "octavia-listener" + } + ], + "dns_names": [ + "fishing.com" + ], + "id": "9ddc1899-2a08-4bdb-9a74-4f88371d3533", + "issued_by": { + "country": [ + "RU" + ], + "locality": [ + "string" + ], + "serialNumber": "string", + "streetAddress": [ + "string" + ] + }, + "name": "Zeliboba", + "private_key": { + "type": "RSA" + }, + "serial": "2c4ba60c7a43107bd0d6c79907dc915fdb028285", + "validity": { + "basic_constraints": true, + "notAfter": "2034-01-06T08:37:43Z", + "notBefore": "2024-01-09T08:37:43Z" + }, + "version": 228 + } +] \ No newline at end of file diff --git a/service/certs/model.go b/service/certs/model.go new file mode 100644 index 0000000..ffdb8d0 --- /dev/null +++ b/service/certs/model.go @@ -0,0 +1,90 @@ +package certs + +// Certificate entity received by the user when making a request +// GET /cert/{id}. +type Certificate struct { + Consumers []Consumer `json:"consumers"` + DNSNames []string `json:"dns_names"` + ID string `json:"id"` + IssuedBy IssuedBy `json:"issued_by"` + Name string `json:"name"` + PrivateKey PrivateKey `json:"private_key"` + Serial string `json:"serial"` + Validity Validity `json:"validity"` + Version int64 `json:"version"` +} + +type Consumer struct { + ID string `json:"id"` + Region string `json:"region"` + Type string `json:"type"` +} + +type IssuedBy struct { + Country []string `json:"country"` + Locality []string `json:"locality"` + SerialNumber string `json:"serialNumber"` //nolint:tagliatelle + StreetAddress []string `json:"streetAddress"` //nolint:tagliatelle +} + +type PrivateKey struct { + Type string `json:"type"` +} + +type Validity struct { + BasicConstraints bool `json:"basic_constraints"` + NotAfter string `json:"notAfter"` //nolint:tagliatelle + NotBefore string `json:"notBefore"` //nolint:tagliatelle +} + +// UpdateCertificateVersionRequest entity send by the user when making a request +// POST /cert/{id}. +type UpdateCertificateVersionRequest struct { + Pem Pem `json:"pem"` +} + +type Pem struct { + Certificates []string `json:"certificates"` + PrivateKey string `json:"private_key"` +} + +// UpdateCertificateNameRequest entity send by the user when making a request +// PUT /cert/{id}. +type UpdateCertificateNameRequest struct { + Name string `json:"name"` +} + +// RemoveConsumerRequest entity send by the user when making a request +// DELETE /cert/{id}/consumers. +type RemoveConsumersRequest struct { + Consumers []RemoveConsumer `json:"consumers,omitempty"` +} + +type RemoveConsumer struct { + ID string `json:"id"` + Region string `json:"region"` + Type string `json:"type"` +} + +// AddConsumersRequest entity send by the user when making a request +// PUT /cert/{id}/consumers. +type AddConsumersRequest struct { + Consumers []AddConsumer `json:"consumers,omitempty"` +} + +type AddConsumer struct { + ID string `json:"id"` + Region string `json:"region"` + Type string `json:"type"` +} + +// GetCertificatesResponse entity received by the user when making a request +// GET /certs. +type GetCertificatesResponse []Certificate + +// CreateCertificateRequest entity received by the user when making a request +// POST /certs. +type CreateCertificateRequest struct { + Name string `json:"name"` + Pem Pem `json:"pem"` +} diff --git a/service/secrets/fixtures/secret-response-data.json b/service/secrets/fixtures/secret-response-data.json new file mode 100644 index 0000000..69faf97 --- /dev/null +++ b/service/secrets/fixtures/secret-response-data.json @@ -0,0 +1,9 @@ +{ + "description": "dummy-description", + "name": "dummy-secret", + "version": { + "created_at": "2023-12-26T09:48:01Z", + "value": "dmFsdWU=", + "version_id": 0 + } +} \ No newline at end of file diff --git a/service/secrets/fixtures/secrets-response-data.json b/service/secrets/fixtures/secrets-response-data.json new file mode 100644 index 0000000..097d5e6 --- /dev/null +++ b/service/secrets/fixtures/secrets-response-data.json @@ -0,0 +1,20 @@ +{ + "keys": [ + { + "metadata": { + "created_at": "2023-12-26T09:48:01Z", + "description": "Bla" + }, + "name": "Bla", + "type": "Secret" + }, + { + "metadata": { + "created_at": "2007-12-26T09:48:01Z", + "description": "IAM" + }, + "name": "IAM", + "type": "Secret" + } + ] +} \ No newline at end of file diff --git a/service/secrets/model.go b/service/secrets/model.go new file mode 100644 index 0000000..aa5e085 --- /dev/null +++ b/service/secrets/model.go @@ -0,0 +1,40 @@ +package secrets + +// Secrets — entity received by the user when making a request +// GET /. +type Secrets struct { + Keys []Key `json:"keys"` +} + +type Key struct { + Metadata SecretMetadata `json:"metadata"` + Name string `json:"name"` + Type string `json:"type"` +} + +type SecretMetadata struct { + CreatedAt string `json:"created_at"` + Description string `json:"description"` +} + +// Secret — entity received by the user when making a request +// GET /{key}. +type Secret struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + Version SecretVersion `json:"version"` +} + +type SecretVersion struct { + CreatedAt string `json:"created_at"` + Value string `json:"value"` // The value of the secret in base64. + VersionID uint `json:"version_id"` +} + +// UserSecret — an entity created by the user to save it in the Secret Manager +// POST /{key} and PUT /{key}. +type UserSecret struct { + Key string `json:"-"` + Description string `json:"description,omitempty"` + Value string `json:"value"` // The value of the secret in base64. +} diff --git a/service/secrets/secrets.go b/service/secrets/secrets.go new file mode 100644 index 0000000..bb77d75 --- /dev/null +++ b/service/secrets/secrets.go @@ -0,0 +1,186 @@ +package secrets + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + + "github.com/selectel/secretsmanager/internal/httpclient" + "github.com/selectel/secretsmanager/secretsmanagererrors" +) + +// Service implements Secrets Manager part that is responsible for handling secrets operations. +type Service struct { + apiURLSecrets string + httpClient *httpclient.HTTPClient +} + +func New(url string, client *httpclient.HTTPClient) *Service { + return &Service{ + apiURLSecrets: url, + httpClient: client, + } +} + +func (s Service) List(ctx context.Context) (Secrets, error) { + endpoint, err := url.Parse(s.apiURLSecrets) + if err != nil { + return Secrets{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + q := endpoint.Query() + q.Add("list", "") + endpoint.RawQuery = q.Encode() + + respBody, err := s.httpClient.DoRequest(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return Secrets{}, err //nolint:wrapcheck // DoRequest already wraps the error. + } + + var sc Secrets + err = json.Unmarshal(respBody, &sc) + if err != nil { + return Secrets{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotUnmarshalBody, + Desc: err.Error(), + } + } + return sc, nil +} + +func (s Service) Delete(ctx context.Context, key string) error { + if len(key) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptySecretName, + Desc: "field name in secret is empty", + } + } + + endpoint, err := url.JoinPath(s.apiURLSecrets, key) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrInternalAppError, + Desc: err.Error(), + } + } + _, err = s.httpClient.DoRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return nil +} + +func (s Service) Get(ctx context.Context, key string) (Secret, error) { + if len(key) == 0 { + return Secret{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptySecretName, + Desc: "field name in secret is empty", + } + } + + endpoint, err := url.JoinPath(s.apiURLSecrets, key) + if err != nil { + return Secret{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + respBody, err := s.httpClient.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return Secret{}, err //nolint:wrapcheck // DoRequest already wraps the error. + } + + var sc Secret + err = json.Unmarshal(respBody, &sc) + if err != nil { + return Secret{}, secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotUnmarshalBody, + Desc: err.Error(), + } + } + + return sc, nil +} + +func (s Service) Update(ctx context.Context, usc UserSecret) error { + if len(usc.Key) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptySecretName, + Desc: "field name in secret is empty", + } + } + + endpoint, err := url.JoinPath(s.apiURLSecrets, usc.Key) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + marshalled, err := json.Marshal(usc) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotMarshalSecretBody, + Desc: err.Error(), + } + } + + reqBody := bytes.NewReader(marshalled) + + _, err = s.httpClient.DoRequest(ctx, http.MethodPut, endpoint, reqBody) + if err != nil { + return err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return nil +} + +func (s Service) Create(ctx context.Context, usc UserSecret) error { + if len(usc.Key) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptySecretName, + Desc: "field name in secret is empty", + } + } + + if len(usc.Value) == 0 { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrEmptySecretValue, + Desc: "field value in secret is empty", + } + } + usc.Value = base64.StdEncoding.EncodeToString([]byte(usc.Value)) + + endpoint, err := url.JoinPath(s.apiURLSecrets, usc.Key) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotFormatEndpoint, + Desc: err.Error(), + } + } + + marshalled, err := json.Marshal(usc) + if err != nil { + return secretsmanagererrors.Error{ + Err: secretsmanagererrors.ErrCannotMarshalSecretBody, + Desc: err.Error(), + } + } + + reqBody := bytes.NewReader(marshalled) + _, err = s.httpClient.DoRequest(ctx, http.MethodPost, endpoint, reqBody) + if err != nil { + return err //nolint:wrapcheck // DoRequest already wraps the error. + } + + return nil +} diff --git a/service/secrets/secrets_test.go b/service/secrets/secrets_test.go new file mode 100644 index 0000000..ee803e0 --- /dev/null +++ b/service/secrets/secrets_test.go @@ -0,0 +1,282 @@ +package secrets_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/h2non/gock" + "github.com/stretchr/testify/suite" + + "github.com/selectel/secretsmanager/internal/auth" + "github.com/selectel/secretsmanager/internal/httpclient" + "github.com/selectel/secretsmanager/secretsmanagererrors" + "github.com/selectel/secretsmanager/service/secrets" +) + +const ( + testDummyEndpoint = "http://example.com/" + testDummyKey = "dummy-secret" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context. +type SecretsSuite struct { + suite.Suite + service *secrets.Service +} + +// Executes before each test case +// Make sure that same HTTPClient is set in both +// service & service mock before each test. +func (suite *SecretsSuite) SetupTest() { + auth, err := auth.NewKeystoneTokenAuth("dummy") + suite.Require().NoError(err) + + httpClient := &http.Client{Timeout: 10 * time.Second} + suite.service = secrets.New( + testDummyEndpoint, + httpclient.New(auth, httpClient), + ) + // http client, on the basis of which + // we will perform mocks during gock initialization. + gock.InterceptClient(httpClient) +} + +// Executes after each test case. +func (suite *SecretsSuite) TearDownTest() { + // Verify that we don't have pending mocks + suite.Require().True(gock.IsDone()) + // Flush pending mocks after test execution. + gock.Off() + + suite.service = nil +} + +// TestSuiteSecrets runs all suite tests. +func TestSuiteSecrets(t *testing.T) { + suite.Run(t, new(SecretsSuite)) +} + +func (suite *SecretsSuite) TestList() { + expectedSecrets := secrets.Secrets{ + Keys: []secrets.Key{ + { + Metadata: secrets.SecretMetadata{ + CreatedAt: "2023-12-26T09:48:01Z", + Description: "Bla", + }, + Name: "Bla", + Type: "Secret", + }, + { + Metadata: secrets.SecretMetadata{ + CreatedAt: "2007-12-26T09:48:01Z", + Description: "IAM", + }, + Name: "IAM", + Type: "Secret", + }, + }, + } + + gock.New(testDummyEndpoint+"?"). + Get(""). + MatchParam("list", ""). + Reply(http.StatusOK). + File("./fixtures/secrets-response-data.json") + + ctx := context.Background() + res, err := suite.service.List(ctx) + suite.Require().NoError(err) + + suite.Equal(expectedSecrets, res) +} + +func (suite *SecretsSuite) TestDelete() { + tests := map[string]struct { + key string + code int + expErr error + }{ + "Successful deletion": {testDummyKey, http.StatusNoContent, nil}, + "Empty secret": {"", http.StatusBadRequest, secretsmanagererrors.ErrEmptySecretName}, + } + + gock.New(testDummyEndpoint). + Delete(testDummyKey). + Reply(http.StatusOK) + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + err := suite.service.Delete(ctx, test.key) + + suite.Require().ErrorIs(err, test.expErr) + }) + } +} + +func (suite *SecretsSuite) TestGet() { + tests := map[string]struct { + key string + expSecret secrets.Secret + code int + expErr error + }{ + "Successful Get": { + testDummyKey, + secrets.Secret{ + Description: "dummy-description", + Name: testDummyKey, + Version: secrets.SecretVersion{ + CreatedAt: "2023-12-26T09:48:01Z", + Value: "dmFsdWU=", + VersionID: 0, + }, + }, + http.StatusOK, + nil, + }, + "Empty Key": { + "", + secrets.Secret{}, + http.StatusInternalServerError, + secretsmanagererrors.ErrEmptySecretName, + }, + } + + gock.New(testDummyEndpoint). + Get(testDummyKey). + Reply(http.StatusOK). + File("./fixtures/secret-response-data.json") + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + got, err := suite.service.Get(ctx, test.key) + + suite.Require().ErrorIs(err, test.expErr) + suite.Equal(test.expSecret, got) + }) + } +} + +func (suite *SecretsSuite) TestUpdate() { + tests := map[string]struct { + key string + expSecret secrets.UserSecret + code int + expErr error + }{ + "Successful Update": { + testDummyKey, + secrets.UserSecret{ + Key: testDummyKey, + Description: "dummy-description", + }, + http.StatusOK, + nil, + }, + "Empty Description": { + testDummyKey, + secrets.UserSecret{ + Key: testDummyKey, + }, + http.StatusOK, + nil, + }, + "Empty Key": { + "", + secrets.UserSecret{ + Key: "", + Description: "dummy-description", + }, + http.StatusInternalServerError, + secretsmanagererrors.ErrEmptySecretName, + }, + } + + gock.New(testDummyEndpoint). + Put(testDummyKey). + Reply(http.StatusOK) + + gock.New(testDummyEndpoint). + Put(""). + Reply(http.StatusOK) + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + err := suite.service.Update(ctx, test.expSecret) + + suite.Require().ErrorIs(err, test.expErr) + }) + } +} + +func (suite *SecretsSuite) TestCreate() { + tests := map[string]struct { + key string + expSecret secrets.UserSecret + code int + expErr error + }{ + "Successful Create": { + testDummyKey, + secrets.UserSecret{ + Key: testDummyKey, + Description: "dummy-description", + Value: "dmFsdWU=", + }, + http.StatusOK, + nil, + }, + "Empty Description": { + testDummyKey, + secrets.UserSecret{ + Key: testDummyKey, + Value: "dmFsdWU=", + }, + http.StatusOK, + nil, + }, + "Empty Name": { + "", + secrets.UserSecret{ + Key: "", + Value: "dmFsdWU=", + }, + http.StatusInternalServerError, + secretsmanagererrors.ErrEmptySecretName, + }, + "Empty Value": { + testDummyKey, + secrets.UserSecret{ + Key: testDummyKey, + Value: "", + }, + http.StatusInternalServerError, + secretsmanagererrors.ErrEmptySecretValue, + }, + } + + gock.New(testDummyEndpoint). + Post(testDummyKey). + Reply(http.StatusOK) + + gock.New(testDummyEndpoint). + Post(""). + Reply(http.StatusOK) + + for name, test := range tests { + suite.T().Run(name, func(t *testing.T) { + ctx := context.Background() + err := suite.service.Create(ctx, test.expSecret) + + suite.Require().ErrorIs(err, test.expErr) + }) + } +}