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..dccacd3
--- /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..b1d5398
--- /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](./errors.md)
\ No newline at end of file
diff --git a/docs/errors.md b/docs/errors.md
new file mode 100644
index 0000000..f2176f1
--- /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..6d6f7ba
--- /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[📄 main.go](./create-list-update-delete-secrets/main.go)
+
+## Certificates
+> To perform any operations with certificates you have to call a `Certificates` property on client.
+
+### Creating a Certificates
+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 certificates can be found in[📄 main.go](./create-addconsumers-update-get-certs/main.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..2294961
--- /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"
+)
+```
+
+>
+>
+>
+> Basic Flow with Certificate in Secrets Manаger
+>
+> ```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 Manаger, 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 Manаger by List method, that shows all Certificates in project
+> certs, err := cl.Certificates.List(ctx)
+> ```
+>
+
+
+>
+>
+>
+> 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)
+> ```
+>
+
+
+
+>
+>
+>
+> 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"
+> ```
+>
+
+>
+>
+>
+> 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
+> ```
+>
+
+
+>
+>
+>
+> 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..f88c4af
--- /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"
+)
+```
+
+>
+>
+>
+> Basic Flow with Secrets in Secrets Manаger
+>
+> ```go
+> // To upload a Secret into in Secrets Manаger 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 Manаger, call Create method
+> err := cl.Secrets.Create(ctx, mySecret)
+>
+> // If you wich to retrive it from Secrets Manаger, 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 Manаger
+> err := cl.Secrets.Delete(ctx, "John-Cena")
+> ```
+
+>
+>
+> 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)
+> }
+> ```
+>
+
+>
+>
+> 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)
+ })
+ }
+}