Integrations service for CRUD operations (#23989)

* Integrations service for CRUD operations

This adds the services that will handle the CRUD operations.
It also includes a custom JSON serializer.

* improve integration.unmarshaljson

* add trace.wrap to service

* add CheckAndSetDefaults and godocs

* Integration resource: remove status
This commit is contained in:
Marco André Dinis 2023-04-05 17:49:43 +01:00 committed by GitHub
parent c7671c774b
commit a71e822db0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 1226 additions and 0 deletions

View file

@ -338,6 +338,9 @@ const (
// KindHeadlessAuthentication is a headless authentication resource.
KindHeadlessAuthentication = "headless_authentication"
// KindIntegration is a connection to a 3rd party system API.
KindIntegration = "integration"
// V6 is the sixth version of resources.
V6 = "v6"

232
api/types/integration.go Normal file
View file

@ -0,0 +1,232 @@
/*
Copyright 2023 Gravitational, Inc.
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.
*/
package types
import (
"encoding/json"
"fmt"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/utils"
)
const (
// IntegrationSubKindAWSOIDC is an integration with AWS that uses OpenID Connect as an Identity Provider.
IntegrationSubKindAWSOIDC = "aws-oidc"
)
// Integration specifies is a connection configuration between Teleport and a 3rd party system.
type Integration interface {
ResourceWithLabels
// GetAWSOIDCIntegrationSpec returns the `aws-oidc` spec fields.
GetAWSOIDCIntegrationSpec() *AWSOIDCIntegrationSpecV1
// SetAWSOIDCIntegrationSpec sets the `aws-oidc` spec fields.
SetAWSOIDCIntegrationSpec(*AWSOIDCIntegrationSpecV1)
}
var _ ResourceWithLabels = (*IntegrationV1)(nil)
// NewIntegrationAWSOIDC returns a new `aws-oidc` subkind Integration
func NewIntegrationAWSOIDC(md Metadata, spec *AWSOIDCIntegrationSpecV1) (*IntegrationV1, error) {
ig := &IntegrationV1{
ResourceHeader: ResourceHeader{
Metadata: md,
Kind: KindIntegration,
Version: V1,
SubKind: IntegrationSubKindAWSOIDC,
},
Spec: IntegrationSpecV1{
SubKindSpec: &IntegrationSpecV1_AWSOIDC{
AWSOIDC: spec,
},
},
}
if err := ig.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
return ig, nil
}
// String returns the integration string representation.
func (ig *IntegrationV1) String() string {
return fmt.Sprintf("IntegrationV1(Name=%v, SubKind=%s, Labels=%v)",
ig.GetName(), ig.GetSubKind(), ig.GetAllLabels())
}
// MatchSearch goes through select field values and tries to
// match against the list of search values.
func (ig *IntegrationV1) MatchSearch(values []string) bool {
fieldVals := append(utils.MapToStrings(ig.GetAllLabels()), ig.GetName(), ig.GetSubKind())
return MatchSearch(fieldVals, values, nil)
}
// setStaticFields sets static resource header and metadata fields.
func (ig *IntegrationV1) setStaticFields() {
ig.Kind = KindIntegration
ig.Version = V1
}
// CheckAndSetDefaults checks and sets default values
func (ig *IntegrationV1) CheckAndSetDefaults() error {
ig.setStaticFields()
if err := ig.ResourceHeader.CheckAndSetDefaults(); err != nil {
return trace.Wrap(err)
}
return trace.Wrap(ig.Spec.CheckAndSetDefaults())
}
// CheckAndSetDefaults validates and sets default values for a integration.
func (s *IntegrationSpecV1) CheckAndSetDefaults() error {
if s.SubKindSpec == nil {
return trace.BadParameter("missing required subkind spec")
}
switch integrationSubKind := s.SubKindSpec.(type) {
case *IntegrationSpecV1_AWSOIDC:
err := integrationSubKind.CheckAndSetDefaults()
if err != nil {
return trace.Wrap(err)
}
default:
return trace.BadParameter("unknown integration subkind: %T", integrationSubKind)
}
return nil
}
// CheckAndSetDefaults validates an agent mesh integration.
func (s *IntegrationSpecV1_AWSOIDC) CheckAndSetDefaults() error {
if s == nil || s.AWSOIDC == nil {
return trace.BadParameter("aws_oidc is required for %q subkind", IntegrationSubKindAWSOIDC)
}
if s.AWSOIDC.RoleARN == "" {
return trace.BadParameter("role_arn is required for %q subkind", IntegrationSubKindAWSOIDC)
}
return nil
}
// GetAWSOIDCIntegrationSpec returns the specific spec fields for `aws-oidc` subkind integrations.
func (ig *IntegrationV1) GetAWSOIDCIntegrationSpec() *AWSOIDCIntegrationSpecV1 {
return ig.Spec.GetAWSOIDC()
}
// SetAWSOIDCIntegrationSpec sets the specific fields for the `aws-oidc` subkind integration.
func (ig *IntegrationV1) SetAWSOIDCIntegrationSpec(awsOIDCSpec *AWSOIDCIntegrationSpecV1) {
ig.Spec.SubKindSpec = &IntegrationSpecV1_AWSOIDC{
AWSOIDC: awsOIDCSpec,
}
}
// Integrations is a list of Integration resources.
type Integrations []Integration
// AsResources returns these groups as resources with labels.
func (igs Integrations) AsResources() []ResourceWithLabels {
resources := make([]ResourceWithLabels, len(igs))
for i, ig := range igs {
resources[i] = ig
}
return resources
}
// Len returns the slice length.
func (igs Integrations) Len() int { return len(igs) }
// Less compares integrations by name.
func (igs Integrations) Less(i, j int) bool { return igs[i].GetName() < igs[j].GetName() }
// Swap swaps two integrations.
func (igs Integrations) Swap(i, j int) { igs[i], igs[j] = igs[j], igs[i] }
// UnmarshalJSON is a custom unmarshaller for JSON format.
// It is required because the Spec.SubKindSpec proto field is a oneof.
// This translates into two issues when generating golang code:
// - the Spec.SubKindSpec field in Go is an interface
// - there's no way to provide json tags for oneof fields, so instead of snake_case, we get CamelCase for the Spec.SubKindSpec field
//
// Spec.SubKindSpec is an interface because it can have one of multiple values,
// even though there's only one type for now: aws_oidc.
// When trying to unmarshal this field, we must provide a concrete type.
// To do so, we unmarshal just the root fields (ResourceHeader: Name, Kind, SubKind, Version, Metadata)
// and then use its SubKind to provide a concrete type for the Spec.SubKindSpec field.
// Unmarshalling the remaining fields uses the standard json.Unmarshal over the Spec field.
//
// Spec.SubKindSpec is expecting the `SubKindSpec` json tag, however we are using snake_case everywhere.
// So, we create a local type that has the expected json tag (`sub_kind_spec`) and use it to unmarshal and then copy
// to the proper type.
func (ig *IntegrationV1) UnmarshalJSON(data []byte) error {
var integration IntegrationV1
d := struct {
ResourceHeader `json:""`
Spec struct {
RawSubKindSpec json.RawMessage `json:"subkind_spec"`
} `json:"spec"`
}{}
err := json.Unmarshal(data, &d)
if err != nil {
return trace.Wrap(err)
}
integration.ResourceHeader = d.ResourceHeader
var subkindSpec isIntegrationSpecV1_SubKindSpec
switch integration.SubKind {
case IntegrationSubKindAWSOIDC:
subkindSpec = &IntegrationSpecV1_AWSOIDC{}
default:
return trace.BadParameter("invalid subkind %q", integration.ResourceHeader.SubKind)
}
if err := json.Unmarshal(d.Spec.RawSubKindSpec, subkindSpec); err != nil {
return trace.Wrap(err)
}
integration.Spec.SubKindSpec = subkindSpec
if err := integration.CheckAndSetDefaults(); err != nil {
return trace.Wrap(err)
}
*ig = integration
return nil
}
// MarshalJSON is a custom marshaller for JSON format.
// gogoproto doesn't allow for oneof json tags [https://github.com/gogo/protobuf/issues/623]
// So, this is required to correctly use snake_case for every field.
// Please see [IntegrationV1.UnmarshalJSON] for more information.
func (ig *IntegrationV1) MarshalJSON() ([]byte, error) {
d := struct {
ResourceHeader `json:""`
Spec struct {
SubKindSpec isIntegrationSpecV1_SubKindSpec `json:"subkind_spec"`
} `json:"spec"`
}{}
d.ResourceHeader = ig.ResourceHeader
d.Spec.SubKindSpec = ig.Spec.SubKindSpec
out, err := json.Marshal(d)
return out, trace.Wrap(err)
}

View file

@ -0,0 +1,135 @@
/*
Copyright 2023 Gravitational, Inc.
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.
*/
package types
import (
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/defaults"
)
func TestIntegrationJSONMarshalCycle(t *testing.T) {
ig, err := NewIntegrationAWSOIDC(
Metadata{Name: "some-integration"},
&AWSOIDCIntegrationSpecV1{
RoleARN: "arn:aws:iam::123456789012:role/DevTeams",
},
)
require.NoError(t, err)
bs, err := json.Marshal(ig)
require.NoError(t, err)
var ig2 IntegrationV1
err = json.Unmarshal(bs, &ig2)
require.NoError(t, err)
require.Equal(t, ig, &ig2)
}
func TestIntegrationCheckAndSetDefaults(t *testing.T) {
noErrorFunc := func(err error) bool {
return err == nil
}
for _, tt := range []struct {
name string
integration func(string) (*IntegrationV1, error)
expectedIntegration func(string) *IntegrationV1
expectedErrorIs func(error) bool
}{
{
name: "valid",
integration: func(name string) (*IntegrationV1, error) {
return NewIntegrationAWSOIDC(
Metadata{
Name: name,
},
&AWSOIDCIntegrationSpecV1{
RoleARN: "some arn role",
},
)
},
expectedIntegration: func(name string) *IntegrationV1 {
return &IntegrationV1{
ResourceHeader: ResourceHeader{
Kind: KindIntegration,
SubKind: IntegrationSubKindAWSOIDC,
Version: V1,
Metadata: Metadata{
Name: name,
Namespace: defaults.Namespace,
},
},
Spec: IntegrationSpecV1{
SubKindSpec: &IntegrationSpecV1_AWSOIDC{
AWSOIDC: &AWSOIDCIntegrationSpecV1{
RoleARN: "some arn role",
},
},
},
}
},
expectedErrorIs: noErrorFunc,
},
{
name: "aws-oidc: error when subkind spec is not provided",
integration: func(name string) (*IntegrationV1, error) {
return NewIntegrationAWSOIDC(
Metadata{
Name: name,
},
nil,
)
},
expectedErrorIs: func(err error) bool {
return trace.IsBadParameter(err)
},
},
{
name: "aws-oidc: error when no role is provided",
integration: func(name string) (*IntegrationV1, error) {
return NewIntegrationAWSOIDC(
Metadata{
Name: name,
},
&AWSOIDCIntegrationSpecV1{},
)
},
expectedErrorIs: func(err error) bool {
return trace.IsBadParameter(err)
},
},
} {
t.Run(tt.name, func(t *testing.T) {
name := uuid.NewString()
ig, err := tt.integration(name)
require.True(t, tt.expectedErrorIs(err), "expected another error", err)
if err != nil {
return
}
require.Equal(t, tt.expectedIntegration(name), ig)
require.Contains(t, ig.String(), name)
})
}
}

View file

@ -0,0 +1,201 @@
/*
Copyright 2023 Gravitational, Inc.
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.
*/
package integrationv1
import (
"context"
"github.com/gravitational/trace"
"github.com/sirupsen/logrus"
"google.golang.org/protobuf/types/known/emptypb"
integrationpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/services"
)
// ServiceConfig holds configuration options for
// the Integration gRPC service.
type ServiceConfig struct {
Authorizer authz.Authorizer
Cache services.IntegrationsGetter
Backend services.Integrations
Logger *logrus.Entry
}
// CheckAndSetDefaults checks the ServiceConfig fields and returns an error if
// a required param is not provided.
// Authorizer, Cache and Backend are required params
func (s *ServiceConfig) CheckAndSetDefaults() error {
if s.Cache == nil {
return trace.BadParameter("cache is required")
}
if s.Backend == nil {
return trace.BadParameter("backend is required")
}
if s.Authorizer == nil {
return trace.BadParameter("authorizer is required")
}
if s.Logger == nil {
s.Logger = logrus.WithField(trace.Component, "integrations.service")
}
return nil
}
// Service implements the teleport.integration.v1.IntegrationService RPC service.
type Service struct {
integrationpb.UnimplementedIntegrationServiceServer
authorizer authz.Authorizer
cache services.IntegrationsGetter
backend services.Integrations
logger *logrus.Entry
}
// NewService returns a new Integrations gRPC service.
func NewService(cfg *ServiceConfig) (*Service, error) {
if err := cfg.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
return &Service{
logger: cfg.Logger,
authorizer: cfg.Authorizer,
cache: cfg.Cache,
backend: cfg.Backend,
}, nil
}
var _ integrationpb.IntegrationServiceServer = (*Service)(nil)
// ListIntegrations returns a paginated list of all Integration resources.
func (s *Service) ListIntegrations(ctx context.Context, req *integrationpb.ListIntegrationsRequest) (*integrationpb.ListIntegrationsResponse, error) {
_, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindIntegration, types.VerbRead, types.VerbList)
if err != nil {
return nil, trace.Wrap(err)
}
results, nextKey, err := s.cache.ListIntegrations(ctx, int(req.GetLimit()), req.GetNextKey())
if err != nil {
return nil, trace.Wrap(err)
}
igs := make([]*types.IntegrationV1, len(results))
for i, r := range results {
v1, ok := r.(*types.IntegrationV1)
if !ok {
return nil, trace.BadParameter("unexpected Integration type %T", r)
}
igs[i] = v1
}
return &integrationpb.ListIntegrationsResponse{
Integrations: igs,
NextKey: nextKey,
}, nil
}
// GetIntegration returns the specified Integration resource.
func (s *Service) GetIntegration(ctx context.Context, req *integrationpb.GetIntegrationRequest) (*types.IntegrationV1, error) {
_, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindIntegration, types.VerbRead)
if err != nil {
return nil, trace.Wrap(err)
}
integration, err := s.cache.GetIntegration(ctx, req.GetName())
if err != nil {
return nil, trace.Wrap(err)
}
igV1, ok := integration.(*types.IntegrationV1)
if !ok {
return nil, trace.BadParameter("unexpected Integration type %T", integration)
}
return igV1, nil
}
// CreateIntegration creates a new Okta import rule resource.
func (s *Service) CreateIntegration(ctx context.Context, req *integrationpb.CreateIntegrationRequest) (*types.IntegrationV1, error) {
_, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindIntegration, types.VerbCreate)
if err != nil {
return nil, trace.Wrap(err)
}
ig, err := s.backend.CreateIntegration(ctx, req.GetIntegration())
if err != nil {
return nil, trace.Wrap(err)
}
igV1, ok := ig.(*types.IntegrationV1)
if !ok {
return nil, trace.BadParameter("unexpected Integration type %T", ig)
}
return igV1, nil
}
// UpdateIntegration updates an existing Okta import rule resource.
func (s *Service) UpdateIntegration(ctx context.Context, req *integrationpb.UpdateIntegrationRequest) (*types.IntegrationV1, error) {
_, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindIntegration, types.VerbUpdate)
if err != nil {
return nil, trace.Wrap(err)
}
ig, err := s.backend.UpdateIntegration(ctx, req.GetIntegration())
if err != nil {
return nil, trace.Wrap(err)
}
igV1, ok := ig.(*types.IntegrationV1)
if !ok {
return nil, trace.BadParameter("unexpected Integration type %T", ig)
}
return igV1, nil
}
// DeleteIntegration removes the specified Integration resource.
func (s *Service) DeleteIntegration(ctx context.Context, req *integrationpb.DeleteIntegrationRequest) (*emptypb.Empty, error) {
_, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindIntegration, types.VerbDelete)
if err != nil {
return nil, trace.Wrap(err)
}
if err := s.backend.DeleteIntegration(ctx, req.GetName()); err != nil {
return nil, trace.Wrap(err)
}
return &emptypb.Empty{}, nil
}
// DeleteAllIntegrations removes all Integration resources.
func (s *Service) DeleteAllIntegrations(ctx context.Context, _ *integrationpb.DeleteAllIntegrationsRequest) (*emptypb.Empty, error) {
_, err := authz.AuthorizeWithVerbs(ctx, s.logger, s.authorizer, true, types.KindIntegration, types.VerbDelete)
if err != nil {
return nil, trace.Wrap(err)
}
if err := s.backend.DeleteAllIntegrations(ctx); err != nil {
return nil, trace.Wrap(err)
}
return &emptypb.Empty{}, nil
}

