diff --git a/go.sum b/go.sum index 86e5991712..743794dae6 100644 --- a/go.sum +++ b/go.sum @@ -149,6 +149,7 @@ github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd h1:qMd81Ts1T github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= github.com/coreos/bbolt v1.3.2 h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -471,6 +472,7 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5i github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= diff --git a/secret/http_client.go b/secret/http_client.go new file mode 100644 index 0000000000..312fa4acb1 --- /dev/null +++ b/secret/http_client.go @@ -0,0 +1,88 @@ +package secret + +import ( + "context" + "fmt" + + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/kit/tracing" + "github.com/influxdata/influxdb/v2/pkg/httpc" +) + +type Client struct { + Client *httpc.Client +} + +// LoadSecret is not implemented for http +func (s *Client) LoadSecret(ctx context.Context, orgID influxdb.ID, k string) (string, error) { + return "", &influxdb.Error{ + Code: influxdb.EMethodNotAllowed, + Msg: "load secret is not implemented for http", + } +} + +// PutSecret is not implemented for http. +func (s *Client) PutSecret(ctx context.Context, orgID influxdb.ID, k string, v string) error { + return &influxdb.Error{ + Code: influxdb.EMethodNotAllowed, + Msg: "put secret is not implemented for http", + } +} + +// GetSecretKeys get all secret keys mathing an org ID via HTTP. +func (s *Client) GetSecretKeys(ctx context.Context, orgID influxdb.ID) ([]string, error) { + span, _ := tracing.StartSpanFromContext(ctx) + defer span.Finish() + + span.LogKV("org-id", orgID) + + path := fmt.Sprintf("/api/v2/orgs/%s/secrets", orgID.String()) + + var ss secretsResponse + err := s.Client. + Get(path). + DecodeJSON(&ss). + Do(ctx) + if err != nil { + return nil, err + } + + return ss.Secrets, nil +} + +// PutSecrets is not implemented for http. +func (s *Client) PutSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + return &influxdb.Error{ + Code: influxdb.EMethodNotAllowed, + Msg: "put secrets is not implemented for http", + } +} + +// PatchSecrets will update the existing secret with new via http. +func (s *Client) PatchSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + span, _ := tracing.StartSpanFromContext(ctx) + defer span.Finish() + + if orgID != 0 { + span.LogKV("org-id", orgID) + } + + path := fmt.Sprintf("/api/v2/orgs/%s/secrets", orgID.String()) + + return s.Client. + PatchJSON(m, path). + Do(ctx) +} + +// DeleteSecret removes a single secret via HTTP. +func (s *Client) DeleteSecret(ctx context.Context, orgID influxdb.ID, ks ...string) error { + span, _ := tracing.StartSpanFromContext(ctx) + defer span.Finish() + + path := fmt.Sprintf("/api/v2/orgs/%s/secrets/delete", orgID.String()) + return s.Client. + PostJSON(secretsDeleteBody{ + Secrets: ks, + }, path). + Do(ctx) +} diff --git a/secret/http_server.go b/secret/http_server.go new file mode 100644 index 0000000000..efe27a9116 --- /dev/null +++ b/secret/http_server.go @@ -0,0 +1,134 @@ +package secret + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi" + "github.com/influxdata/influxdb/v2" + kithttp "github.com/influxdata/influxdb/v2/kit/transport/http" + "go.uber.org/zap" +) + +type handler struct { + log *zap.Logger + svc influxdb.SecretService + api *kithttp.API + + idLookupKey string +} + +// NewHandler creates a new handler for the secret service +func NewHandler(log *zap.Logger, idLookupKey string, svc influxdb.SecretService) http.Handler { + h := &handler{ + log: log, + svc: svc, + api: kithttp.NewAPI(kithttp.WithLog(log)), + + idLookupKey: idLookupKey, + } + + r := chi.NewRouter() + + r.Get("/", h.handleGetSecrets) + r.Patch("/", h.handlePatchSecrets) + // TODO: this shouldn't be a post to delete + r.Post("/delete", h.handleDeleteSecrets) + return r +} + +// handleGetSecrets is the HTTP handler for the GET /api/v2/orgs/:id/secrets route. +func (h *handler) handleGetSecrets(w http.ResponseWriter, r *http.Request) { + orgID, err := h.decodeOrgID(r) + if err != nil { + h.api.Err(w, r, err) + } + + ks, err := h.svc.GetSecretKeys(r.Context(), orgID) + if err != nil && influxdb.ErrorCode(err) != influxdb.ENotFound { + h.api.Err(w, r, err) + return + } + + h.api.Respond(w, r, http.StatusOK, newSecretsResponse(orgID, ks)) +} + +type secretsResponse struct { + Links map[string]string `json:"links"` + Secrets []string `json:"secrets"` +} + +func newSecretsResponse(orgID influxdb.ID, ks []string) *secretsResponse { + if ks == nil { + ks = []string{} + } + return &secretsResponse{ + Links: map[string]string{ + "org": fmt.Sprintf("/api/v2/orgs/%s", orgID), + "self": fmt.Sprintf("/api/v2/orgs/%s/secrets", orgID), + }, + Secrets: ks, + } +} + +// handleGetPatchSecrets is the HTTP handler for the PATCH /api/v2/orgs/:id/secrets route. +func (h *handler) handlePatchSecrets(w http.ResponseWriter, r *http.Request) { + orgID, err := h.decodeOrgID(r) + if err != nil { + h.api.Err(w, r, err) + } + + var secrets map[string]string + if err := h.api.DecodeJSON(r.Body, &secrets); err != nil { + h.api.Err(w, r, err) + return + } + + if err := h.svc.PatchSecrets(r.Context(), orgID, secrets); err != nil { + h.api.Err(w, r, err) + return + } + + h.api.Respond(w, r, http.StatusNoContent, nil) +} + +type secretsDeleteBody struct { + Secrets []string `json:"secrets"` +} + +// handleDeleteSecrets is the HTTP handler for the DELETE /api/v2/orgs/:id/secrets route. +func (h *handler) handleDeleteSecrets(w http.ResponseWriter, r *http.Request) { + orgID, err := h.decodeOrgID(r) + if err != nil { + h.api.Err(w, r, err) + } + + var reqBody secretsDeleteBody + + if err := h.api.DecodeJSON(r.Body, &reqBody); err != nil { + h.api.Err(w, r, err) + return + } + + if err := h.svc.DeleteSecret(r.Context(), orgID, reqBody.Secrets...); err != nil { + h.api.Err(w, r, err) + return + } + + h.api.Respond(w, r, http.StatusNoContent, nil) +} + +func (h *handler) decodeOrgID(r *http.Request) (influxdb.ID, error) { + org := chi.URLParam(r, h.idLookupKey) + if org == "" { + return influxdb.InvalidID(), &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "url missing id", + } + } + id, err := influxdb.IDFromString(org) + if err != nil { + return influxdb.InvalidID(), err + } + return *id, nil +} diff --git a/secret/http_server_test.go b/secret/http_server_test.go new file mode 100644 index 0000000000..95a5b3e52e --- /dev/null +++ b/secret/http_server_test.go @@ -0,0 +1,337 @@ +package secret + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi" + "github.com/influxdata/influxdb/v2" + influxdbhttp "github.com/influxdata/influxdb/v2/http" + "github.com/influxdata/influxdb/v2/inmem" + "github.com/influxdata/influxdb/v2/mock" + influxdbtesting "github.com/influxdata/influxdb/v2/testing" + "go.uber.org/zap/zaptest" +) + +func initSecretService(f influxdbtesting.SecretServiceFields, t *testing.T) (influxdb.SecretService, func()) { + t.Helper() + s := inmem.NewKVStore() + + storage, err := NewStore(s) + if err != nil { + t.Fatal(err) + } + svc := NewService(storage) + + for _, s := range f.Secrets { + if err := svc.PutSecrets(context.Background(), s.OrganizationID, s.Env); err != nil { + t.Fatalf("failed to populate users") + } + } + + ctx := context.Background() + + for _, ss := range f.Secrets { + if err := svc.PutSecrets(ctx, ss.OrganizationID, ss.Env); err != nil { + t.Fatalf("failed to populate secrets") + } + } + + handler := NewHandler(zaptest.NewLogger(t), "id", svc) + router := chi.NewRouter() + router.Mount("/api/v2/orgs/{id}/secrets", handler) + server := httptest.NewServer(router) + httpClient, err := influxdbhttp.NewHTTPClient(server.URL, "", false) + if err != nil { + t.Fatal(err) + } + client := Client{ + Client: httpClient, + } + return &client, server.Close +} + +func TestSecretService(t *testing.T) { + t.Parallel() + influxdbtesting.GetSecretKeys(initSecretService, t) + influxdbtesting.PatchSecrets(initSecretService, t) + influxdbtesting.DeleteSecrets(initSecretService, t) +} + +func TestSecretService_handleGetSecrets(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + orgID influxdb.ID + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "get basic secrets", + fields: fields{ + &mock.SecretService{ + GetSecretKeysFn: func(ctx context.Context, orgID influxdb.ID) ([]string, error) { + return []string{"hello", "world"}, nil + }, + }, + }, + args: args{ + orgID: 1, + }, + wants: wants{ + statusCode: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: "{\n\t\"links\": {\n\t\t\"org\": \"/api/v2/orgs/0000000000000001\",\n\t\t\"self\": \"/api/v2/orgs/0000000000000001/secrets\"\n\t},\n\t\"secrets\": [\n\t\t\"hello\",\n\t\t\"world\"\n\t]\n}", + }, + }, + { + name: "get secrets when there are none", + fields: fields{ + &mock.SecretService{ + GetSecretKeysFn: func(ctx context.Context, orgID influxdb.ID) ([]string, error) { + return []string{}, nil + }, + }, + }, + args: args{ + orgID: 1, + }, + wants: wants{ + statusCode: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: "{\n\t\"links\": {\n\t\t\"org\": \"/api/v2/orgs/0000000000000001\",\n\t\t\"self\": \"/api/v2/orgs/0000000000000001/secrets\"\n\t},\n\t\"secrets\": []\n}", + }, + }, + { + name: "get secrets when organization has no secret keys", + fields: fields{ + &mock.SecretService{ + GetSecretKeysFn: func(ctx context.Context, orgID influxdb.ID) ([]string, error) { + return []string{}, &influxdb.Error{ + Code: influxdb.ENotFound, + Msg: "organization has no secret keys", + } + + }, + }, + }, + args: args{ + orgID: 1, + }, + wants: wants{ + statusCode: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: "{\n\t\"links\": {\n\t\t\"org\": \"/api/v2/orgs/0000000000000001\",\n\t\t\"self\": \"/api/v2/orgs/0000000000000001/secrets\"\n\t},\n\t\"secrets\": []\n}", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(zaptest.NewLogger(t), "id", tt.fields.SecretService) + router := chi.NewRouter() + router.Mount("/api/v2/orgs/{id}/secrets", h) + + u := fmt.Sprintf("http://any.url/api/v2/orgs/%s/secrets", tt.args.orgID) + r := httptest.NewRequest("GET", u, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, r) + + res := w.Result() + content := res.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(res.Body) + + if res.StatusCode != tt.wants.statusCode { + t.Errorf("handleGetSecrets() = %v, want %v", res.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("handleGetSecrets() = %v, want %v", content, tt.wants.contentType) + } + if tt.wants.body != "" { + if string(body) != tt.wants.body { + t.Errorf("%q. handleGetSecrets() invalid body: %q", tt.name, body) + } + } + }) + } +} + +func TestSecretService_handlePatchSecrets(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + orgID influxdb.ID + secrets map[string]string + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "get basic secrets", + fields: fields{ + &mock.SecretService{ + PatchSecretsFn: func(ctx context.Context, orgID influxdb.ID, s map[string]string) error { + return nil + }, + }, + }, + args: args{ + orgID: 1, + secrets: map[string]string{ + "abc": "123", + }, + }, + wants: wants{ + statusCode: http.StatusNoContent, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(zaptest.NewLogger(t), "id", tt.fields.SecretService) + router := chi.NewRouter() + router.Mount("/api/v2/orgs/{id}/secrets", h) + + b, err := json.Marshal(tt.args.secrets) + if err != nil { + t.Fatalf("failed to marshal secrets: %v", err) + } + + buf := bytes.NewReader(b) + u := fmt.Sprintf("http://any.url/api/v2/orgs/%s/secrets", tt.args.orgID) + r := httptest.NewRequest("PATCH", u, buf) + w := httptest.NewRecorder() + + router.ServeHTTP(w, r) + + res := w.Result() + content := res.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(res.Body) + + if res.StatusCode != tt.wants.statusCode { + t.Errorf("handlePatchSecrets() = %v, want %v", res.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("handlePatchSecrets() = %v, want %v", content, tt.wants.contentType) + } + if tt.wants.body != "" { + if string(body) != tt.wants.body { + t.Errorf("%q. handlePatchSecrets() invalid body", tt.name) + } + } + + }) + } +} + +func TestSecretService_handleDeleteSecrets(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + orgID influxdb.ID + secrets []string + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "get basic secrets", + fields: fields{ + &mock.SecretService{ + DeleteSecretFn: func(ctx context.Context, orgID influxdb.ID, s ...string) error { + return nil + }, + }, + }, + args: args{ + orgID: 1, + secrets: []string{ + "abc", + }, + }, + wants: wants{ + statusCode: http.StatusNoContent, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(zaptest.NewLogger(t), "id", tt.fields.SecretService) + router := chi.NewRouter() + router.Mount("/api/v2/orgs/{id}/secrets", h) + + b, err := json.Marshal(struct { + Secrets []string `json:"secrets"` + }{ + Secrets: tt.args.secrets, + }) + if err != nil { + t.Fatalf("failed to marshal secrets: %v", err) + } + + buf := bytes.NewReader(b) + u := fmt.Sprintf("http://any.url/api/v2/orgs/%s/secrets/delete", tt.args.orgID) + r := httptest.NewRequest("POST", u, buf) + w := httptest.NewRecorder() + + router.ServeHTTP(w, r) + + res := w.Result() + content := res.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(res.Body) + + if res.StatusCode != tt.wants.statusCode { + t.Errorf("handleDeleteSecrets() = %v, want %v", res.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("handleDeleteSecrets() = %v, want %v", content, tt.wants.contentType) + } + if tt.wants.body != "" { + if string(body) != tt.wants.body { + t.Errorf("%q. handleDeleteSecrets() invalid body", tt.name) + } + } + + }) + } +} diff --git a/secret/middleware_auth.go b/secret/middleware_auth.go new file mode 100644 index 0000000000..fa508286b4 --- /dev/null +++ b/secret/middleware_auth.go @@ -0,0 +1,100 @@ +package secret + +import ( + "context" + + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/authorizer" +) + +var _ influxdb.SecretService = (*AuthedSvc)(nil) + +// AuthedSvc wraps a influxdb.AuthedSvc and authorizes actions +// against it appropriately. +type AuthedSvc struct { + s influxdb.SecretService +} + +// NewAuthedService constructs an instance of an authorizing secret service. +func NewAuthedService(s influxdb.SecretService) *AuthedSvc { + return &AuthedSvc{ + s: s, + } +} + +// LoadSecret checks to see if the authorizer on context has read access to the secret key provided. +func (s *AuthedSvc) LoadSecret(ctx context.Context, orgID influxdb.ID, key string) (string, error) { + if _, _, err := authorizer.AuthorizeOrgReadResource(ctx, influxdb.SecretsResourceType, orgID); err != nil { + return "", err + } + secret, err := s.s.LoadSecret(ctx, orgID, key) + if err != nil { + return "", err + } + return secret, nil +} + +// GetSecretKeys checks to see if the authorizer on context has read access to all the secrets belonging to orgID. +func (s *AuthedSvc) GetSecretKeys(ctx context.Context, orgID influxdb.ID) ([]string, error) { + if _, _, err := authorizer.AuthorizeOrgReadResource(ctx, influxdb.SecretsResourceType, orgID); err != nil { + return []string{}, err + } + secrets, err := s.s.GetSecretKeys(ctx, orgID) + if err != nil { + return []string{}, err + } + return secrets, nil +} + +// PutSecret checks to see if the authorizer on context has write access to the secret key provided. +func (s *AuthedSvc) PutSecret(ctx context.Context, orgID influxdb.ID, key string, val string) error { + if _, _, err := authorizer.AuthorizeCreate(ctx, influxdb.SecretsResourceType, orgID); err != nil { + return err + } + err := s.s.PutSecret(ctx, orgID, key, val) + if err != nil { + return err + } + return nil +} + +// PutSecrets checks to see if the authorizer on context has read and write access to the secret keys provided. +func (s *AuthedSvc) PutSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + // PutSecrets operates on intersection between m and keys beloging to orgID. + // We need to have read access to those secrets since it deletes the secrets (within the intersection) that have not be overridden. + if _, _, err := authorizer.AuthorizeOrgReadResource(ctx, influxdb.SecretsResourceType, orgID); err != nil { + return err + } + if _, _, err := authorizer.AuthorizeOrgWriteResource(ctx, influxdb.SecretsResourceType, orgID); err != nil { + return err + } + err := s.s.PutSecrets(ctx, orgID, m) + if err != nil { + return err + } + return nil +} + +// PatchSecrets checks to see if the authorizer on context has write access to the secret keys provided. +func (s *AuthedSvc) PatchSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + if _, _, err := authorizer.AuthorizeOrgWriteResource(ctx, influxdb.SecretsResourceType, orgID); err != nil { + return err + } + err := s.s.PatchSecrets(ctx, orgID, m) + if err != nil { + return err + } + return nil +} + +// DeleteSecret checks to see if the authorizer on context has write access to the secret keys provided. +func (s *AuthedSvc) DeleteSecret(ctx context.Context, orgID influxdb.ID, keys ...string) error { + if _, _, err := authorizer.AuthorizeOrgWriteResource(ctx, influxdb.SecretsResourceType, orgID); err != nil { + return err + } + err := s.s.DeleteSecret(ctx, orgID, keys...) + if err != nil { + return err + } + return nil +} diff --git a/secret/middleware_auth_test.go b/secret/middleware_auth_test.go new file mode 100644 index 0000000000..c9973f9090 --- /dev/null +++ b/secret/middleware_auth_test.go @@ -0,0 +1,698 @@ +package secret_test + +import ( + "bytes" + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/influxdata/influxdb/v2" + influxdbcontext "github.com/influxdata/influxdb/v2/context" + "github.com/influxdata/influxdb/v2/mock" + "github.com/influxdata/influxdb/v2/secret" + influxdbtesting "github.com/influxdata/influxdb/v2/testing" +) + +var secretCmpOptions = cmp.Options{ + cmp.Comparer(func(x, y []byte) bool { + return bytes.Equal(x, y) + }), +} + +func TestSecretService_LoadSecret(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + permission influxdb.Permission + org influxdb.ID + key string + } + type wants struct { + err error + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "authorized to access secret within org", + fields: fields{ + SecretService: &mock.SecretService{ + LoadSecretFn: func(ctx context.Context, orgID influxdb.ID, k string) (string, error) { + if k == "key" { + return "val", nil + } + return "", &influxdb.Error{ + Code: influxdb.ENotFound, + Msg: influxdb.ErrSecretNotFound, + } + }, + }, + }, + args: args{ + permission: influxdb.Permission{ + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + key: "key", + org: influxdb.ID(10), + }, + wants: wants{ + err: nil, + }, + }, + { + name: "cannot access not existing secret", + fields: fields{ + SecretService: &mock.SecretService{ + LoadSecretFn: func(ctx context.Context, orgID influxdb.ID, k string) (string, error) { + if k == "key" { + return "val", nil + } + return "", &influxdb.Error{ + Code: influxdb.ENotFound, + Msg: influxdb.ErrSecretNotFound, + } + }, + }, + }, + args: args{ + permission: influxdb.Permission{ + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + key: "not existing", + org: influxdb.ID(10), + }, + wants: wants{ + err: &influxdb.Error{ + Code: influxdb.ENotFound, + Msg: influxdb.ErrSecretNotFound, + }, + }, + }, + { + name: "unauthorized to access secret within org", + fields: fields{ + SecretService: &mock.SecretService{ + LoadSecretFn: func(ctx context.Context, orgID influxdb.ID, k string) (string, error) { + if k == "key" { + return "val", nil + } + return "", &influxdb.Error{ + Code: influxdb.ENotFound, + Msg: influxdb.ErrSecretNotFound, + } + }, + }, + }, + args: args{ + permission: influxdb.Permission{ + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + ID: influxdbtesting.IDPtr(10), + }, + }, + org: influxdb.ID(2), + key: "key", + }, + wants: wants{ + err: &influxdb.Error{ + Msg: "read:orgs/0000000000000002/secrets is unauthorized", + Code: influxdb.EUnauthorized, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := secret.NewAuthedService(tt.fields.SecretService) + + ctx := context.Background() + ctx = influxdbcontext.SetAuthorizer(ctx, mock.NewMockAuthorizer(false, []influxdb.Permission{tt.args.permission})) + + _, err := s.LoadSecret(ctx, tt.args.org, tt.args.key) + influxdbtesting.ErrorsEqual(t, err, tt.wants.err) + }) + } +} + +func TestSecretService_GetSecretKeys(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + permission influxdb.Permission + org influxdb.ID + } + type wants struct { + err error + secrets []string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "authorized to see all secrets within an org", + fields: fields{ + SecretService: &mock.SecretService{ + GetSecretKeysFn: func(ctx context.Context, orgID influxdb.ID) ([]string, error) { + return []string{ + "0000000000000001secret1", + "0000000000000001secret2", + "0000000000000001secret3", + }, nil + }, + }, + }, + args: args{ + permission: influxdb.Permission{ + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(1), + }, + }, + org: influxdb.ID(1), + }, + wants: wants{ + secrets: []string{ + "0000000000000001secret1", + "0000000000000001secret2", + "0000000000000001secret3", + }, + }, + }, + { + name: "unauthorized to see all secrets within an org", + fields: fields{ + SecretService: &mock.SecretService{ + GetSecretKeysFn: func(ctx context.Context, orgID influxdb.ID) ([]string, error) { + return []string{ + "0000000000000002secret1", + "0000000000000002secret2", + "0000000000000002secret3", + }, nil + }, + }, + }, + args: args{ + permission: influxdb.Permission{ + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(1), + }, + }, + org: influxdb.ID(2), + }, + wants: wants{ + err: &influxdb.Error{ + Code: influxdb.EUnauthorized, + Msg: "read:orgs/0000000000000002/secrets is unauthorized", + }, + secrets: []string{}, + }, + }, + { + name: "errors when there are not secret into an org", + fields: fields{ + SecretService: &mock.SecretService{ + GetSecretKeysFn: func(ctx context.Context, orgID influxdb.ID) ([]string, error) { + return []string(nil), &influxdb.Error{ + Code: influxdb.ENotFound, + Msg: "organization has no secret keys", + } + }, + }, + }, + args: args{ + permission: influxdb.Permission{ + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + org: influxdb.ID(10), + }, + wants: wants{ + err: &influxdb.Error{ + Code: influxdb.ENotFound, + Msg: "organization has no secret keys", + }, + secrets: []string{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := secret.NewAuthedService(tt.fields.SecretService) + + ctx := context.Background() + ctx = influxdbcontext.SetAuthorizer(ctx, mock.NewMockAuthorizer(false, []influxdb.Permission{tt.args.permission})) + + secrets, err := s.GetSecretKeys(ctx, tt.args.org) + influxdbtesting.ErrorsEqual(t, err, tt.wants.err) + + if diff := cmp.Diff(secrets, tt.wants.secrets, secretCmpOptions...); diff != "" { + t.Errorf("secrets are different -got/+want\ndiff %s", diff) + } + }) + } +} + +func TestSecretService_PatchSecrets(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + org influxdb.ID + permissions []influxdb.Permission + } + type wants struct { + err error + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "authorized to patch secrets", + fields: fields{ + SecretService: &mock.SecretService{ + PatchSecretsFn: func(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + return nil + }, + }, + }, + args: args{ + org: influxdb.ID(1), + permissions: []influxdb.Permission{ + { + Action: influxdb.WriteAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(1), + }, + }, + }, + }, + wants: wants{ + err: nil, + }, + }, + { + name: "unauthorized to update secret", + fields: fields{ + SecretService: &mock.SecretService{ + PatchSecretsFn: func(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + return nil + }, + }, + }, + args: args{ + org: influxdb.ID(1), + permissions: []influxdb.Permission{ + { + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + }, + }, + wants: wants{ + err: &influxdb.Error{ + Msg: "write:orgs/0000000000000001/secrets is unauthorized", + Code: influxdb.EUnauthorized, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := secret.NewAuthedService(tt.fields.SecretService) + + ctx := context.Background() + ctx = influxdbcontext.SetAuthorizer(ctx, mock.NewMockAuthorizer(false, tt.args.permissions)) + + patches := make(map[string]string) + err := s.PatchSecrets(ctx, tt.args.org, patches) + influxdbtesting.ErrorsEqual(t, err, tt.wants.err) + }) + } +} + +func TestSecretService_DeleteSecret(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + org influxdb.ID + permissions []influxdb.Permission + } + type wants struct { + err error + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "authorized to delete secret", + fields: fields{ + SecretService: &mock.SecretService{ + DeleteSecretFn: func(ctx context.Context, orgID influxdb.ID, keys ...string) error { + return nil + }, + }, + }, + args: args{ + org: influxdb.ID(1), + permissions: []influxdb.Permission{ + { + Action: influxdb.WriteAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(1), + }, + }, + }, + }, + wants: wants{ + err: nil, + }, + }, + { + name: "unauthorized to delete secret", + fields: fields{ + SecretService: &mock.SecretService{ + DeleteSecretFn: func(ctx context.Context, orgID influxdb.ID, keys ...string) error { + return nil + }, + }, + }, + args: args{ + org: 10, + permissions: []influxdb.Permission{ + { + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(1), + }, + }, + }, + }, + wants: wants{ + err: &influxdb.Error{ + Msg: "write:orgs/000000000000000a/secrets is unauthorized", + Code: influxdb.EUnauthorized, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := secret.NewAuthedService(tt.fields.SecretService) + + ctx := context.Background() + ctx = influxdbcontext.SetAuthorizer(ctx, mock.NewMockAuthorizer(false, tt.args.permissions)) + + err := s.DeleteSecret(ctx, tt.args.org) + influxdbtesting.ErrorsEqual(t, err, tt.wants.err) + }) + } +} + +func TestSecretService_PutSecret(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + permission influxdb.Permission + orgID influxdb.ID + } + type wants struct { + err error + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "authorized to put a secret", + fields: fields{ + SecretService: &mock.SecretService{ + PutSecretFn: func(ctx context.Context, orgID influxdb.ID, key string, val string) error { + return nil + }, + }, + }, + args: args{ + orgID: influxdb.ID(10), + permission: influxdb.Permission{ + Action: influxdb.WriteAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + }, + wants: wants{ + err: nil, + }, + }, + { + name: "unauthorized to put a secret", + fields: fields{ + SecretService: &mock.SecretService{ + PutSecretFn: func(ctx context.Context, orgID influxdb.ID, key string, val string) error { + return nil + }, + }, + }, + args: args{ + orgID: 10, + permission: influxdb.Permission{ + Action: influxdb.WriteAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + ID: influxdbtesting.IDPtr(1), + }, + }, + }, + wants: wants{ + err: &influxdb.Error{ + Msg: "write:orgs/000000000000000a/secrets is unauthorized", + Code: influxdb.EUnauthorized, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := secret.NewAuthedService(tt.fields.SecretService) + + ctx := context.Background() + ctx = influxdbcontext.SetAuthorizer(ctx, mock.NewMockAuthorizer(false, []influxdb.Permission{tt.args.permission})) + + err := s.PutSecret(ctx, tt.args.orgID, "", "") + influxdbtesting.ErrorsEqual(t, err, tt.wants.err) + }) + } +} + +func TestSecretService_PutSecrets(t *testing.T) { + type fields struct { + SecretService influxdb.SecretService + } + type args struct { + permissions []influxdb.Permission + orgID influxdb.ID + } + type wants struct { + err error + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "authorized to put secrets", + fields: fields{ + SecretService: &mock.SecretService{ + PutSecretsFn: func(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + return nil + }, + }, + }, + args: args{ + orgID: influxdb.ID(10), + permissions: []influxdb.Permission{ + { + Action: influxdb.WriteAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + { + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + }, + }, + wants: wants{ + err: nil, + }, + }, + { + name: "unauthorized to put secrets", + fields: fields{ + SecretService: &mock.SecretService{ + PutSecretsFn: func(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + return nil + }, + }, + }, + args: args{ + orgID: influxdb.ID(2), + permissions: []influxdb.Permission{ + { + Action: influxdb.WriteAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(1), + }, + }, + { + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(2), + }, + }, + }, + }, + wants: wants{ + err: &influxdb.Error{ + Msg: "write:orgs/0000000000000002/secrets is unauthorized", + Code: influxdb.EUnauthorized, + }, + }, + }, + { + name: "unauthorized to put secrets without read access to their org", + fields: fields{ + SecretService: &mock.SecretService{ + PutSecretFn: func(ctx context.Context, orgID influxdb.ID, key string, val string) error { + return nil + }, + PutSecretsFn: func(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + return nil + }, + }, + }, + args: args{ + orgID: 10, + permissions: []influxdb.Permission{ + { + Action: influxdb.WriteAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + }, + }, + wants: wants{ + err: &influxdb.Error{ + Msg: "read:orgs/000000000000000a/secrets is unauthorized", + Code: influxdb.EUnauthorized, + }, + }, + }, + { + name: "unauthorized to put secrets without write access to their org", + fields: fields{ + SecretService: &mock.SecretService{ + PutSecretFn: func(ctx context.Context, orgID influxdb.ID, key string, val string) error { + return nil + }, + PutSecretsFn: func(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + return nil + }, + }, + }, + args: args{ + orgID: 10, + permissions: []influxdb.Permission{ + { + Action: influxdb.ReadAction, + Resource: influxdb.Resource{ + Type: influxdb.SecretsResourceType, + OrgID: influxdbtesting.IDPtr(10), + }, + }, + }, + }, + wants: wants{ + err: &influxdb.Error{ + Msg: "write:orgs/000000000000000a/secrets is unauthorized", + Code: influxdb.EUnauthorized, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := secret.NewAuthedService(tt.fields.SecretService) + + ctx := context.Background() + ctx = influxdbcontext.SetAuthorizer(ctx, mock.NewMockAuthorizer(false, tt.args.permissions)) + + secrets := make(map[string]string) + err := s.PutSecrets(ctx, tt.args.orgID, secrets) + influxdbtesting.ErrorsEqual(t, err, tt.wants.err) + }) + } +} diff --git a/secret/middleware_logging.go b/secret/middleware_logging.go new file mode 100644 index 0000000000..97d9b9d724 --- /dev/null +++ b/secret/middleware_logging.go @@ -0,0 +1,109 @@ +package secret + +import ( + "context" + "time" + + "github.com/influxdata/influxdb/v2" + "go.uber.org/zap" +) + +// Logger is a logger service middleware for secrets +type Logger struct { + logger *zap.Logger + secretService influxdb.SecretService +} + +var _ influxdb.SecretService = (*Logger)(nil) + +// NewLogger returns a logging service middleware for the User Service. +func NewLogger(log *zap.Logger, s influxdb.SecretService) *Logger { + return &Logger{ + logger: log, + secretService: s, + } +} + +// LoadSecret retrieves the secret value v found at key k for organization orgID. +func (l *Logger) LoadSecret(ctx context.Context, orgID influxdb.ID, key string) (str string, err error) { + defer func(start time.Time) { + dur := zap.Duration("took", time.Since(start)) + if err != nil { + l.logger.Debug("failed to load secret", zap.Error(err), dur) + return + } + l.logger.Debug("secret load", dur) + }(time.Now()) + return l.secretService.LoadSecret(ctx, orgID, key) + +} + +// GetSecretKeys retrieves all secret keys that are stored for the organization orgID. +func (l *Logger) GetSecretKeys(ctx context.Context, orgID influxdb.ID) (strs []string, err error) { + defer func(start time.Time) { + dur := zap.Duration("took", time.Since(start)) + if err != nil { + l.logger.Debug("failed to get secret keys", zap.Error(err), dur) + return + } + l.logger.Debug("secret get keys", dur) + }(time.Now()) + return l.secretService.GetSecretKeys(ctx, orgID) + +} + +// PutSecret stores the secret pair (k,v) for the organization orgID. +func (l *Logger) PutSecret(ctx context.Context, orgID influxdb.ID, key string, val string) (err error) { + defer func(start time.Time) { + dur := zap.Duration("took", time.Since(start)) + if err != nil { + l.logger.Debug("failed to put secret", zap.Error(err), dur) + return + } + l.logger.Debug("secret put", dur) + }(time.Now()) + return l.secretService.PutSecret(ctx, orgID, key, val) + +} + +// PutSecrets puts all provided secrets and overwrites any previous values. +func (l *Logger) PutSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) (err error) { + defer func(start time.Time) { + dur := zap.Duration("took", time.Since(start)) + if err != nil { + l.logger.Debug("failed to put secrets", zap.Error(err), dur) + return + } + l.logger.Debug("secret puts", dur) + }(time.Now()) + return l.secretService.PutSecrets(ctx, orgID, m) + +} + +// PatchSecrets patches all provided secrets and updates any previous values. +func (l *Logger) PatchSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) (err error) { + defer func(start time.Time) { + dur := zap.Duration("took", time.Since(start)) + if err != nil { + l.logger.Debug("failed to patch secret", zap.Error(err), dur) + return + } + l.logger.Debug("secret patch", dur) + }(time.Now()) + return l.secretService.PatchSecrets(ctx, orgID, m) + +} + +// DeleteSecret removes a single secret from the secret store. +func (l *Logger) DeleteSecret(ctx context.Context, orgID influxdb.ID, keys ...string) (err error) { + defer func(start time.Time) { + dur := zap.Duration("took", time.Since(start)) + if err != nil { + l.logger.Debug("failed to delete secret", zap.Error(err), dur) + return + } + l.logger.Debug("secret delete", dur) + }(time.Now()) + return l.secretService.DeleteSecret(ctx, orgID, keys...) + +} diff --git a/secret/middleware_metrics.go b/secret/middleware_metrics.go new file mode 100644 index 0000000000..d3ce60e66c --- /dev/null +++ b/secret/middleware_metrics.go @@ -0,0 +1,69 @@ +package secret + +import ( + "context" + + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/kit/metric" + "github.com/prometheus/client_golang/prometheus" +) + +// SecreteService is a metrics middleware system for the secret service +type SecreteService struct { + // RED metrics + rec *metric.REDClient + + secretSvc influxdb.SecretService +} + +var _ influxdb.SecretService = (*SecreteService)(nil) + +// NewMetricService creates a new secret metrics middleware +func NewMetricService(reg prometheus.Registerer, s influxdb.SecretService) *SecreteService { + return &SecreteService{ + rec: metric.New(reg, "secret"), + secretSvc: s, + } +} + +// LoadSecret retrieves the secret value v found at key k for organization orgID. +func (ms *SecreteService) LoadSecret(ctx context.Context, orgID influxdb.ID, key string) (string, error) { + rec := ms.rec.Record("load_secret") + secret, err := ms.secretSvc.LoadSecret(ctx, orgID, key) + return secret, rec(err) +} + +// GetSecretKeys retrieves all secret keys that are stored for the organization orgID. +func (ms *SecreteService) GetSecretKeys(ctx context.Context, orgID influxdb.ID) ([]string, error) { + rec := ms.rec.Record("get_secret_keys") + secrets, err := ms.secretSvc.GetSecretKeys(ctx, orgID) + return secrets, rec(err) +} + +// PutSecret stores the secret pair (k,v) for the organization orgID. +func (ms *SecreteService) PutSecret(ctx context.Context, orgID influxdb.ID, key string, val string) error { + rec := ms.rec.Record("put_secret") + err := ms.secretSvc.PutSecret(ctx, orgID, key, val) + return rec(err) +} + +// PutSecrets puts all provided secrets and overwrites any previous values. +func (ms *SecreteService) PutSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + rec := ms.rec.Record("put_secrets") + err := ms.secretSvc.PutSecrets(ctx, orgID, m) + return rec(err) +} + +// PatchSecrets patches all provided secrets and updates any previous values. +func (ms *SecreteService) PatchSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + rec := ms.rec.Record("patch_secrets") + err := ms.secretSvc.PatchSecrets(ctx, orgID, m) + return rec(err) +} + +// DeleteSecret removes a single secret from the secret store. +func (ms *SecreteService) DeleteSecret(ctx context.Context, orgID influxdb.ID, keys ...string) error { + rec := ms.rec.Record("delete_secret") + err := ms.secretSvc.DeleteSecret(ctx, orgID, keys...) + return rec(err) +} diff --git a/secret/service.go b/secret/service.go new file mode 100644 index 0000000000..6179150c71 --- /dev/null +++ b/secret/service.go @@ -0,0 +1,89 @@ +package secret + +import ( + "context" + + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/kv" +) + +type Service struct { + s *Storage +} + +// NewService creates a new service implementaiton for secrets +func NewService(s *Storage) *Service { + return &Service{s} +} + +// LoadSecret retrieves the secret value v found at key k for organization orgID. +func (s *Service) LoadSecret(ctx context.Context, orgID influxdb.ID, k string) (string, error) { + var v string + err := s.s.View(ctx, func(tx kv.Tx) error { + var err error + v, err = s.s.GetSecret(ctx, tx, orgID, k) + return err + }) + return v, err +} + +// GetSecretKeys retrieves all secret keys that are stored for the organization orgID. +func (s *Service) GetSecretKeys(ctx context.Context, orgID influxdb.ID) ([]string, error) { + var v []string + err := s.s.View(ctx, func(tx kv.Tx) error { + var err error + v, err = s.s.ListSecret(ctx, tx, orgID) + return err + }) + return v, err +} + +// PutSecret stores the secret pair (k,v) for the organization orgID. +func (s *Service) PutSecret(ctx context.Context, orgID influxdb.ID, k, v string) error { + err := s.s.Update(ctx, func(tx kv.Tx) error { + return s.s.PutSecret(ctx, tx, orgID, k, v) + }) + return err +} + +// PutSecrets puts all provided secrets and overwrites any previous values. +func (s *Service) PutSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + // put secretes expects to replace all existing secretes + keys, err := s.GetSecretKeys(ctx, orgID) + if err != nil { + return err + } + if err := s.DeleteSecret(ctx, orgID, keys...); err != nil { + return err + } + + return s.PatchSecrets(ctx, orgID, m) +} + +// PatchSecrets patches all provided secrets and updates any previous values. +func (s *Service) PatchSecrets(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + err := s.s.Update(ctx, func(tx kv.Tx) error { + for k, v := range m { + err := s.s.PutSecret(ctx, tx, orgID, k, v) + if err != nil { + return err + } + } + return nil + }) + return err +} + +// DeleteSecret removes a single secret from the secret store. +func (s *Service) DeleteSecret(ctx context.Context, orgID influxdb.ID, ks ...string) error { + err := s.s.Update(ctx, func(tx kv.Tx) error { + for _, k := range ks { + err := s.s.DeleteSecret(ctx, tx, orgID, k) + if err != nil { + return err + } + } + return nil + }) + return err +} diff --git a/secret/service_test.go b/secret/service_test.go new file mode 100644 index 0000000000..9843e13fec --- /dev/null +++ b/secret/service_test.go @@ -0,0 +1,33 @@ +package secret_test + +import ( + "context" + "testing" + + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/inmem" + "github.com/influxdata/influxdb/v2/secret" + influxdbtesting "github.com/influxdata/influxdb/v2/testing" +) + +func TestBoltSecretService(t *testing.T) { + influxdbtesting.SecretService(initSvc, t) +} + +func initSvc(f influxdbtesting.SecretServiceFields, t *testing.T) (influxdb.SecretService, func()) { + s := inmem.NewKVStore() + + storage, err := secret.NewStore(s) + if err != nil { + t.Fatal(err) + } + svc := secret.NewService(storage) + + for _, s := range f.Secrets { + if err := svc.PutSecrets(context.Background(), s.OrganizationID, s.Env); err != nil { + t.Fatalf("failed to populate users") + } + } + + return svc, func() {} +} diff --git a/secret/storage.go b/secret/storage.go new file mode 100644 index 0000000000..99e7d79bc0 --- /dev/null +++ b/secret/storage.go @@ -0,0 +1,193 @@ +package secret + +import ( + "context" + "encoding/base64" + "errors" + + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/kv" +) + +var secretBucket = []byte("secretsv1") + +// Storage is a store translation layer between the data storage unit and the +// service layer. +type Storage struct { + store kv.Store +} + +// NewStore creates a new storage system +func NewStore(s kv.Store) (*Storage, error) { + err := s.Update(context.Background(), func(tx kv.Tx) error { + if _, err := tx.Bucket(secretBucket); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return &Storage{s}, nil +} + +func (s *Storage) View(ctx context.Context, fn func(kv.Tx) error) error { + return s.store.View(ctx, fn) +} + +func (s *Storage) Update(ctx context.Context, fn func(kv.Tx) error) error { + return s.store.Update(ctx, fn) +} + +// GetSecret Returns the value of a secret +func (s *Storage) GetSecret(ctx context.Context, tx kv.Tx, orgID influxdb.ID, k string) (string, error) { + key, err := encodeSecretKey(orgID, k) + if err != nil { + return "", err + } + + b, err := tx.Bucket(secretBucket) + if err != nil { + return "", err + } + + val, err := b.Get(key) + if kv.IsNotFound(err) { + return "", &influxdb.Error{ + Code: influxdb.ENotFound, + Msg: influxdb.ErrSecretNotFound, + } + } + + if err != nil { + return "", err + } + + v, err := decodeSecretValue(val) + if err != nil { + return "", err + } + + return v, nil +} + +// ListSecrets returns a list of secret keys +func (s *Storage) ListSecret(ctx context.Context, tx kv.Tx, orgID influxdb.ID) ([]string, error) { + b, err := tx.Bucket(secretBucket) + if err != nil { + return nil, err + } + + prefix, err := orgID.Encode() + if err != nil { + return nil, err + } + + cur, err := b.ForwardCursor(prefix, kv.WithCursorPrefix(prefix)) + if err != nil { + return nil, err + } + + keys := []string{} + + err = kv.WalkCursor(ctx, cur, func(k, v []byte) error { + id, key, err := decodeSecretKey(k) + if err != nil { + return err + } + + if id != orgID { + // We've reached the end of the keyspace for the provided orgID + return nil + } + + keys = append(keys, key) + return nil + }) + if err != nil { + return nil, err + } + + return keys, nil +} + +// PutSecret sets a secret in the db. +func (s *Storage) PutSecret(ctx context.Context, tx kv.Tx, orgID influxdb.ID, k, v string) error { + key, err := encodeSecretKey(orgID, k) + if err != nil { + return err + } + + val := encodeSecretValue(v) + + b, err := tx.Bucket(secretBucket) + if err != nil { + return err + } + + if err := b.Put(key, val); err != nil { + return err + } + + return nil +} + +// DeleteSecret removes a secret for the db +func (s *Storage) DeleteSecret(ctx context.Context, tx kv.Tx, orgID influxdb.ID, k string) error { + key, err := encodeSecretKey(orgID, k) + if err != nil { + return err + } + + b, err := tx.Bucket(secretBucket) + if err != nil { + return err + } + + return b.Delete(key) +} + +func encodeSecretKey(orgID influxdb.ID, k string) ([]byte, error) { + buf, err := orgID.Encode() + if err != nil { + return nil, err + } + + key := make([]byte, 0, influxdb.IDLength+len(k)) + key = append(key, buf...) + key = append(key, k...) + + return key, nil +} + +func decodeSecretKey(key []byte) (influxdb.ID, string, error) { + if len(key) < influxdb.IDLength { + // This should not happen. + return influxdb.InvalidID(), "", errors.New("provided key is too short to contain an ID (please report this error)") + } + + var id influxdb.ID + if err := id.Decode(key[:influxdb.IDLength]); err != nil { + return influxdb.InvalidID(), "", err + } + + k := string(key[influxdb.IDLength:]) + + return id, k, nil +} + +func decodeSecretValue(val []byte) (string, error) { + // store the secret value base64 encoded so that it's marginally better than plaintext + v, err := base64.StdEncoding.DecodeString(string(val)) + if err != nil { + return "", err + } + + return string(v), nil +} + +func encodeSecretValue(v string) []byte { + val := make([]byte, base64.StdEncoding.EncodedLen(len(v))) + base64.StdEncoding.Encode(val, []byte(v)) + return val +}