diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..ef6333a --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +Before creating a PR please create an issue that will describe a problem. + +## Project structure + +Every API part should be implemented in its separate package. + +Any package which implements methods to work with IAM API uses the +following structure: + +``` +(iam_api_component)/ +├── testdata +| └── fixtures.go # Tests fixtures +├── doc.go # Documentation at the godoc.org +├── requests.go # Methods to work with the API +├── requests_test.go # Tests for all implemented requests +└── schemas.go # Models and types +``` + +## Tests + +Please implement tests for all methods that you're creating. + +You can use: +* [httpmock](https://github.com/jarcoal/httpmock) to mock requests and responses. +* [testify](https://github.com/stretchr/testify) to easily write assertions and validations. diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..23c3097 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,22 @@ +name: Golangci-lint +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.54 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..86f631d --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,27 @@ +name: Unit tests +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Run tests with coverage + run: go test -v ./... -race -coverprofile=coverage.out -covermode=atomic + + - name: Run tests -race + run: go test -v ./... -race + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5c24e6a --- /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 # Requires explanation for using nolint comments. + - 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/iam-go) + + godot: + scope: declarations + exclude: + - '^ @' + + goimports: + local-prefixes: github.com/selectel/iam-go + + 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 + +... 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..8b50225 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +default: workflow + +workflow: golangci-lint unit-test + +unit-test: + @echo "--- Running unit tests ---" + go test -v ./... + +unit-test-race: + @echo "--- Running unit tests -race ---" + go test -v -race ./... + +golangci-lint: + @echo "--- Running golangci-lint ---" + golangci-lint run ./... + +.PHONY: workflow unit-test golangci-lint \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbca6c7 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# iam-go: Go SDK for IAM 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/iam-go/) +[![Go Report Card](https://goreportcard.com/badge/github.com/selectel/iam-go)](https://goreportcard.com/report/github.com/selectel/iam-go) +[![codecov](https://codecov.io/gh/Selectel/iam-go/branch/main/graph/badge.svg)](https://codecov.io/gh/Selectel/iam-go) + +Package iam-go provides Go SDK to work with the Selectel IAM API. + +Jump To: +* [Documentation](#Documentation) +* [Getting started](#Getting-started) +* [Additional info](#Additional-info) + +## Documentation + +The Go library documentation is available at [go.dev](https://pkg.go.dev/github.com/selectel/iam-go/). + +## Getting started + +You can use this library to work with the following objects of the Selectel IAM API: + +* [users](https://pkg.go.dev/github.com/selectel/iam-go/service/users) +* [serviceusers](https://pkg.go.dev/github.com/selectel/iam-go/service/serviceusers) +* [ec2](https://pkg.go.dev/github.com/selectel/iam-go/service/ec2) + +### Installation + +You can install `iam-go` via `go get` command: + +```bash +go get github.com/selectel/iam-go +``` + +### Authentication + +To work with the Selectel IAM API you first need to: + +* Create a Selectel account: [registration page](https://my.selectel.ru/registration). +* Retrieve a Keystone Token for your account via [API](https://developers.selectel.com/docs/control-panel/authorization/#obtain-keystone-token) or [go-selvpcclient](https://github.com/selectel/go-selvpcclient). + +After that initialize `Client` with the retrieved token. + + +### Usage example + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/selectel/iam-go" +) + +func main() { + // A KeystoneToken to work with the Selectel IAM API. + // It should be Service User Token + token := "gAAAAABeVNzu-..." + + // A Prefix to be added to User-Agent. + prefix := "iam-custom" + + // Create a new IAM client. + iamClient, err := iam.New( + iam.WithAuthOpts(&iam.AuthOpts{KeystoneToken: token}), + iam.WithUserAgentPrefix(prefix), + ) + // Handle the error. + if err != nil { + log.Fatalf("Error occured: %s", err) + return + } + + // Get the Users instance. + usersAPI := iamClient.Users + + // Prepare an empty context. + ctx := context.Background() + + // Get all users. + users := usersAPI.List(ctx) + + // Print info about each user. + for _, user := range users { + fmt.Println("ID:", user.ID) + fmt.Println("KeystoneID:", user.Keystone.ID) + fmt.Println("AuthType:", user.AuthType) + } +} +``` + +## Additional info +* See [examples](./examples) for more code examples of iam-go +* Read [docs](./docs) for advanced topics and guides diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..c35e7fa --- /dev/null +++ b/doc.go @@ -0,0 +1,2 @@ +// Package iam is an entyry point of IAM SDK and implements functionality for interacting with the Selectel IAM API. +package iam diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ff0806b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,5 @@ +# Advanced Concepts + +Here are listed some of the concepts, which can help you to controll the SDK more precisely. + +* [**Error Handling**](./errors.md) diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..b24d503 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,34 @@ +# Error Handling + +In generall, iam-go errors can be divided into two types: + +1. **IAM Server Errors**: returned by the IAM API Server itself +2. **IAM Client Errors**: returned by the iam-go library + +Any of these can be handled as a special type: [_**iamerrors.Error**_](../iamerrors/iamerrors.go). + +Below are some examples on how to handle these errors: + +1. You can use _errors.Is_ to identify the type of error: + +```go +if err != nil { + switch { + case errors.Is(err, iamerrors.ErrForbidden): + log.Fatalf("No rights: %s", err.Error()) + } + ... +} +``` + +2. You can cast a returned error to _iamerrors.Error_ with _errors.As_ and get the specific info (description of an error, for example): + +```go +if err != nil { + var iamError *iamerrors.Error + if errors.As(err, &iamError) { + log.Fatalf("IAM Error! Description: %s", iamError.Desc) + } + ... +} +``` \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b144cb6 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,9 @@ +# iam-go Examples + +This directory contains examples that cover various use cases and functionality for iam-go. + +### Concepts +- [**Create & Delete EC2**](./ec2-create-delete): Create a new EC2(S3) credential for an existing Service User (ID is needed). +- [**Create, Update & Delete Service User**](./serviceuser-create-update-delete): Create a new Service User, then update it's data and delete it. +- [**Transfer role from one User to another**](./transfer-role): Find a billing User from all and transfer it's role to another User (ID is needed). +- [**Create & Delete User**](./user-create-delete): Create a new User and delete it. diff --git a/examples/ec2-create-delete/README.md b/examples/ec2-create-delete/README.md new file mode 100644 index 0000000..c457d7b --- /dev/null +++ b/examples/ec2-create-delete/README.md @@ -0,0 +1,19 @@ +# Create & Delete EC2(S3) credential + +This example program demonstrates how to manage creating and deleting EC2(S3) credential for a Service User. + +The part of deleting a just-created credential is commented. + +## Running this example + +Running this file will execute the following operations: + +1. **Create:** Create is used to create a new EC2(S3) credential. It is implied, that the Service User ID is known. +2. **(Delete):** _(commented by default)_ Delete deletes a just-created credential on a previous step. + +You should see an output like the following (with both operations enabled): + +``` +Step 1: Created credential Secret Key: a1b2c3... AccessKey: 1a2b3c... +Step 2: Deleted the credential +``` diff --git a/examples/ec2-create-delete/main.go b/examples/ec2-create-delete/main.go new file mode 100644 index 0000000..20841bb --- /dev/null +++ b/examples/ec2-create-delete/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + + iam "github.com/selectel/iam-go" +) + +func main() { + // KeystoneToken + token := "gAAAAA..." + + // Prefix to be added to User-Agent. + prefix := "iam-go" + + // ID of the User to create EC2 credential for. + userID := "a1b2c3..." + + // Name of the EC2 credential to create. + name := "my-ec2-credential" + + // Project ID to create the EC2 credential for. + projectID := "a1b2c3..." + + // Create a new IAM client. + iamClient, err := iam.New( + iam.WithAuthOpts(&iam.AuthOpts{KeystoneToken: token}), + iam.WithUserAgentPrefix(prefix), + ) + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + // Get the EC2 instance. + ec2API := iamClient.EC2 + + // Prepare an empty context. + ctx := context.Background() + + // Create a new EC2 credential for the Service User ID. + credential, err := ec2API.Create( + ctx, + userID, + name, + projectID, + ) + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Step 1: Created credential Secret Key: %s Access Key: %s\n", credential.SecretKey, credential.AccessKey) + + // // Delete an existing EC2 credential. + // err = ec2API.Delete(ctx, &ec2.DeleteInput{ + // UserID: userID, + // AccessKey: credential.AccessKey, + // }) + + // // Handle the error. + // if err != nil { + // fmt.Println(err) + // } + + // fmt.Printf("Step 2: Deleted credential") +} diff --git a/examples/serviceuser-create-update-delete/README.md b/examples/serviceuser-create-update-delete/README.md new file mode 100644 index 0000000..2ab9d1e --- /dev/null +++ b/examples/serviceuser-create-update-delete/README.md @@ -0,0 +1,23 @@ +# Create, Update & Delete Service User + +This example program demonstrates how to manage creating, updating and deleting Service User. + +The part of deleting a just-created Service User is commented. + +As an example, the Billing Role will be assigned for a new Service User and in update method this Service User will be set to _Disabled_. + +## Running this example + +Running this file will execute the following operations: + +1. **Create:** Create is used to create a new Service User. +2. **Update** Update sets _Enabled_ property of the just-created Service User to _false_ +3. **(Delete):** _(commented by default)_ Delete deletes a just-created Service User. + +You should see an output like the following (with all operations enabled): + +``` +Step 1: Created Service User ID: a1b2c3... +Step 2: Disabled Service User ID: a1b2c3... +Step 3: Deleted Service User ID: a1b2c3... +``` diff --git a/examples/serviceuser-create-update-delete/main.go b/examples/serviceuser-create-update-delete/main.go new file mode 100644 index 0000000..c8b3059 --- /dev/null +++ b/examples/serviceuser-create-update-delete/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "fmt" + + "github.com/selectel/iam-go" + "github.com/selectel/iam-go/service/serviceusers" +) + +func main() { + // KeystoneToken + token := "gAAAAA..." + + // Prefix to be added to User-Agent. + prefix := "iam-go" + + // Name of the Service User to create. + name := "service-user" + + // Password of the Service User to create. + password := "Qazwsxedc123" + + // Create a new IAM client. + iamClient, err := iam.New( + iam.WithAuthOpts(&iam.AuthOpts{KeystoneToken: token}), + iam.WithUserAgentPrefix(prefix), + ) + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + // Get the Service User instance. + serviceUsersAPI := iamClient.ServiceUsers + + // Prepare an empty context. + ctx := context.Background() + + // Create a new Service User. + serviceUser, err := serviceUsersAPI.Create(ctx, serviceusers.CreateRequest{ + Enabled: true, + Name: name, + Password: password, + Roles: []serviceusers.Role{{Scope: serviceusers.Account, RoleName: serviceusers.Billing}}, + }) + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Step 1: Created Service User ID: %s\n", serviceUser.ID) + + // Disable the just-created Service User. + _, err = serviceUsersAPI.Update(ctx, serviceUser.ID, serviceusers.UpdateRequest{ + Enabled: false, + }) + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Step 2: Disabled Service User ID %s\n", serviceUser.ID) + + // // Delete an existing Service User. + // err = serviceUsersAPI.Delete(ctx, serviceUser.ID) + + // // Handle the error. + // if err != nil { + // fmt.Println(err) + // } + + // fmt.Printf("Step 3: Deleted Service User ID %s\n", serviceUser.ID) +} diff --git a/examples/transfer-role/README.md b/examples/transfer-role/README.md new file mode 100644 index 0000000..7210fd0 --- /dev/null +++ b/examples/transfer-role/README.md @@ -0,0 +1,21 @@ +# Transfer role from one User to another + +This example program demonstrates how to unassign Billing role from one User and assign it to another. + +The same approach can be applied for Service Users. + +## Running this example + +Running this file will execute the following operations: + +1. **List:** List is used to retrieve all Users. The first one, who has a billing role, will be selected as 'transferer'. +2. **UnassignRole:** UnassinRole will remove Billing role from chosen user. +3. **AssignRole** AssignRole will add Billing role to the predefined User ID. + +You should see an output like the following: + +``` +Step 1: User 123456_12345 with the Billing role was found +Step 2: Unassigned the Billing role from User 123456_12345 +Step 3: Assigned the Billing role to User 654321_65432 +``` diff --git a/examples/transfer-role/main.go b/examples/transfer-role/main.go new file mode 100644 index 0000000..d52c40e --- /dev/null +++ b/examples/transfer-role/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "fmt" + + "github.com/selectel/iam-go" + "github.com/selectel/iam-go/service/users" +) + +func main() { + // KeystoneToken + token := "gAAAAA..." + + // Prefix to be added to User-Agent. + prefix := "iam-go" + + // ID of the User to assign role to. + userID := "654321_65432" + + // Create a new IAM client. + iamClient, err := iam.New( + iam.WithAuthOpts(&iam.AuthOpts{KeystoneToken: token}), + iam.WithUserAgentPrefix(prefix), + ) + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + // Get the Users instance. + usersAPI := iamClient.Users + + // Prepare an empty context. + ctx := context.Background() + + // List the roles assigned to each user and find a billing. + allUsers, err := usersAPI.List(ctx) + if err != nil { + fmt.Println(err) + return + } + + var chosenUser *users.User + for _, user := range allUsers { + for _, role := range user.Roles { + if role.RoleName == users.Billing && user.ID != "account_root" { + chosenUser = &user + break + } + } + if chosenUser != nil { + break + } + } + + if chosenUser == nil { + fmt.Println("No billing role was found") + return + } + + // Step 1 + fmt.Printf("Step 1: User %s with the Billing role was found\n", chosenUser.ID) + + // Unassign the role. + err = usersAPI.UnassignRoles( + ctx, + chosenUser.ID, + []users.Role{{Scope: users.Account, RoleName: users.Billing}}, + ) + + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + // Step 2 + fmt.Printf("Step 2: Unassigned the Billing role from User %s \n", chosenUser.ID) + + // Assign the role. + err = usersAPI.AssignRoles( + ctx, + userID, + []users.Role{{Scope: users.Account, RoleName: users.Billing}}, + ) + + // Handle the error. + if err != nil { + fmt.Println(err) + } + + // Step 3 + fmt.Printf("Step 3: Assigned the Billing role to User %s \n", userID) +} diff --git a/examples/user-create-delete/README.md b/examples/user-create-delete/README.md new file mode 100644 index 0000000..f286963 --- /dev/null +++ b/examples/user-create-delete/README.md @@ -0,0 +1,21 @@ +# Create & Delete User + +This example program demonstrates how to manage creating and deleting User. + +The part of deleting a just-created User is commented. + +As an example, the Billing Role will be assigned for a new User. + +## Running this example + +Running this file will execute the following operations: + +1. **Create:** Create is used to create a new User. +2. **(Delete):** _(commented by default)_ Delete deletes a just-created User on a previous step. + +You should see an output like the following (with both operations enabled): + +``` +Step 1: Created User ID: 12345... Keystone ID: 1a2b3c... +Step 2: Deleted the User ID: 12345... +``` diff --git a/examples/user-create-delete/main.go b/examples/user-create-delete/main.go new file mode 100644 index 0000000..4c6bb6e --- /dev/null +++ b/examples/user-create-delete/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "fmt" + + "github.com/selectel/iam-go" + "github.com/selectel/iam-go/service/users" +) + +func main() { + // KeystoneToken + token := "gAAAAA..." + + // Prefix to be added to User-Agent. + prefix := "iam-go" + + // Email of the User to create. + email := "testmail@mail.com" + + // Create a new IAM client. + iamClient, err := iam.New( + iam.WithAuthOpts(&iam.AuthOpts{KeystoneToken: token}), + iam.WithUserAgentPrefix(prefix), + ) + + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + // Get the Users instance. + usersAPI := iamClient.Users + + // Prepare an empty context. + ctx := context.Background() + + // Create a new User. + user, err := usersAPI.Create(ctx, users.CreateRequest{ + AuthType: users.Local, + Email: email, + Federation: nil, + Roles: []users.Role{{Scope: users.Account, RoleName: users.Billing}}, + }) + // Handle the error. + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("Step 1: Created User ID: %s Keystone ID: %s\n", user.ID, user.KeystoneID) + + // // Delete an existing User. + // err = usersAPI.Delete(ctx, user.ID) + + // // Handle the error. + // if err != nil { + // fmt.Println(err) + // } + + // fmt.Printf("Step 2: Deleted User ID: %s", user.ID) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0eac0d9 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/selectel/iam-go + +go 1.20 + +require ( + github.com/jarcoal/httpmock v1.3.1 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // 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..c5232a0 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +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/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= +github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= +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/iam.go b/iam.go new file mode 100644 index 0000000..2a1724f --- /dev/null +++ b/iam.go @@ -0,0 +1,164 @@ +package iam + +import ( + "net/http" + "runtime/debug" + "time" + + "github.com/selectel/iam-go/iamerrors" + baseclient "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/ec2" + "github.com/selectel/iam-go/service/serviceusers" + "github.com/selectel/iam-go/service/users" +) + +const ( + // appName represents an application name. + appName = "iam-go" + + // defaultIAMApiURL represents a default Selectel IAM API URL. + defaultIAMApiURL = "https://api.selectel.ru/iam/v1" + + // 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 +) + +// Client stores the configuration, which is needed to make requests to the IAM API. +type Client struct { + // authOpts contains data to authenticate against Selectel IAM API. + authOpts *AuthOpts + + // baseClient contains the configuration of the Client. + baseClient *baseclient.BaseClient + + // Users instance is used to make requests against Selectel IAM API. + Users *users.Users + + // ServiceUsers instance is used to make requests against Selectel IAM API. + ServiceUsers *serviceusers.ServiceUsers + + // EC2 instance is used to make requests against Selectel IAM API. + EC2 *ec2.EC2 +} + +type AuthOpts struct { + KeystoneToken string +} + +type Option func(*Client) + +// WithAPIUrl is a functional parameter for Client, used to set IAM API URL. +func WithAPIUrl(url string) Option { + return func(c *Client) { + c.baseClient.APIUrl = url + } +} + +// WithCustomHTTPClient is a functional parameter for Client, used to set a custom HTTP client. +func WithCustomHTTPClient(httpClient *http.Client) Option { + return func(c *Client) { + c.baseClient.HTTPClient = httpClient + } +} + +// WithAuthOpts is a functional parameter for Client, used to set on of implementations of AuthType. +func WithAuthOpts(authOpts *AuthOpts) Option { + return func(c *Client) { + c.authOpts = authOpts + } +} + +// WithUserAgentPrefix is a functional parameter for Client, used to set a custom prefix. +func WithUserAgentPrefix(prefix string) Option { + return func(c *Client) { + c.baseClient.UserAgentPrefix = prefix + } +} + +// New returns a new instance of Client for the v1 IAM API. +func New(opts ...Option) (*Client, error) { + c := &Client{baseClient: &baseclient.BaseClient{}} + + for _, opt := range opts { + opt(c) + } + + if !c.validateAndSetAuthMethod() { + return nil, iamerrors.Error{Err: iamerrors.ErrClientNoAuthOpts, Desc: "No AuthOpts was passed"} + } + + if c.baseClient.APIUrl == "" { + c.baseClient.APIUrl = defaultIAMApiURL + } + + if c.baseClient.HTTPClient == nil { + c.baseClient.HTTPClient = &http.Client{ + Timeout: defaultHTTPTimeout * time.Second, + Transport: newHTTPTransport(), + } + } + + appVersion := findModuleVersion() + userAgent := appName + "/" + appVersion + if c.baseClient.UserAgentPrefix == "" { + c.baseClient.UserAgent = userAgent + } else { + c.baseClient.UserAgent = c.baseClient.UserAgentPrefix + " " + userAgent + } + + c.Users = users.New(c.baseClient) + c.ServiceUsers = serviceusers.New(c.baseClient) + c.EC2 = ec2.New(c.baseClient) + + return c, nil +} + +func (c *Client) validateAndSetAuthMethod() bool { + if c.authOpts == nil { + return false + } + if c.authOpts.KeystoneToken != "" { + c.baseClient.AuthMethod = &baseclient.KeystoneTokenAuth{ + KeystoneToken: c.authOpts.KeystoneToken, + } + return true + } + return false +} + +func newHTTPTransport() *http.Transport { + return &http.Transport{ + MaxIdleConns: defaultMaxIdleConns, + IdleConnTimeout: defaultIdleConnTimeout * time.Second, + TLSHandshakeTimeout: defaultTLSHandshakeTimeout * time.Second, + ExpectContinueTimeout: defaultExpectContinueTimeout * time.Second, + } +} + +func findModuleVersion() string { + moduleName := "github.com/selectel/" + appName + + info, ok := debug.ReadBuildInfo() + if ok { + for _, dep := range info.Deps { + if dep.Path == moduleName { + return dep.Version + } + } + } + return "v0.1.0" +} diff --git a/iam_test.go b/iam_test.go new file mode 100644 index 0000000..551c00f --- /dev/null +++ b/iam_test.go @@ -0,0 +1,156 @@ +package iam + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/selectel/iam-go/iamerrors" + baseclient "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/ec2" + "github.com/selectel/iam-go/service/serviceusers" + "github.com/selectel/iam-go/service/users" +) + +const ( + testToken = "test-token" + testURL = "http://example.org/" +) + +//nolint:funlen // This is a test function. +func TestNew(t *testing.T) { + type args struct { + opts []Option + } + tests := []struct { + name string + args args + expectedClient func() *Client + expectedBaseClient *baseclient.BaseClient + expectedError error + }{ + { + name: "Test NewIAMClientV1 only with TokenAuth and APIUrl", + args: args{ + opts: []Option{ + WithAPIUrl(testURL), + WithAuthOpts(&AuthOpts{ + KeystoneToken: testToken, + }), + }, + }, + expectedClient: func() *Client { + baseClient := &baseclient.BaseClient{ + HTTPClient: &http.Client{ + Timeout: defaultHTTPTimeout * time.Second, + Transport: newHTTPTransport(), + }, + APIUrl: testURL, + AuthMethod: &baseclient.KeystoneTokenAuth{KeystoneToken: testToken}, + UserAgent: appName + "/" + findModuleVersion(), + } + return &Client{ + authOpts: &AuthOpts{ + KeystoneToken: testToken, + }, + baseClient: baseClient, + Users: users.New(baseClient), + ServiceUsers: serviceusers.New(baseClient), + EC2: ec2.New(baseClient), + } + }, + expectedError: nil, + }, + { + name: "Test NewIAMClientV1 only with APIUrl", + args: args{ + opts: []Option{ + WithAPIUrl(testURL), + }, + }, + expectedClient: nil, + expectedError: iamerrors.ErrClientNoAuthOpts, + }, + { + name: "Test NewIAMClientV1 only with TokenAuth", + args: args{ + opts: []Option{ + WithAuthOpts(&AuthOpts{ + KeystoneToken: testToken, + }), + }, + }, + expectedClient: func() *Client { + baseClient := &baseclient.BaseClient{ + HTTPClient: &http.Client{ + Timeout: defaultHTTPTimeout * time.Second, + Transport: newHTTPTransport(), + }, + APIUrl: defaultIAMApiURL, + AuthMethod: &baseclient.KeystoneTokenAuth{KeystoneToken: testToken}, + UserAgent: appName + "/" + findModuleVersion(), + } + return &Client{ + authOpts: &AuthOpts{ + KeystoneToken: testToken, + }, + baseClient: baseClient, + Users: users.New(baseClient), + ServiceUsers: serviceusers.New(baseClient), + EC2: ec2.New(baseClient), + } + }, + expectedError: nil, + }, + { + name: "Test NewIAMClientV1 only with TokenAuth and APIUrl and HTTPClient", + args: args{ + opts: []Option{ + WithAPIUrl(testURL), + WithAuthOpts(&AuthOpts{ + KeystoneToken: testToken, + }), + WithCustomHTTPClient(&http.Client{ + Timeout: 10 * time.Second, + }), + }, + }, + expectedClient: func() *Client { + baseClient := &baseclient.BaseClient{ + HTTPClient: &http.Client{ + Timeout: 10 * time.Second, + }, + APIUrl: testURL, + AuthMethod: &baseclient.KeystoneTokenAuth{KeystoneToken: testToken}, + UserAgent: appName + "/" + findModuleVersion(), + } + return &Client{ + authOpts: &AuthOpts{ + KeystoneToken: testToken, + }, + baseClient: baseClient, + Users: users.New(baseClient), + ServiceUsers: serviceusers.New(baseClient), + EC2: ec2.New(baseClient), + } + }, + expectedError: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + var expected *Client + if tt.expectedClient != nil { + expected = tt.expectedClient() + } + actual, err := New(tt.args.opts...) + require.ErrorIs(err, tt.expectedError) + assert.Equal(expected, actual) + }) + } +} diff --git a/iamerrors/iamerrors.go b/iamerrors/iamerrors.go new file mode 100644 index 0000000..bdaac68 --- /dev/null +++ b/iamerrors/iamerrors.go @@ -0,0 +1,89 @@ +package iamerrors + +import ( + "errors" + "fmt" +) + +var ( + ErrClientNoAuthOpts = errors.New("CLIENT_NO_AUTH_METHOD") + ErrAuthTokenUnathorized = errors.New("AUTH_TOKEN_UNAUTHORIZED") + + ErrUserNotFound = errors.New("USER_NOT_FOUND") + ErrDomainNotFound = errors.New("DOMAIN_NOT_FOUND") + ErrProjectNotFound = errors.New("PROJECT_NOT_FOUND") + ErrUserAlreadyExists = errors.New("USER_ALREADY_EXISTS") + ErrRequestValidationError = errors.New("REQUEST_VALIDATION_FAILED") + ErrForbidden = errors.New("REQUEST_FORBIDDEN") + ErrUnauthorized = errors.New("USER_UNAUTHORIZED") + ErrInternalServerError = errors.New("INTERNAL_SERVER_ERROR") + ErrCredentialNotFound = errors.New("CRED_NOT_FOUND") + + ErrUserIDRequired = errors.New("USER_ID_REQUIRED") + ErrProjectIDRequired = errors.New("PROJECT_ID_REQUIRED") + + ErrCredentialNameRequired = errors.New("CREDENTIAL_NAME_REQUIRED") + ErrCredentialAccessKeyRequired = errors.New("CREDENTIAL_ACCESS_KEY_REQUIRED") + + ErrServiceUserNameRequired = errors.New("SERVICE_USER_NAME_REQUIRED") + ErrServiceUserPasswordRequired = errors.New("SERVICE_USER_PASSWORD_REQUIRED") + ErrServiceUserRolesRequired = errors.New("SERVICE_USER_ROLES_REQUIRED") + + ErrUserRolesRequired = errors.New("USER_ROLES_REQUIRED") + ErrUserEmailRequired = errors.New("USER_EMAIL_REQUIRED") + + ErrInputDataRequired = errors.New("INPUT_DATA_REQUIRED") + + ErrInternalAppError = errors.New("INTERNAL_APP_ERROR") + + ErrUnknown = errors.New("UNKNOWN_ERROR") + + //nolint:gochecknoglobals // stringToError is not global. + stringToError = map[string]error{ + ErrUserNotFound.Error(): ErrUserNotFound, + ErrClientNoAuthOpts.Error(): ErrClientNoAuthOpts, + ErrAuthTokenUnathorized.Error(): ErrAuthTokenUnathorized, + ErrDomainNotFound.Error(): ErrDomainNotFound, + ErrCredentialNotFound.Error(): ErrCredentialNotFound, + ErrProjectNotFound.Error(): ErrProjectNotFound, + ErrUserAlreadyExists.Error(): ErrUserAlreadyExists, + ErrRequestValidationError.Error(): ErrRequestValidationError, + ErrForbidden.Error(): ErrForbidden, + ErrUnauthorized.Error(): ErrUnauthorized, + ErrInternalServerError.Error(): ErrInternalServerError, + ErrCredentialNameRequired.Error(): ErrCredentialNameRequired, + ErrCredentialAccessKeyRequired.Error(): ErrCredentialAccessKeyRequired, + ErrUserIDRequired.Error(): ErrUserIDRequired, + ErrProjectIDRequired.Error(): ErrProjectIDRequired, + ErrServiceUserNameRequired.Error(): ErrServiceUserNameRequired, + ErrServiceUserPasswordRequired.Error(): ErrServiceUserPasswordRequired, + ErrServiceUserRolesRequired.Error(): ErrServiceUserRolesRequired, + ErrUserRolesRequired.Error(): ErrUserRolesRequired, + ErrUserEmailRequired.Error(): ErrUserEmailRequired, + ErrInputDataRequired.Error(): ErrInputDataRequired, + ErrInternalAppError.Error(): ErrInternalAppError, + ErrUnknown.Error(): ErrUnknown, + } +) + +func GetError(errorString string) error { + err, ok := stringToError[errorString] + if !ok { + return nil + } + return err +} + +// Error represents an error returned by the IAM API. It contains a human-readable description of the error. +type Error struct { + Err error + Desc string +} + +func (e Error) Error() string { + return fmt.Sprintf("iam-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/internal/client/auth.go b/internal/client/auth.go new file mode 100644 index 0000000..10161f5 --- /dev/null +++ b/internal/client/auth.go @@ -0,0 +1,16 @@ +package client + +// AuthMethod is implemented by all authentication methods. +type AuthMethod interface { + GetKeystoneToken() string +} + +// KeystoneTokenAuth represents Keystone token authentication method. +// It conforms to AuthMethod interface. +type KeystoneTokenAuth struct { + KeystoneToken string +} + +func (k KeystoneTokenAuth) GetKeystoneToken() string { + return k.KeystoneToken +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..f988f41 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,107 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/selectel/iam-go/iamerrors" +) + +type DoRequestInput struct { + Body io.Reader + Method string + URL string +} + +type BaseClient struct { + // HTTPClient represents the HTTP client used to make requests. + HTTPClient *http.Client + + // APIUrl represents a valid IAM API URL, which will be used in all requests. + APIUrl string + + // AuthMethod contains an approach to authenticate against Selectel IAM API based on AuthOpts. + AuthMethod AuthMethod + + // UserAgent represents a User-Agent to be added to all requests. + UserAgent string + + // UserAgentPrefix contains custom prefix to be added to userAgent. + UserAgentPrefix string +} + +// DoRequest performs the HTTP request with the current Client.HTTPClient and given User-Agent prefix. +// +// X-Auth-Token and other optional headers are added automatically. +func (bc *BaseClient) DoRequest(ctx context.Context, input DoRequestInput) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, input.Method, input.URL, input.Body) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + request.Header.Set("X-Auth-Token", bc.AuthMethod.GetKeystoneToken()) + if input.Body != nil { + request.Header.Set("Content-Type", "application/json") + } + + request.Header.Set("User-Agent", bc.UserAgent) + + response, err := bc.HTTPClient.Do(request) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + if response.StatusCode >= 400 { + err := decodeError(response.StatusCode, body) + return nil, err + } + + return body, nil +} + +func decodeError(statusCode int, body []byte) error { + if statusCode == http.StatusUnauthorized { + errDescription := string(body) + return iamerrors.Error{Err: iamerrors.ErrAuthTokenUnathorized, Desc: errDescription} + } + + var eg ErrorGeneric + err := UnmarshalJSON(body, &eg) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + if e := iamerrors.GetError(eg.Code); e != nil { + return iamerrors.Error{Err: e, Desc: eg.Message} + } + return iamerrors.Error{Err: iamerrors.ErrUnknown, Desc: fmt.Sprintf("%s -- %s", eg.Code, eg.Message)} +} + +// ErrorGeneric represents an error returned by the IAM API. +type ErrorGeneric struct { + // Code is a short name of error. + Code string `json:"code"` + + // Message describes the reason of error. + Message string `json:"message"` + + // ErrorDescription represents a human-readable description of the error. + ErrorDescription error `json:"-"` +} + +// UnmarshalJSON accepts an object in which ResposeResult.Body will be extracted. +func UnmarshalJSON(body []byte, to interface{}) error { + err := json.Unmarshal(body, to) + if err != nil { + return fmt.Errorf("UnmarshalJSON() — Unmarshal error: %w", err) + } + return nil +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..18d4a46 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,112 @@ +package client + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client/testdata" +) + +func TestDoRequest(t *testing.T) { + type args struct { + body io.Reader + method string + url string + } + tests := []struct { + name string + args args + prepare func() + expectedBody []byte + expectedError error + }{ + { + name: "Test DoRequest GET method", + args: args{ + method: http.MethodGet, + url: testdata.TestURL, + body: nil, + }, + prepare: func() { + httpmock.RegisterResponder(http.MethodGet, testdata.TestURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(200, testdata.TestDoRequestRaw) + return resp, nil + }) + }, + expectedBody: []byte(testdata.TestDoRequestRaw), + expectedError: nil, + }, + { + name: "Test DoRequest POST method", + args: args{ + method: http.MethodPost, + url: testdata.TestURL, + body: bytes.NewReader([]byte(testdata.TestDoRequestRaw)), + }, + prepare: func() { + httpmock.RegisterResponder(http.MethodPost, testdata.TestURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(200, testdata.TestDoRequestRaw) + return resp, nil + }) + }, + expectedBody: []byte(testdata.TestDoRequestRaw), + expectedError: nil, + }, + { + name: "Test DoRequest GET method return Error", + args: args{ + method: http.MethodGet, + url: testdata.TestURL, + body: nil, + }, + prepare: func() { + httpmock.RegisterResponder(http.MethodGet, testdata.TestURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(403, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedBody: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + baseClient := &BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: tt.args.url, + AuthMethod: &KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + UserAgent: testdata.TestUserAgent, + } + + httpmock.ActivateNonDefault(baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actualBody, err := baseClient.DoRequest(ctx, DoRequestInput{ + Body: tt.args.body, + Method: tt.args.method, + URL: baseClient.APIUrl, + }) + + require.ErrorIs(err, tt.expectedError) + assert.Equal(tt.expectedBody, actualBody) + }) + } +} diff --git a/internal/client/testdata/fixtures.go b/internal/client/testdata/fixtures.go new file mode 100644 index 0000000..c91c4d0 --- /dev/null +++ b/internal/client/testdata/fixtures.go @@ -0,0 +1,17 @@ +package testdata + +const ( + TestToken = "test-token" + TestURL = "http://example.org/" + TestUserAgent = "iam-go/v0.0.1" +) + +const TestDoRequestRaw = `{ + "id": "test-id", + "name": "test-name" +}` + +const TestDoRequestErr = `{ + "code": "REQUEST_FORBIDDEN", + "message": "You don't have permission to do this" +}` \ No newline at end of file diff --git a/service/ec2/doc.go b/service/ec2/doc.go new file mode 100644 index 0000000..dc365db --- /dev/null +++ b/service/ec2/doc.go @@ -0,0 +1,2 @@ +// Package ec2 provides a set of functions for interacting with the Selectel EC2 API. +package ec2 diff --git a/service/ec2/requests.go b/service/ec2/requests.go new file mode 100644 index 0000000..3aa5523 --- /dev/null +++ b/service/ec2/requests.go @@ -0,0 +1,120 @@ +package ec2 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" +) + +// EC2 is used to communicate with the EC2(S3) API. +type EC2 struct { + baseClient *client.BaseClient +} + +// Initialises EC2 with the given client. +func New(baseClient *client.BaseClient) *EC2 { + return &EC2{ + baseClient: baseClient, + } +} + +// List returns a list of EC2-credentials for the given user. +func (ec2 *EC2) List(ctx context.Context, userID string) ([]Credential, error) { + if userID == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + + url, err := url.JoinPath(ec2.baseClient.APIUrl, "service_users", userID, "credentials") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := ec2.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodGet, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var credentials listResponse + err = client.UnmarshalJSON(response, &credentials) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return credentials.Credentials, nil +} + +// Create creates a new EC2-credential for the given user. +func (ec2 *EC2) Create(ctx context.Context, userID, name, projectID string) (*CreatedCredential, error) { + if userID == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + if name == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrCredentialNameRequired, Desc: "No credential name was provided."} + } + if projectID == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrProjectIDRequired, Desc: "No projectID was provided."} + } + + url, err := url.JoinPath(ec2.baseClient.APIUrl, "service_users", userID, "credentials") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + body, err := json.Marshal(createRequest{Name: name, ProjectID: projectID}) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := ec2.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: http.MethodPost, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var createdCredential CreatedCredential + err = client.UnmarshalJSON(response, &createdCredential) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &createdCredential, nil +} + +// Delete deletes an EC2-credential for the given user. +func (ec2 *EC2) Delete(ctx context.Context, userID, accessKey string) error { + if userID == "" { + return iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + if accessKey == "" { + return iamerrors.Error{Err: iamerrors.ErrCredentialAccessKeyRequired, Desc: "No accessKey was provided."} + } + + url, err := url.JoinPath(ec2.baseClient.APIUrl, "service_users", userID, "credentials", accessKey) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = ec2.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodDelete, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} diff --git a/service/ec2/requests_test.go b/service/ec2/requests_test.go new file mode 100644 index 0000000..3672f90 --- /dev/null +++ b/service/ec2/requests_test.go @@ -0,0 +1,243 @@ +package ec2 + +import ( + "context" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/ec2/testdata" +) + +const ( + credentialsURL = "/service_users/1/credentials" +) + +func TestList(t *testing.T) { + type args struct { + userID string + } + tests := []struct { + name string + args args + prepare func() + expectedResponse []Credential + expectedError error + }{ + { + name: "Test List return output", + args: args{ + userID: "1", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+credentialsURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestGetCredentialsResponse) + return resp, nil + }) + }, + expectedResponse: []Credential{ + { + Name: "12345", + ProjectID: "test-project", + AccessKey: "test-access-key", + }, + }, + expectedError: nil, + }, + { + name: "Test List return error", + args: args{ + userID: "1", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+credentialsURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + ec2API := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(ec2API.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actualResponse, err := ec2API.List(ctx, tt.args.userID) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actualResponse) + }) + } +} + +func TestCreate(t *testing.T) { + type args struct { + userID string + name string + projectID string + } + tests := []struct { + name string + args args + prepare func() + expectedResponse *CreatedCredential + expectedError error + }{ + { + name: "Test Create return output", + args: args{ + userID: "1", + name: "12345", + projectID: "test-project", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+credentialsURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestCreateCredentialResponse) + return resp, nil + }) + }, + expectedResponse: &CreatedCredential{ + Name: "12345", + ProjectID: "test-project", + AccessKey: "test-access-key", + SecretKey: "test-secret-key", + }, + expectedError: nil, + }, + { + name: "Test Create return error", + args: args{ + userID: "1", + name: "12345", + projectID: "test-project", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+credentialsURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + ec2API := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(ec2API.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actualResponse, err := ec2API.Create(ctx, tt.args.userID, tt.args.name, tt.args.projectID) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actualResponse) + }) + } +} + +func TestDelete(t *testing.T) { + type args struct { + userID string + accessKey string + } + tests := []struct { + name string + args args + prepare func() + expectedError error + }{ + { + name: "Test Delete return output", + args: args{ + userID: "1", + accessKey: "test-access-key", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+credentialsURL+"/test-access-key", + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + defer resp.Body.Close() + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "Test Delete return error", + args: args{ + userID: "1", + accessKey: "test-access-key", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+credentialsURL+"/test-access-key", + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + ec2API := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(ec2API.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := ec2API.Delete(ctx, tt.args.userID, tt.args.accessKey) + + require.ErrorIs(err, tt.expectedError) + }) + } +} diff --git a/service/ec2/schemas.go b/service/ec2/schemas.go new file mode 100644 index 0000000..dee9153 --- /dev/null +++ b/service/ec2/schemas.go @@ -0,0 +1,26 @@ +package ec2 + +// CreatedCredential represents a EC2-credential for the given user. +// It contains "secret_key" field, which appears only once at the end of creating. +type CreatedCredential struct { + Name string `json:"name"` + ProjectID string `json:"project_id"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` +} + +// Credential represents an EC2-credential for the given user. +type Credential struct { + Name string `json:"name"` + ProjectID string `json:"project_id"` + AccessKey string `json:"access_key"` +} + +type createRequest struct { + Name string `json:"name,omitempty"` + ProjectID string `json:"project_id,omitempty"` +} + +type listResponse struct { + Credentials []Credential `json:"credentials"` +} diff --git a/service/ec2/testdata/fixtures.go b/service/ec2/testdata/fixtures.go new file mode 100644 index 0000000..b641cf9 --- /dev/null +++ b/service/ec2/testdata/fixtures.go @@ -0,0 +1,27 @@ +package testdata + +const ( + TestToken = "test-token" + TestURL = "http://example.org" +) + +const TestGetCredentialsResponse = `{ + "credentials": [{ + "name": "12345", + "project_id": "test-project", + "access_key": "test-access-key" + }] +}` + +// nolint gosec complains +const TestCreateCredentialResponse = `{ + "name": "12345", + "project_id": "test-project", + "access_key": "test-access-key", + "secret_key": "test-secret-key" +}` + +const TestDoRequestErr = `{ + "code": "REQUEST_FORBIDDEN", + "message": "You don't have permission to do this" +}` diff --git a/service/serviceusers/doc.go b/service/serviceusers/doc.go new file mode 100644 index 0000000..d71833b --- /dev/null +++ b/service/serviceusers/doc.go @@ -0,0 +1,2 @@ +// Package serviceusers provides a set of functions for interacting with the Selectel Service Users API. +package serviceusers diff --git a/service/serviceusers/requests.go b/service/serviceusers/requests.go new file mode 100644 index 0000000..e9383d4 --- /dev/null +++ b/service/serviceusers/requests.go @@ -0,0 +1,240 @@ +package serviceusers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" +) + +// ServiceUsers is used to communicate with the Service Users API. +type ServiceUsers struct { + baseClient *client.BaseClient +} + +// Initialises ServiceUsers with the given client. +func New(baseClient *client.BaseClient) *ServiceUsers { + return &ServiceUsers{ + baseClient: baseClient, + } +} + +// List returns a list of Service Users for the account. +func (su *ServiceUsers) List(ctx context.Context) ([]ServiceUser, error) { + url, err := url.JoinPath(su.baseClient.APIUrl, "service_users") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := su.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodGet, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var users listResponse + err = client.UnmarshalJSON(response, &users) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return users.Users, nil +} + +// Get returns an info about Service User with the selectel userID. +func (su *ServiceUsers) Get(ctx context.Context, userID string) (*ServiceUser, error) { + if userID == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + + url, err := url.JoinPath(su.baseClient.APIUrl, "service_users", userID) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := su.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodGet, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var user ServiceUser + err = client.UnmarshalJSON(response, &user) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &user, nil +} + +// Create creates a new Service User. +func (su *ServiceUsers) Create(ctx context.Context, input CreateRequest) (*ServiceUser, error) { + if input.Name == "" { + return nil, iamerrors.Error{ + Err: iamerrors.ErrServiceUserNameRequired, Desc: "No name for Service User was provided.", + } + } + if input.Password == "" { + return nil, iamerrors.Error{ + Err: iamerrors.ErrServiceUserPasswordRequired, Desc: "No password for Service User was provided.", + } + } + + url, err := url.JoinPath(su.baseClient.APIUrl, "service_users") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + body, err := json.Marshal(&createRequest{ + Enabled: input.Enabled, + Name: input.Name, + Password: input.Password, + Roles: input.Roles, + }) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := su.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: http.MethodPost, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var createdUser ServiceUser + err = client.UnmarshalJSON(response, &createdUser) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &createdUser, nil +} + +// Delete deletes a Service User from the account. +func (su *ServiceUsers) Delete(ctx context.Context, userID string) error { + if userID == "" { + return iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + + url, err := url.JoinPath(su.baseClient.APIUrl, "service_users", userID) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = su.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodDelete, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} + +// Update updates the info for a Service User with the given userID. +func (su *ServiceUsers) Update(ctx context.Context, userID string, input UpdateRequest) (*ServiceUser, error) { + if userID == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + + url, err := url.JoinPath(su.baseClient.APIUrl, "service_users", userID) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + body, err := json.Marshal(&updateRequest{ + Enabled: &input.Enabled, + Name: input.Name, + Password: input.Password, + }) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := su.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: http.MethodPatch, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var updatedUser ServiceUser + err = client.UnmarshalJSON(response, &updatedUser) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &updatedUser, nil +} + +// AssignRoles adds new roles for a Service User with the given userID. +func (su *ServiceUsers) AssignRoles(ctx context.Context, userID string, roles []Role) error { + if userID == "" { + return iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + if len(roles) == 0 { + return iamerrors.Error{ + Err: iamerrors.ErrServiceUserRolesRequired, + Desc: "No roles for Service User was provided.", + } + } + + return su.manageRoles(ctx, http.MethodPut, userID, roles) +} + +// UnassignRoles removes roles from a Service User with the given userID. +func (su *ServiceUsers) UnassignRoles(ctx context.Context, userID string, roles []Role) error { + if userID == "" { + return iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + if len(roles) == 0 { + return iamerrors.Error{ + Err: iamerrors.ErrServiceUserRolesRequired, + Desc: "No roles for Service User was provided.", + } + } + + return su.manageRoles(ctx, http.MethodDelete, userID, roles) +} + +func (su *ServiceUsers) manageRoles(ctx context.Context, method string, userID string, roles []Role) error { + url, err := url.JoinPath(su.baseClient.APIUrl, "service_users", userID, "roles") + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + body, err := json.Marshal(manageRolesRequest{ + Roles: roles, + }) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = su.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: method, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} diff --git a/service/serviceusers/requests_test.go b/service/serviceusers/requests_test.go new file mode 100644 index 0000000..5765182 --- /dev/null +++ b/service/serviceusers/requests_test.go @@ -0,0 +1,575 @@ +package serviceusers + +import ( + "context" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/serviceusers/testdata" +) + +const ( + serviceUsersURL = "/service_users" + serviceUsersIDURL = "/service_users/123" + serviceUsersRolesURL = "/service_users/123/roles" +) + +func TestList(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedResponse []ServiceUser + expectedError error + }{ + { + name: "Test List return output", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+serviceUsersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestGetUsersResponse) + return resp, nil + }) + }, + expectedResponse: []ServiceUser{ + { + Name: "test", + Enabled: true, + ID: "123", + Roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + }, + expectedError: nil, + }, + { + name: "Test List return error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+serviceUsersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + serviceUsersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(serviceUsersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := serviceUsersAPI.List(ctx) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestGet(t *testing.T) { + type args struct { + userID string + } + tests := []struct { + name string + args args + prepare func() + expectedResponse *ServiceUser + expectedError error + }{ + { + name: "Test Get return output", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+serviceUsersIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestGetUserResponse) + return resp, nil + }) + }, + expectedResponse: &ServiceUser{ + Name: "test", + Enabled: true, + ID: "123", + Roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + expectedError: nil, + }, + { + name: "Test Get return error", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+serviceUsersIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + serviceUsersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(serviceUsersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := serviceUsersAPI.Get(ctx, tt.args.userID) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestDelete(t *testing.T) { + type args struct { + userID string + } + tests := []struct { + name string + args args + prepare func() + expectedError error + }{ + { + name: "Test Delete return output", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+serviceUsersIDURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "Test Delete return error", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+serviceUsersIDURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + serviceUsersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(serviceUsersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := serviceUsersAPI.Delete(ctx, tt.args.userID) + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +//nolint:funlen // This is a test function. +func TestCreate(t *testing.T) { + type args struct { + enabled bool + name string + password string + roles []Role + } + tests := []struct { + name string + args args + prepare func() + expectedResponse *ServiceUser + expectedError error + }{ + { + name: "Test Create return output", + args: args{ + enabled: true, + name: "test", + password: "Qazwsxedc123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+serviceUsersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestCreateUserResponse) + return resp, nil + }) + }, + expectedResponse: &ServiceUser{ + Name: "test", + Enabled: true, + ID: "123", + Roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + expectedError: nil, + }, + { + name: "Test Create return insecure password", + args: args{ + enabled: true, + name: "test", + password: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+serviceUsersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse( + http.StatusBadRequest, + testdata.TestCreateUserInsecurePasswordErr, + ) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrRequestValidationError, + }, + { + name: "Test Create return error", + args: args{ + enabled: true, + name: "test", + password: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+serviceUsersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + serviceUsersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(serviceUsersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := serviceUsersAPI.Create(ctx, CreateRequest{ + Name: tt.args.name, + Enabled: tt.args.enabled, + Password: tt.args.password, + Roles: tt.args.roles, + }) + + require.ErrorIs(err, tt.expectedError) + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestUpdate(t *testing.T) { + type args struct { + userID string + enabled bool + name string + password string + } + tests := []struct { + name string + args args + prepare func() + expectedResponse *ServiceUser + expectedError error + }{ + { + name: "Test Update return output", + args: args{ + userID: "123", + enabled: true, + name: "test1", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPatch, testdata.TestURL+serviceUsersIDURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestUpdateUserResponse) + return resp, nil + }) + }, + expectedResponse: &ServiceUser{ + Name: "test1", + Enabled: true, + ID: "123", + }, + expectedError: nil, + }, + { + name: "Test Update return error", + args: args{ + userID: "123", + enabled: true, + name: "test", + password: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPatch, testdata.TestURL+serviceUsersIDURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + serviceUsersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(serviceUsersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + tt.prepare() + + ctx := context.Background() + actual, err := serviceUsersAPI.Update(ctx, tt.args.userID, UpdateRequest{ + Enabled: tt.args.enabled, + Name: tt.args.name, + Password: tt.args.password, + }) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestAssignRoles(t *testing.T) { + type args struct { + userID string + roles []Role + } + tests := []struct { + name string + args args + prepare func() + expectedError error + }{ + { + name: "Test AssignRoles return output", + args: args{ + userID: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPut, testdata.TestURL+serviceUsersRolesURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "Test AssignRoles return error", + args: args{ + userID: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPut, testdata.TestURL+serviceUsersRolesURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + serviceUsersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(serviceUsersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := serviceUsersAPI.AssignRoles(ctx, tt.args.userID, tt.args.roles) + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +func TestUnassignRoles(t *testing.T) { + type args struct { + userID string + roles []Role + } + tests := []struct { + name string + args args + prepare func() + expectedError error + }{ + { + name: "Test UnassignRoles return output", + args: args{ + userID: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+serviceUsersRolesURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "Test UnassignRoles return error", + args: args{ + userID: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+serviceUsersRolesURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + serviceUsersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(serviceUsersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + tt.prepare() + + ctx := context.Background() + err := serviceUsersAPI.UnassignRoles(ctx, tt.args.userID, tt.args.roles) + + require.ErrorIs(err, tt.expectedError) + }) + } +} diff --git a/service/serviceusers/schemas.go b/service/serviceusers/schemas.go new file mode 100644 index 0000000..6393a72 --- /dev/null +++ b/service/serviceusers/schemas.go @@ -0,0 +1,87 @@ +package serviceusers + +type RoleName string + +const ( + // Account owner. + AccountOwner RoleName = "account_owner" + + // User administrator. + IAMAdmin RoleName = "iam_admin" + + // Account/Project administrator. + Member RoleName = "member" + + // Account/Project reader. + Reader RoleName = "reader" + + // Billing administrator. + Billing RoleName = "billing" + + // Object storage administrator. + ObjectStorageAdmin RoleName = "object_storage:admin" + + // Object storage user. + ObjectStorageUser RoleName = "object_storage_user" +) + +type Scope string + +const ( + // Project scope. + Project Scope = "project" + + // Account scope. + Account Scope = "account" +) + +// ServiceUser represents a Selectel Service User. +type ServiceUser struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Name string `json:"name"` + Roles []Role `json:"roles"` +} + +type Role struct { + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + RoleName RoleName `json:"role_name"` + Scope Scope `json:"scope"` +} + +// CreateRequest is used to set options for Create method. +type CreateRequest struct { + Enabled bool + Name string + Password string + Roles []Role +} + +// UpdateRequest is used to set options for Update method. +type UpdateRequest struct { + Enabled bool + Name string + Password string +} + +type createRequest struct { + Enabled bool `json:"enabled,omitempty"` + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` + Roles []Role `json:"roles,omitempty"` +} + +type updateRequest struct { + Enabled *bool `json:"enabled,omitempty"` + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` +} + +type manageRolesRequest struct { + Roles []Role `json:"roles"` +} + +type listResponse struct { + Users []ServiceUser `json:"users"` +} diff --git a/service/serviceusers/testdata/fixtures.go b/service/serviceusers/testdata/fixtures.go new file mode 100644 index 0000000..3c40b2f --- /dev/null +++ b/service/serviceusers/testdata/fixtures.go @@ -0,0 +1,63 @@ +package testdata + +const ( + TestToken = "test-token" + TestURL = "http://example.org" +) + +const TestGetUsersResponse = `{ + "users": [ + { + "name": "test", + "enabled": true, + "id": "123", + "roles": [ + { + "scope": "account", + "role_name": "member" + } + ] + } + ] +}` + +const TestGetUserResponse = `{ + "name": "test", + "enabled": true, + "id": "123", + "roles": [ + { + "scope": "account", + "role_name": "member" + } + ] +}` + +const TestCreateUserResponse = `{ + "name": "test", + "enabled": true, + "id": "123", + "roles": [ + { + "scope": "account", + "role_name": "member" + } + ] +}` + +const TestUpdateUserResponse = `{ + "name": "test1", + "enabled": true, + "id": "123" +}` + +const TestDoRequestErr = `{ + "code": "REQUEST_FORBIDDEN", + "message": "You don't have permission to do this" +}` + +// nolint gosec complains +const TestCreateUserInsecurePasswordErr = `{ + "code": "REQUEST_VALIDATION_FAILED", + "message": "insecure_password" +}` diff --git a/service/users/doc.go b/service/users/doc.go new file mode 100644 index 0000000..54b89cf --- /dev/null +++ b/service/users/doc.go @@ -0,0 +1,2 @@ +// Package Users provides a set of functions for interacting with the Selectel Users API. +package users diff --git a/service/users/requests.go b/service/users/requests.go new file mode 100644 index 0000000..393239d --- /dev/null +++ b/service/users/requests.go @@ -0,0 +1,217 @@ +package users + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" +) + +// Users is used to communicate with the Users API. +type Users struct { + baseClient *client.BaseClient +} + +// Initialises Users with the given client. +func New(baseClient *client.BaseClient) *Users { + return &Users{ + baseClient: baseClient, + } +} + +// List returns a list of Users for the account. +func (u *Users) List(ctx context.Context) ([]User, error) { + url, err := url.JoinPath(u.baseClient.APIUrl, "users") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := u.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodGet, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var users listResponse + err = client.UnmarshalJSON(response, &users) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return users.Users, nil +} + +// Get returns an info of User with the selectel userID. +func (u *Users) Get(ctx context.Context, userID string) (*User, error) { + if userID == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + + url, err := url.JoinPath(u.baseClient.APIUrl, "users", userID) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := u.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodGet, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var user User + err = client.UnmarshalJSON(response, &user) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &user, nil +} + +// Create creates a new User. +func (u *Users) Create(ctx context.Context, input CreateRequest) (*User, error) { + if input.Email == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrUserEmailRequired, Desc: "No email for User was provided."} + } + + url, err := url.JoinPath(u.baseClient.APIUrl, "users") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + body, err := json.Marshal(&createRequest{ + AuthType: input.AuthType, + Email: input.Email, + Federation: input.Federation, + Roles: input.Roles, + SubscriptionsOnly: false, // Issue, should be hardcoded + Subscriptions: []string{}, // Issue, should be hardcoded + }) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := u.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: http.MethodPost, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var createdUser User + err = client.UnmarshalJSON(response, &createdUser) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &createdUser, nil +} + +// Delete deletes a User from the account. +func (u *Users) Delete(ctx context.Context, userID string) error { + if userID == "" { + return iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + + url, err := url.JoinPath(u.baseClient.APIUrl, "users", userID) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = u.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodDelete, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} + +// ResendInvite sends a confirmation email again. +func (u *Users) ResendInvite(ctx context.Context, userID string) error { + if userID == "" { + return iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + + url, err := url.JoinPath(u.baseClient.APIUrl, "users", userID, "resend_invite") + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = u.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodPatch, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} + +// AssignRoles adds new roles for a User with the given userID. +func (u *Users) AssignRoles(ctx context.Context, userID string, roles []Role) error { + if userID == "" { + return iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + + if len(roles) == 0 { + return iamerrors.Error{Err: iamerrors.ErrUserRolesRequired, Desc: "No roles for User was provided."} + } + + return u.manageRoles(ctx, http.MethodPut, userID, roles) +} + +// UnassignRoles removes roles from a User with the given userID. +func (u *Users) UnassignRoles(ctx context.Context, userID string, roles []Role) error { + if userID == "" { + return iamerrors.Error{Err: iamerrors.ErrUserIDRequired, Desc: "No userID was provided."} + } + if len(roles) == 0 { + return iamerrors.Error{Err: iamerrors.ErrUserRolesRequired, Desc: "No roles for User was provided."} + } + + return u.manageRoles(ctx, http.MethodDelete, userID, roles) +} + +func (u *Users) manageRoles(ctx context.Context, method string, userID string, roles []Role) error { + url, err := url.JoinPath(u.baseClient.APIUrl, "users", userID, "roles") + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + body, err := json.Marshal(manageRolesRequest{ + Roles: roles, + }) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = u.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: method, + URL: url, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} diff --git a/service/users/requests_test.go b/service/users/requests_test.go new file mode 100644 index 0000000..6378815 --- /dev/null +++ b/service/users/requests_test.go @@ -0,0 +1,535 @@ +package users + +import ( + "context" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/users/testdata" +) + +const ( + usersURL = "/users" + usersIDURL = "/users/123" + rolesURL = "/users/123/roles" + resendInviteURL = "/users/123/resend_invite" +) + +func TestList(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedResponse []User + expectedError error + }{ + { + name: "Test List return output", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+usersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestGetUsersResponse) + return resp, nil + }) + }, + expectedResponse: []User{ + { + AuthType: "local", + KeystoneID: "123", + ID: "123", + Roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + }, + expectedError: nil, + }, + { + name: "Test List return error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+usersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + usersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(usersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := usersAPI.List(ctx) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestGet(t *testing.T) { + type args struct { + userID string + } + tests := []struct { + name string + args args + prepare func() + expectedResponse *User + expectedError error + }{ + { + name: "Test Get return output", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+usersIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestGetUserResponse) + return resp, nil + }) + }, + expectedResponse: &User{ + AuthType: "local", + KeystoneID: "123", + ID: "123", + Roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + expectedError: nil, + }, + { + name: "Test Get return error", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+usersIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + usersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(usersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := usersAPI.Get(ctx, tt.args.userID) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestDelete(t *testing.T) { + type args struct { + userID string + } + tests := []struct { + name string + args args + prepare func() + expectedError error + }{ + { + name: "Test Delete return output", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+usersIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "Test Delete return error", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+usersIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + usersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(usersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := usersAPI.Delete(ctx, tt.args.userID) + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +func TestCreate(t *testing.T) { + type args struct { + authType AuthType + email string + federation Federation + roles []Role + } + tests := []struct { + name string + args args + prepare func() + expectedResponse *User + expectedError error + }{ + { + name: "Test Create return output", + args: args{ + authType: "federated", + email: "test@mail", + federation: Federation{ + ExternalID: "123", + ID: "123", + }, + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+usersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestCreateUserResponse) + return resp, nil + }) + }, + expectedResponse: &User{ + AuthType: "federated", + Federation: &Federation{ + ExternalID: "123", + ID: "123", + }, + ID: "123", + KeystoneID: "123", + Roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + expectedError: nil, + }, + { + name: "Test Create return error", + args: args{ + authType: "federated", + email: "test@mail", + federation: Federation{ + ExternalID: "123", + ID: "123", + }, + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+usersURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + usersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(usersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + ctx := context.Background() + + //nolint:gosec // This is just a test + actual, err := usersAPI.Create(ctx, CreateRequest{ + AuthType: tt.args.authType, + Email: tt.args.email, + Federation: &tt.args.federation, + Roles: tt.args.roles, + }) + require.ErrorIs(err, tt.expectedError) + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestResendInvite(t *testing.T) { + type args struct { + userID string + } + tests := []struct { + name string + args args + prepare func() + expectedError error + }{ + { + name: "Test ResendInvite return nil", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPatch, testdata.TestURL+resendInviteURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "Test ResendInvite return error", + args: args{ + userID: "123", + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPatch, testdata.TestURL+resendInviteURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + usersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(usersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := usersAPI.ResendInvite(ctx, tt.args.userID) + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +func TestAssignRoles(t *testing.T) { + type args struct { + userID string + roles []Role + } + tests := []struct { + name string + args args + prepare func() + expectedError error + }{ + { + name: "Test AssignRoles return output", + args: args{ + userID: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPut, testdata.TestURL+rolesURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "Test AssignRoles return error", + args: args{ + userID: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodPut, testdata.TestURL+rolesURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + usersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(usersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := usersAPI.AssignRoles(ctx, tt.args.userID, tt.args.roles) + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +func TestUnassignRoles(t *testing.T) { + type args struct { + userID string + roles []Role + } + tests := []struct { + name string + args args + prepare func() + expectedError error + }{ + { + name: "Test UnassignRoles return output", + args: args{ + userID: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+rolesURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "Test UnassignRoles return error", + args: args{ + userID: "123", + roles: []Role{ + {Scope: Account, RoleName: Member}, + }, + }, + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+rolesURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + usersAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(usersAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := usersAPI.UnassignRoles(ctx, tt.args.userID, tt.args.roles) + + require.ErrorIs(err, tt.expectedError) + }) + } +} diff --git a/service/users/schemas.go b/service/users/schemas.go new file mode 100644 index 0000000..86912e5 --- /dev/null +++ b/service/users/schemas.go @@ -0,0 +1,83 @@ +package users + +type AuthType string + +const ( + Local AuthType = "local" + Federated AuthType = "federated" +) + +type RoleName string + +const ( + // Account owner. + AccountOwner RoleName = "account_owner" + + // User administrator. + IAMAdmin RoleName = "iam_admin" + + // Account/Project administrator. + Member RoleName = "member" + + // Account/Project reader. + Reader RoleName = "reader" + + // Billing administrator. + Billing RoleName = "billing" +) + +type Scope string + +const ( + // Project scope. + Project Scope = "project" + + // Account scope. + Account Scope = "account" +) + +// User represents a Selectel Panel User. +type User struct { + AuthType AuthType `json:"auth_type"` + Federation *Federation `json:"federation,omitempty"` + Roles []Role `json:"roles"` + ID string `json:"id"` + KeystoneID string `json:"keystone_id"` +} + +type Federation struct { + ExternalID string `json:"external_id"` + ID string `json:"id"` +} + +type Role struct { + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + RoleName RoleName `json:"role_name"` + Scope Scope `json:"scope"` +} + +// CreateRequest is used to set options for Create method. +type CreateRequest struct { + AuthType AuthType + Email string + Federation *Federation + Roles []Role +} + +type createRequest struct { + AuthType AuthType `json:"auth_type,omitempty"` + Email string `json:"email,omitempty"` + Federation *Federation `json:"federation,omitempty"` + Roles []Role `json:"roles,omitempty"` + SubscriptionsOnly bool `json:"subscriptions_only"` // Issue, should be hardcoded to `false` + Subscriptions []string `json:"subscriptions"` // Issue, should be hardcoded to `[]` +} + +type manageRolesRequest struct { + Roles []Role `json:"roles"` +} + +type listResponse struct { + Users []User `json:"users"` +} diff --git a/service/users/testdata/fixtures.go b/service/users/testdata/fixtures.go new file mode 100644 index 0000000..6490344 --- /dev/null +++ b/service/users/testdata/fixtures.go @@ -0,0 +1,55 @@ +package testdata + +const ( + TestToken = "test-token" + TestURL = "http://example.org" +) + +const TestGetUsersResponse = `{ + "users": [ + { + "auth_type": "local", + "id": "123", + "keystone_id": "123", + "roles": [ + { + "scope": "account", + "role_name": "member" + } + ] + } + ] +}` + +const TestGetUserResponse = `{ + "auth_type": "local", + "id": "123", + "keystone_id": "123", + "roles": [ + { + "scope": "account", + "role_name": "member" + } + ] +}` + +const TestCreateUserResponse = `{ + "auth_type": "federated", + "keystone_id": "123", + "id": "123", + "roles": [ + { + "scope": "account", + "role_name": "member" + } + ], + "federation": { + "external_id": "123", + "id": "123" + } +}` + +const TestDoRequestErr = `{ + "code": "REQUEST_FORBIDDEN", + "message": "You don't have permission to do this" +}`