View file

@ -0,0 +1,384 @@
/*
Copyright 2023 Gravitational, Inc.
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.
*/
package integrationv1
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
integrationpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/backend/memory"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/local"
"github.com/gravitational/teleport/lib/tlsca"
)
func TestIntegrationCRUD(t *testing.T) {
t.Parallel()
ctx, localClient, resourceSvc := initSvc(t, types.KindIntegration)
noError := func(err error) bool {
return err == nil
}
sampleIntegrationFn := func(t *testing.T, name string) types.Integration {
ig, err := types.NewIntegrationAWSOIDC(
types.Metadata{Name: name},
&types.AWSOIDCIntegrationSpecV1{
RoleARN: "arn:aws:iam::123456789012:role/OpsTeam",
},
)
require.NoError(t, err)
return ig
}
tt := []struct {
Name string
Role types.RoleSpecV6
Setup func(t *testing.T, igName string)
Test func(ctx context.Context, resourceSvc *Service, igName string) error
ErrAssertion func(error) bool
}{
// Read
{
Name: "allowed read access to integrations",
Role: types.RoleSpecV6{
Allow: types.RoleConditions{Rules: []types.Rule{{
Resources: []string{types.KindIntegration},
Verbs: []string{types.VerbRead},
}}},
},
Setup: func(t *testing.T, igName string) {
_, err := localClient.CreateIntegration(ctx, sampleIntegrationFn(t, igName))
require.NoError(t, err)
},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.GetIntegration(ctx, &integrationpb.GetIntegrationRequest{
Name: igName,
})
return err
},
ErrAssertion: noError,
},
{
Name: "no access to read integrations",
Role: types.RoleSpecV6{},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.GetIntegration(ctx, &integrationpb.GetIntegrationRequest{
Name: igName,
})
return err
},
ErrAssertion: trace.IsAccessDenied,
},
{
Name: "denied access to read integrations",
Role: types.RoleSpecV6{
Deny: types.RoleConditions{Rules: []types.Rule{{
Resources: []string{types.KindIntegration},
Verbs: []string{types.VerbRead},
}}},
},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.GetIntegration(ctx, &integrationpb.GetIntegrationRequest{
Name: igName,
})
return err
},
ErrAssertion: trace.IsAccessDenied,
},
// List
{
Name: "allowed list access to integrations",
Role: types.RoleSpecV6{
Allow: types.RoleConditions{Rules: []types.Rule{{
Resources: []string{types.KindIntegration},
Verbs: []string{types.VerbList, types.VerbRead},
}}},
},
Setup: func(t *testing.T, _ string) {
for i := 0; i < 10; i++ {
_, err := localClient.CreateIntegration(ctx, sampleIntegrationFn(t, uuid.NewString()))
require.NoError(t, err)
}
},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.ListIntegrations(ctx, &integrationpb.ListIntegrationsRequest{
Limit: 0,
NextKey: "",
})
return err
},
ErrAssertion: noError,
},
{
Name: "no list access to integrations",
Role: types.RoleSpecV6{
Allow: types.RoleConditions{Rules: []types.Rule{{
Resources: []string{types.KindIntegration},
Verbs: []string{types.VerbCreate},
}}},
},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.ListIntegrations(ctx, &integrationpb.ListIntegrationsRequest{
Limit: 0,
NextKey: "",
})
return err
},
ErrAssertion: trace.IsAccessDenied,
},
// Create
{
Name: "no access to create integrations",
Role: types.RoleSpecV6{},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
ig := sampleIntegrationFn(t, igName)
_, err := resourceSvc.CreateIntegration(ctx, &integrationpb.CreateIntegrationRequest{Integration: ig.(*types.IntegrationV1)})
return err
},
ErrAssertion: trace.IsAccessDenied,
},
{
Name: "access to create integrations",
Role: types.RoleSpecV6{
Allow: types.RoleConditions{Rules: []types.Rule{{
Resources: []string{types.KindIntegration},
Verbs: []string{types.VerbCreate},
}}},
},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
ig := sampleIntegrationFn(t, igName)
_, err := resourceSvc.CreateIntegration(ctx, &integrationpb.CreateIntegrationRequest{Integration: ig.(*types.IntegrationV1)})
return err
},
ErrAssertion: noError,
},
// Update
{
Name: "no access to update integration",
Role: types.RoleSpecV6{},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
ig := sampleIntegrationFn(t, igName)
_, err := resourceSvc.UpdateIntegration(ctx, &integrationpb.UpdateIntegrationRequest{Integration: ig.(*types.IntegrationV1)})
return err
},
ErrAssertion: trace.IsAccessDenied,
},
{
Name: "access to update integration",
Role: types.RoleSpecV6{
Allow: types.RoleConditions{Rules: []types.Rule{{
Resources: []string{types.KindIntegration},
Verbs: []string{types.VerbUpdate},
}}},
},
Setup: func(t *testing.T, igName string) {
_, err := localClient.CreateIntegration(ctx, sampleIntegrationFn(t, igName))
require.NoError(t, err)
},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
ig := sampleIntegrationFn(t, igName)
_, err := resourceSvc.UpdateIntegration(ctx, &integrationpb.UpdateIntegrationRequest{Integration: ig.(*types.IntegrationV1)})
return err
},
ErrAssertion: noError,
},
// Delete
{
Name: "no access to delete integration",
Role: types.RoleSpecV6{},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.DeleteIntegration(ctx, &integrationpb.DeleteIntegrationRequest{Name: "x"})
return err
},
ErrAssertion: trace.IsAccessDenied,
},
{
Name: "access to delete integration",
Role: types.RoleSpecV6{
Allow: types.RoleConditions{Rules: []types.Rule{{
Resources: []string{types.KindIntegration},
Verbs: []string{types.VerbDelete},
}}},
},
Setup: func(t *testing.T, igName string) {
_, err := localClient.CreateIntegration(ctx, sampleIntegrationFn(t, igName))
require.NoError(t, err)
},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.DeleteIntegration(ctx, &integrationpb.DeleteIntegrationRequest{Name: igName})
return err
},
ErrAssertion: noError,
},
// Delete all
{
Name: "remove all integrations fails when no access",
Role: types.RoleSpecV6{},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.DeleteAllIntegrations(ctx, &integrationpb.DeleteAllIntegrationsRequest{})
return err
},
ErrAssertion: trace.IsAccessDenied,
},
{
Name: "remove all integrations",
Role: types.RoleSpecV6{
Allow: types.RoleConditions{Rules: []types.Rule{{
Resources: []string{types.KindIntegration},
Verbs: []string{types.VerbDelete},
}}},
},
Setup: func(t *testing.T, _ string) {
for i := 0; i < 10; i++ {
_, err := localClient.CreateIntegration(ctx, sampleIntegrationFn(t, uuid.NewString()))
require.NoError(t, err)
}
},
Test: func(ctx context.Context, resourceSvc *Service, igName string) error {
_, err := resourceSvc.DeleteAllIntegrations(ctx, &integrationpb.DeleteAllIntegrationsRequest{})
return err
},
ErrAssertion: noError,
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
localCtx := authorizerForDummyUser(t, ctx, tc.Role, localClient)
igName := uuid.NewString()
if tc.Setup != nil {
tc.Setup(t, igName)
}
err := tc.Test(localCtx, resourceSvc, igName)
require.True(t, tc.ErrAssertion(err), err)
})
}
}
func authorizerForDummyUser(t *testing.T, ctx context.Context, roleSpec types.RoleSpecV6, localClient localClient) context.Context {
// Create role
roleName := "role-" + uuid.NewString()
role, err := types.NewRole(roleName, roleSpec)
require.NoError(t, err)
err = localClient.CreateRole(ctx, role)
require.NoError(t, err)
// Create user
user, err := types.NewUser("user-" + uuid.NewString())
require.NoError(t, err)
user.AddRole(roleName)
err = localClient.CreateUser(user)
require.NoError(t, err)
return authz.ContextWithUser(ctx, authz.LocalUser{
Username: user.GetName(),
Identity: tlsca.Identity{
Username: user.GetName(),
Groups: []string{role.GetName()},
},
})
}
type localClient interface {
CreateUser(user types.User) error
CreateRole(ctx context.Context, role types.Role) error
CreateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error)
}
func initSvc(t *testing.T, kind string) (context.Context, localClient, *Service) {
ctx := context.Background()
backend, err := memory.New(memory.Config{})
require.NoError(t, err)
clusterConfigSvc, err := local.NewClusterConfigurationService(backend)
require.NoError(t, err)
trustSvc := local.NewCAService(backend)
roleSvc := local.NewAccessService(backend)
userSvc := local.NewIdentityService(backend)
require.NoError(t, clusterConfigSvc.SetAuthPreference(ctx, types.DefaultAuthPreference()))
require.NoError(t, clusterConfigSvc.SetClusterAuditConfig(ctx, types.DefaultClusterAuditConfig()))
require.NoError(t, clusterConfigSvc.SetClusterNetworkingConfig(ctx, types.DefaultClusterNetworkingConfig()))
require.NoError(t, clusterConfigSvc.SetSessionRecordingConfig(ctx, types.DefaultSessionRecordingConfig()))
accessPoint := struct {
services.ClusterConfiguration
services.Trust
services.RoleGetter
services.UserGetter
}{
ClusterConfiguration: clusterConfigSvc,
Trust: trustSvc,
RoleGetter: roleSvc,
UserGetter: userSvc,
}
accessService := local.NewAccessService(backend)
eventService := local.NewEventsService(backend)
lockWatcher, err := services.NewLockWatcher(ctx, services.LockWatcherConfig{
ResourceWatcherConfig: services.ResourceWatcherConfig{
Client: eventService,
Component: "test",
},
LockGetter: accessService,
})
require.NoError(t, err)
authorizer, err := authz.NewAuthorizer(authz.AuthorizerOpts{
ClusterName: "test-cluster",
AccessPoint: accessPoint,
LockWatcher: lockWatcher,
})
require.NoError(t, err)
localResourceService, err := local.NewIntegrationsService(backend)
require.NoError(t, err)
resourceSvc, err := NewService(&ServiceConfig{
Backend: localResourceService,
Authorizer: authorizer,
Cache: localResourceService,
})
require.NoError(t, err)
return ctx, struct {
*local.AccessService
*local.IdentityService
*local.IntegrationsService
}{
AccessService: roleSvc,
IdentityService: userSvc,
IntegrationsService: localResourceService,
}, resourceSvc
}

