From 7dad85b9ad8015a6e4da139fcd9c8869125e0ab9 Mon Sep 17 00:00:00 2001 From: Maikel <43137751+maikelpoot@users.noreply.github.com> Date: Tue, 17 Oct 2023 11:48:29 +0200 Subject: [PATCH] Feature/17 implement service accounts (#23) * chore(serviceaccount): List and Create service accounts * chore(serviceaccount): Add service to Keyhub class * Added update, get and delete functions * chore(serviceaccount): Add ServiceAccountQueryParams * chore(test): add some queryParam marshal testing * chore(serviceaccounts): Added remaining query params --- CHANGELOG.md | 3 + keyhub.go | 2 + keyhub_test.go | 56 ++++++++++++ model/serviceaccount.go | 106 ++++++++++++++++++++++ serviceaccounts.go | 195 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 serviceaccounts.go diff --git a/CHANGELOG.md b/CHANGELOG.md index abb788b..d58f5cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Hotfix: Fix undetected parse error for vault records without an enddate +### Added +- Issue #17 : Implement Service Accounts + ## [1.2.4] - 2023-02-23 ### Changed - Issue #18 : Improved error reporting by returning the ErrorReport diff --git a/keyhub.go b/keyhub.go index e5046b7..3364fb5 100644 --- a/keyhub.go +++ b/keyhub.go @@ -48,6 +48,7 @@ type Client struct { Systems *SystemService ClientApplications *ClientApplicationService Vaults *VaultService + ServiceAccounts *ServiceAccountService LaunchPadTile *LaunchPadTileService } @@ -145,5 +146,6 @@ func NewClient(httpClient *http.Client, issuer string, clientID string, clientSe Systems: newSystemService(oauth2Sling.New()), LaunchPadTile: newLaunchPadTileService(oauth2Sling.New()), Vaults: newVaultService(versionedSling.New().Client(vaultClient)), + ServiceAccounts: NewServiceAccountService(oauth2Sling), }, nil } diff --git a/keyhub_test.go b/keyhub_test.go index 17dd71a..a010ed4 100644 --- a/keyhub_test.go +++ b/keyhub_test.go @@ -16,9 +16,11 @@ package keyhub import ( + "github.com/google/go-querystring/query" "net/http" "strconv" "testing" + "time" "github.com/google/uuid" "github.com/jarcoal/httpmock" @@ -132,3 +134,57 @@ func TestGroups(t *testing.T) { t.Fatalf("ERROR group with id 1 not found") } } + +func verifyQueryParams(t *testing.T, queryParams interface{}, expected string) { + + r, err := query.Values(queryParams) + if err != nil { + t.Fatalf("Could not parse query params") + } + + result := r.Encode() + + if result != expected { + t.Fatalf("Parse error, result `%s` did not match expected `%s`", result, expected) + } + +} + +func TestQueries(t *testing.T) { + + var q model.ServiceAccountQueryParams + + q = model.ServiceAccountQueryParams{} + verifyQueryParams(t, q, "") + + CreatedAfter, _ := time.Parse("2006-01-02", "2023-01-04") + CreatedBefore, _ := time.Parse("2006-01-02", "2023-01-04") + ModifiedSince, _ := time.Parse("2006-01-02", "2023-01-04") + + q = model.ServiceAccountQueryParams{ + UUID: "51f0cb1d-5745-4512-8d0d-bb28e2449d3f", + CreatedAfter: CreatedAfter, + CreatedBefore: CreatedBefore, + ModifiedSince: ModifiedSince, + Additional: nil, + Exclude: []int64{1000}, + Id: []int64{1001}, + CQLQuery: "Blaat", + Active: "BOTH", + GroupOnSystem: 1002, + GroupOnSystemOwners: 1003, + Name: "Name", + NameContains: "Contains", + NameDoesNotStartWith: "NotStartWith", + NameStartsWith: "StartsWith", + Password: 1004, + PasswordRotation: "MANUAL", + RequestedGroupOnSystemOwners: 1005, + System: 1006, + TechnicalAdministrator: 1007, + Username: "Username", + } + + verifyQueryParams(t, q, "Username=Username&active=BOTH&createdAfter=2023-01-04T00%3A00%3A00Z&createdBefore=2023-01-04T00%3A00%3A00Z&createdBefore=2023-01-04T00%3A00%3A00Z&exclude=1000&groupOnSystem=1002&groupOnSystemOwners=1003&id=1001&name=Name&nameContains=Contains&nameDoesNotStartWith=NotStartWith&nameStartsWith=StartsWith&password=1004&passwordRotation=MANUAL&q=Blaat&requestedGroupOnSystemOwners=1005&system=1006&technicalAdministrator=1007&uuid=51f0cb1d-5745-4512-8d0d-bb28e2449d3f") + +} diff --git a/model/serviceaccount.go b/model/serviceaccount.go index 8b53790..bfb3ebc 100644 --- a/model/serviceaccount.go +++ b/model/serviceaccount.go @@ -1 +1,107 @@ package model + +import ( + "github.com/google/uuid" + "net/url" + "time" +) + +const ( + SA_PASSWORD_ROTATION_MANUAL SAPasswordRotation = "MANUAL" + SA_PASSWORD_ROTATION_MANUAL_SIV SAPasswordRotation = "MANUAL_STORED_IN_VAULT" + SA_PASSWORD_ROTATION_DAILY SAPasswordRotation = "DAILY" +) + +type SAPasswordRotation string + +type ServiceAccountList struct { + Items []ServiceAccount `json:"items"` +} + +type ServiceAccountPrimer struct { + //#/components/schemas/serviceaccount_ServiceAccountPrimer + //serviceaccount_ServiceAccount + + Linkable + + Active bool `json:"active"` + UUID uuid.UUID `json:"uuid,omitempty"` + Name string `json:"name"` + Username string `json:"username"` + System *ProvisionedSystem `json:"system"` +} + +type ServiceAccount struct { + ServiceAccountPrimer + + TechnicalAdministrator *Group `json:"technicalAdministrator,omitempty"` + PasswordRotation SAPasswordRotation `json:"passwordRotation"` + Description string `json:"description,omitempty"` + Password *VaultRecord `json:"password,omitempty"` +} + +// AsPrimer Return ServiceAccount with only Primer data +func (s *ServiceAccount) AsPrimer() *ServiceAccount { + serviceAccount := &ServiceAccount{} + serviceAccount.ServiceAccountPrimer = s.ServiceAccountPrimer + return serviceAccount +} + +// ToPrimer Convert to serviceAccountPrimer +func (s *ServiceAccount) ToPrimer() *ServiceAccountPrimer { + serviceAccountPrimer := s.ServiceAccountPrimer + return &serviceAccountPrimer +} + +type ServiceAccountGroupList struct { + Items []ServiceAccountGroup `json:"items"` +} + +type ServiceAccountGroup struct { + GroupOnSystem +} + +type ServiceAccountQueryParams struct { + UUID string `url:"uuid,omitempty"` + CreatedAfter time.Time `url:"createdAfter,omitempty" layout:"2006-01-02T15:04:05Z"` + CreatedBefore time.Time `url:"createdBefore,omitempty" layout:"2006-01-02T15:04:05Z"` + ModifiedSince time.Time `url:"createdBefore,omitempty" layout:"2006-01-02T15:04:05Z"` + Additional *ServiceAccountAdditionalQueryParams `url:"additional,omitempty"` + Exclude []int64 `url:"exclude,omitempty"` + Id []int64 `url:"id,omitempty"` + CQLQuery string `url:"q,omitempty"` + Active string `url:"active,omitempty"` + GroupOnSystem int64 `url:"groupOnSystem,omitempty"` + GroupOnSystemOwners int64 `url:"groupOnSystemOwners,omitempty"` + Name string `url:"name,omitempty"` + NameContains string `url:"nameContains,omitempty"` + NameDoesNotStartWith string `url:"nameDoesNotStartWith,omitempty"` + NameStartsWith string `url:"nameStartsWith,omitempty"` + Password int64 `url:"password,omitempty"` + PasswordRotation string `url:"passwordRotation,omitempty"` + RequestedGroupOnSystemOwners int64 `url:"requestedGroupOnSystemOwners,omitempty"` + System int64 `url:"system,omitempty"` + TechnicalAdministrator int64 `url:"technicalAdministrator,omitempty"` + Username string `url:"Username,omitempty"` +} + +type ServiceAccountAdditionalQueryParams struct { + Audit bool `url:"audit"` + Groups bool `url:"groups"` + Secrets bool `url:"secrets"` +} + +// EncodeValues Custom url encoder to convert bools to list +func (p ServiceAccountAdditionalQueryParams) EncodeValues(key string, v *url.Values) error { + return additionalQueryParamsUrlEncoder(p, key, v) +} + +func NewServiceAccount() *ServiceAccount { + + return &ServiceAccount{ + ServiceAccountPrimer: ServiceAccountPrimer{ + Linkable: Linkable{DType: "serviceaccount.ServiceAccount"}, + }, + PasswordRotation: SA_PASSWORD_ROTATION_MANUAL, + } +} diff --git a/serviceaccounts.go b/serviceaccounts.go new file mode 100644 index 0000000..08e8b90 --- /dev/null +++ b/serviceaccounts.go @@ -0,0 +1,195 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You 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. */ + +package keyhub + +import ( + "fmt" + "github.com/dghubble/sling" + "github.com/google/uuid" + "github.com/topicuskeyhub/go-keyhub/model" + "net/http" + "net/url" + "strconv" +) + +type ServiceAccountService struct { + sling *sling.Sling +} + +// NewServiceAccountService Create new ServiceAccountService +func NewServiceAccountService(sling *sling.Sling) *ServiceAccountService { + return &ServiceAccountService{ + sling: sling.Path("/keyhub/rest/v1/serviceaccount/"), + } +} + +// GetByUUID Get Service account by UUID +func (s *ServiceAccountService) GetByUUID(uuid uuid.UUID) (result *model.ServiceAccount, err error) { + list := new(model.ServiceAccountList) + errorReport := new(model.ErrorReport) + + params := &model.ServiceAccountQueryParams{UUID: uuid.String()} + + _, err = s.sling.New().Get("").QueryStruct(params).Receive(list, errorReport) + if errorReport.Code > 0 { + err = errorReport.Wrap("Could not get ServiceAccount %q.", uuid) + } + if err == nil { + if len(list.Items) > 0 { + result = &list.Items[0] + } else { + err = fmt.Errorf("Account %q not found", uuid.String()) + } + } + + return +} + +// GetById Get Service account by ID +func (s *ServiceAccountService) GetById(id int64) (result *model.ServiceAccount, err error) { + sa := new(model.ServiceAccount) + errorReport := new(model.ErrorReport) + idString := strconv.FormatInt(id, 10) + + _, err = s.sling.New().Get(idString).Receive(sa, errorReport) + if errorReport.Code > 0 { + err = errorReport.Wrap("Could not get ServiceAccount %q.", idString) + return + } + if err == nil && sa == nil { + err = fmt.Errorf("Account %q not found", idString) + return + } + + //use an intermediate variable so sling can fill that variable with the json results. When request was succesful we use the variable as return value. + result = sa + return +} + +//// List Retrieve all vault records for a group (secrets are not included, default audit = true) +//func (s *VaultService) List(group *model.Group, query *model.VaultRecordQueryParams, additional *model.VaultRecordAdditionalQueryParams) (records []model.VaultRecord, err error) { + +// List all Service Accounts +func (s *ServiceAccountService) List(query *model.ServiceAccountQueryParams, additional *model.ServiceAccountAdditionalQueryParams) (list []model.ServiceAccount, err error) { + list = []model.ServiceAccount{} + + if query == nil { + query = new(model.ServiceAccountQueryParams) + } + if additional != nil { + query.Additional = additional + } + + searchRange := model.NewRange() + + var response *http.Response + + for ok := true; ok; ok = searchRange.NextPage() { + + errorReport := new(model.ErrorReport) + results := new(model.ServiceAccountList) + response, err = s.sling.New().Get("").QueryStruct(query).Add(searchRange.GetRequestRangeHeader()).Add(searchRange.GetRequestModeHeader()).Receive(results, errorReport) + searchRange.ParseResponse(response) + + if errorReport.Code > 0 { + err = errorReport.Wrap("Could not get Groups.") + } + if err == nil { + if len(results.Items) > 0 { + list = append(list, results.Items...) + } + } + + } + + return +} + +// Create Create a serviceaccount +func (s *ServiceAccountService) Create(serviceaccount *model.ServiceAccount) (result *model.ServiceAccount, err error) { + serviceAccounts := new(model.ServiceAccountList) + results := new(model.ServiceAccountList) + errorReport := new(model.ErrorReport) + serviceAccounts.Items = append(serviceAccounts.Items, *serviceaccount) + + _, err = s.sling.New().Post("").BodyJSON(serviceAccounts).Receive(results, errorReport) + + if errorReport.Code > 0 { + fmt.Println("Wrap", errorReport.Message) + //apiErr := model.NewKeyhubApiError(*errorReport, "Could not create ServiceAccount in System %q.", serviceaccount.System.Name) + err = errorReport.Wrap("Could not create ServiceAccount in System %q.", serviceaccount.System.Name) + + return + } + if err == nil { + if len(results.Items) > 0 { + result = &results.Items[0] + } else { + err = fmt.Errorf("Could not create ServiceAccount") + } + } + + return +} + +// Update Update service account +func (s *ServiceAccountService) Update(serviceAccount *model.ServiceAccount) (result *model.ServiceAccount, err error) { + updated := new(model.ServiceAccount) + errorReport := new(model.ErrorReport) + + selfUrl, _ := url.Parse(serviceAccount.Self().Href) + + _, err = s.sling.New().Path(selfUrl.Path).Put("").BodyJSON(serviceAccount).Receive(updated, errorReport) + if errorReport.Code > 0 { + err = errorReport.Wrap("Could not update ServiceAccount %s", serviceAccount.UUID) + return + } + result = updated + return +} + +// Delete Delete a service account by object +func (s *ServiceAccountService) Delete(serviceAccount *model.ServiceAccount) (err error) { + errorReport := new(model.ErrorReport) + + selfUrl, _ := url.Parse(serviceAccount.Self().Href) + + _, err = s.sling.New().Path(selfUrl.Path).Delete("").Receive(nil, errorReport) + if errorReport.Code > 0 { + err = errorReport.Wrap("Could not delete ServiceAccount %q", serviceAccount.UUID) + } + + return +} + +// DeleteByUUID Delete a service account by uuid for a certain group +func (s *ServiceAccountService) DeleteByUUID(uuid uuid.UUID) (err error) { + serviceAccount, err := s.GetByUUID(uuid) + if err != nil { + return err + } + + return s.Delete(serviceAccount) +} + +// DeleteByID Delete a service account by ID +func (s *ServiceAccountService) DeleteByID(id int64) (err error) { + serviceAccount, err := s.GetById(id) + if err != nil { + return err + } + return s.Delete(serviceAccount) +}