View file

@ -0,0 +1,98 @@
/*
Copyright 2023 Gravitational, Inc.
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.
*/
package services
import (
"context"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/utils"
)
// Integrations defines an interface for managing Integrations.
type Integrations interface {
IntegrationsGetter
// CreateIntegration creates a new integration resource.
CreateIntegration(context.Context, types.Integration) (types.Integration, error)
// UpdateIntegration updates an existing integration resource.
UpdateIntegration(context.Context, types.Integration) (types.Integration, error)
// DeleteIntegration removes the specified integration resource.
DeleteIntegration(ctx context.Context, name string) error
// DeleteAllIntegrations removes all integrations.
DeleteAllIntegrations(context.Context) error
}
// IntegrationsGetter defines methods for List/Read operations on Integration Resources.
type IntegrationsGetter interface {
// ListIntegrations returns a paginated list of all integration resources.
ListIntegrations(ctx context.Context, pageSize int, nextToken string) ([]types.Integration, string, error)
// GetIntegration returns the specified integration resources.
GetIntegration(ctx context.Context, name string) (types.Integration, error)
}
// MarshalIntegration marshals the Integration resource to JSON.
func MarshalIntegration(ig types.Integration, opts ...MarshalOption) ([]byte, error) {
if err := ig.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
cfg, err := CollectOptions(opts)
if err != nil {
return nil, trace.Wrap(err)
}
switch g := ig.(type) {
case *types.IntegrationV1:
if !cfg.PreserveResourceID {
copy := *g
copy.SetResourceID(0)
g = &copy
}
return utils.FastMarshal(g)
default:
return nil, trace.BadParameter("unsupported integration resource %T", g)
}
}
// UnmarshalIntegration unmarshals Integration resource from JSON.
func UnmarshalIntegration(data []byte, opts ...MarshalOption) (types.Integration, error) {
if len(data) == 0 {
return nil, trace.BadParameter("missing resource data")
}
var ig types.IntegrationV1
err := utils.FastUnmarshal(data, &ig)
if err != nil {
return nil, trace.Wrap(err)
}
cfg, err := CollectOptions(opts)
if err != nil {
return nil, trace.Wrap(err)
}
if cfg.ID != 0 {
ig.SetResourceID(cfg.ID)
}
if !cfg.Expires.IsZero() {
ig.SetExpiry(cfg.Expires)
}
return &ig, nil
}

View file

@ -0,0 +1,60 @@
/*
Copyright 2023 Gravitational, Inc.
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.
*/
package services
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
)
func TestIntegrationMarshalCycle(t *testing.T) {
ig, err := types.NewIntegrationAWSOIDC(
types.Metadata{Name: "some-integration"},
&types.AWSOIDCIntegrationSpecV1{
RoleARN: "arn:aws:iam::123456789012:role/DevTeams",
},
)
require.NoError(t, err)
bs, err := MarshalIntegration(ig)
require.NoError(t, err)
ig2, err := UnmarshalIntegration(bs)
require.NoError(t, err)
require.Equal(t, ig, ig2)
}
func TestIntegrationUnmarshal(t *testing.T) {
ig, err := types.NewIntegrationAWSOIDC(
types.Metadata{Name: "some-integration"},
&types.AWSOIDCIntegrationSpecV1{
RoleARN: "arn:aws:iam::123456789012:role/DevTeams",
},
)
require.NoError(t, err)
storedBlob := []byte(`{"kind":"integration","sub_kind":"aws-oidc","version":"v1","metadata":{"name":"some-integration"},"spec":{"subkind_spec":{"aws_oidc":{"role_arn":"arn:aws:iam::123456789012:role/DevTeams"}}}}`)
ig2, err := UnmarshalIntegration(storedBlob)
require.NoError(t, err)
require.NotNil(t, ig)
require.Equal(t, ig, ig2)
}

View file

@ -0,0 +1,113 @@
/*
Copyright 2023 Gravitational, Inc.
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.
*/
package local
import (
"context"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/local/generic"
)
const (
integrationsPrefix = "integrations"
)
// IntegrationsService manages Integrations in the Backend.
type IntegrationsService struct {
svc generic.Service[types.Integration]
}
// NewIntegrationsService creates a new IntegrationsService.
func NewIntegrationsService(backend backend.Backend) (*IntegrationsService, error) {
svc, err := generic.NewService(&generic.ServiceConfig[types.Integration]{
Backend: backend,
PageLimit: defaults.MaxIterationLimit,
ResourceKind: types.KindIntegration,
BackendPrefix: integrationsPrefix,
MarshalFunc: services.MarshalIntegration,
UnmarshalFunc: services.UnmarshalIntegration,
})
if err != nil {
return nil, trace.Wrap(err)
}
return &IntegrationsService{
svc: *svc,
}, nil
}
// ListIntegrationss returns a paginated list of Integration resources.
func (s *IntegrationsService) ListIntegrations(ctx context.Context, pageSize int, pageToken string) ([]types.Integration, string, error) {
igs, nextKey, err := s.svc.ListResources(ctx, pageSize, pageToken)
if err != nil {
return nil, "", trace.Wrap(err)
}
return igs, nextKey, nil
}
// GetIntegrations returns the specified Integration resource.
func (s *IntegrationsService) GetIntegration(ctx context.Context, name string) (types.Integration, error) {
ig, err := s.svc.GetResource(ctx, name)
if err != nil {
return nil, trace.Wrap(err)
}
return ig, nil
}
// CreateIntegrations creates a new Integration resource.
func (s *IntegrationsService) CreateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error) {
if err := ig.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
if err := s.svc.CreateResource(ctx, ig); err != nil {
return nil, trace.Wrap(err)
}
return ig, nil
}
// UpdateIntegrations updates an existing Integration resource.
func (s *IntegrationsService) UpdateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error) {
if err := ig.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
if err := s.svc.UpdateResource(ctx, ig); err != nil {
return nil, trace.Wrap(err)
}
return ig, nil
}
// DeleteIntegrations removes the specified Integration resource.
func (s *IntegrationsService) DeleteIntegration(ctx context.Context, name string) error {
return trace.Wrap(s.svc.DeleteResource(ctx, name))
}
// DeleteAllIntegrationss removes all Integration resources.
func (s *IntegrationsService) DeleteAllIntegrations(ctx context.Context) error {
return trace.Wrap(s.svc.DeleteAllResources(ctx))
}