diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1605892..df2477b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,6 +57,7 @@ jobs: gotestsum --format short-verbose -- -count=1 -v ${{ matrix.package }}/... push: + needs: [test] runs-on: ubuntu-latest # when on a branch only push if the branch is main # always push when ref is a tag @@ -107,18 +108,6 @@ jobs: git config --global user.name "Aserto Bot" eval `ssh-agent` ssh-add $HOME/.ssh/id_rsa - - - name: Wait for tests to succeed - uses: fountainhead/action-wait-for-check@v1.1.0 - id: wait-for-tests - with: - token: ${{ env.READ_WRITE_TOKEN }} - checkName: test - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - - name: Stop if tests fail - if: steps.wait-for-tests.outputs.conclusion != 'success' - run: exit 1 - name: Push image to GitHub Container Registry uses: goreleaser/goreleaser-action@v6 diff --git a/README.md b/README.md index d59eac5..4f19fa7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ The Aserto SCIM service uses the SCIM 2.0 protocol to import data into the Asert ```yaml --- logging: - prod: true log_level: info server: listen_address: ":8080" @@ -18,17 +17,30 @@ server: enabled: true token: "scim" directory: - address: "directory.prod.aserto.com:8443" - tenant_id: "your_tenant_id" - api_key: "your_directory_rw_api_key" + address: "localhost:9292" + no_tls: true scim: - create_email_identities: true - create_role_groups: true - group_mappings: - - subject_id: app-admin + user: + object_type: user + identity_object_type: identity + identity_relation: user#identifier + property_mapping: + enabled: active + source_object_type: scim_user + manager_relation: manager + group: + object_type: group + group_member_relation: member + source_object_type: scim_group + role: + object_type: group + role_relation: member + relations: + - object_id: system object_type: system - object_id: administrators - relation: member + relation: admin + subject_id: admins + subject_type: group subject_relation: member ``` @@ -78,6 +90,8 @@ curl -X POST \ }' ``` +The create operation will return a user ID, which will be used to identify the user from now on + ### get a user `curl -X 'GET' 'http://127.0.0.1:8080/Users/{user id}' ` @@ -139,13 +153,14 @@ curl -X PATCH \ ]}' ``` -### create a relation from an imported group to a aserto user (e.g. giving admin permission to users that are port of an imported group) +### create a relation from an imported group to a user (e.g. giving admin permission to users that are port of an imported group) ``` - group_mappings: - - subject_id: app-admin + relations: + - object_id: system object_type: system - object_id: administrators relation: admin + subject_id: admins + subject_type: group subject_relation: member ``` -This will create a `admin` relation with `member` subject relation between the imported `add-admin` group and the already created object with id `administrators` ant type `system` \ No newline at end of file +This will create a `admin` relation with `member` subject relation between the `admins` group and the object with id `system` and type `system` \ No newline at end of file diff --git a/common/assets.go b/common/assets.go index 6943707..94559bc 100644 --- a/common/assets.go +++ b/common/assets.go @@ -1,14 +1,12 @@ package common import ( - "embed" - "fmt" + _ "embed" ) -//go:embed assets/* -var staticAssets embed.FS +//go:embed assets/template.tmpl +var template []byte -func LoadTemplate(templateName string) ([]byte, error) { - templateFile := fmt.Sprintf("assets/%s.tmpl", templateName) - return staticAssets.ReadFile(templateFile) +func LoadDefaultTemplate() []byte { + return template } diff --git a/common/assets/users-groups-roles.tmpl b/common/assets/template.tmpl similarity index 77% rename from common/assets/users-groups-roles.tmpl rename to common/assets/template.tmpl index c4ab3df..efac962 100644 --- a/common/assets/users-groups-roles.tmpl +++ b/common/assets/template.tmpl @@ -2,7 +2,7 @@ "objects": [ {{- if eq .objectType "user" }} { - "id": "{{ $.input.userName }}", + "id": "{{ $.objectId }}", "type": "{{ $.vars.user.object_type }}", "displayName": "{{ $.input.displayName }}" }, @@ -50,7 +50,7 @@ {{- end }} {{- else }} { - "id": "{{ $.input.displayName }}", + "id": "{{ $.objectId }}", "type": "{{ $.vars.group.object_type }}", "displayName": "{{ $.input.displayName }}" } @@ -61,14 +61,10 @@ {{- $idRelationMap := splitn "#" 2 $.vars.user.identity_relation }} {{- $idObjType := $idRelationMap._0 }} {{- $idRelation := $idRelationMap._1 }} - {{- $idSubjType := $.vars.user.object_type }} - {{- $objId := $.input.userName }} - {{- $subjId := $.input.userName }} - - {{- if eq $idObjType $.vars.user.object_type }} - {{- $idSubjType = $.vars.user.identity_object_type }} - {{- $subjId = $.input.userName }} - {{- end }} + {{- $idSubjType := ternary $.vars.user.identity_object_type $.vars.user.object_type (eq $idObjType $.vars.user.object_type) }} + + {{- $objId := ternary $.objectId $.input.userName (eq $idObjType $.vars.user.object_type) }} + {{- $subjId := ternary $.input.userName $.objectId (eq $idObjType $.vars.user.object_type) }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -77,14 +73,9 @@ "subject_id": "{{ $subjId }}" }, {{- range $i, $element := $.input.emails }} - {{- if $i }},{{ end }} - {{- if eq $idObjType $.vars.user.object_type }} - {{- $subjId = $element.value }} - {{- $objId := $.input.userName }} - {{- else }} - {{- $subjId := $.input.userName }} - {{- $objId = $element.value }} - {{- end }} + {{- $objId := ternary $.objectId $element.value (eq $idObjType $.vars.user.object_type) }} + {{- $subjId := ternary $element.value $.objectId (eq $idObjType $.vars.user.object_type) }} + {{ if $i }},{{ end }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -95,13 +86,8 @@ {{- end }} {{- if $.input.externalId }} , - {{- if eq $idObjType $.vars.user.object_type }} - {{- $objId := $.input.userName }} - {{- $subjId = $.input.externalId }} - {{- else }} - {{- $objId = $.input.externalId }} - {{- $subjId = $.input.userName }} - {{- end }} + {{- $objId := ternary $.objectId $.input.externalId (eq $idObjType $.vars.user.object_type) }} + {{- $subjId := ternary $.input.externalId $.objectId (eq $idObjType $.vars.user.object_type) }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -117,7 +103,7 @@ , { "object_type": "{{ $.vars.user.object_type }}", - "object_id": "{{ $.input.userName }}", + "object_id": "{{ $.objectId }}", "relation": "{{ $.vars.user.manager_relation }}", "subject_type": "{{ $.vars.user.object_type }}", "subject_id": "{{ $manager.manager.value }}" @@ -133,7 +119,7 @@ "object_id": "{{ $element.value }}", "relation": "{{ $.vars.role.role_relation }}", "subject_type": "{{ $.vars.user.object_type }}", - "subject_id": "{{ $.input.userName }}" + "subject_id": "{{ $.objectId }}" } {{- end }} {{- end }} @@ -144,7 +130,7 @@ {{ if $i }},{{ end }} { "object_type": "{{ $.vars.group.object_type }}", - "object_id": "{{ $.input.displayName }}", + "object_id": "{{ $.objectId }}", "relation": "{{ $.vars.group.group_member_relation }}", "subject_type": "{{ $.vars.user.object_type }}", "subject_id": "{{ $member.value }}" diff --git a/common/assets/users-groups.tmpl b/common/assets/users-groups.tmpl deleted file mode 100644 index feb0acc..0000000 --- a/common/assets/users-groups.tmpl +++ /dev/null @@ -1,130 +0,0 @@ -{ - "objects": [ - {{ if eq .objectType "user" }} - { - "id": "{{ $.input.userName }}", - "type": "{{ $.vars.user.object_type }}", - "displayName": "{{ $.input.displayName }}" - }, - { - "id": "{{ $.input.userName }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties": { - "verified": true - } - }, - {{ range $i, $element := $.input.emails }} - {{ if $i }},{{ end }} - { - "id": "{{ $element.value }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties":{ - "type": "{{ $element.type }}", - "verified": true - } - } - {{ end }} - {{ if and ($.input.externalId) (ne $.input.externalId "") }} - , - { - "id": "{{ $.input.externalId }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties": { - "verified": true - } - } - {{ end }} - {{ else }} - { - "id": "{{ $.input.displayName }}", - "type": "{{ $.vars.group.object_type }}", - "displayName": "{{ $.input.displayName }}" - } - {{ end }} - ], - "relations":[ - {{ if eq .objectType "user" }} - {{ $idRelationMap := splitn "#" 2 $.vars.user.identity_relation }} - {{ $idObjType := $idRelationMap._0 }} - {{ $idRelation := $idRelationMap._1 }} - {{ $idSubjType := $.vars.user.object_type }} - {{ $objId := $.input.userName }} - {{ $subjId := $.input.userName }} - - {{ if eq $idObjType $.vars.user.object_type }} - {{ $idSubjType = $.vars.user.identity_object_type }} - {{ $subjId = $.input.userName }} - {{ end }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - }, - {{ range $i, $element := $.input.emails }} - {{ if $i }},{{ end }} - {{ if eq $idObjType $.vars.user.object_type }} - {{ $subjId = $element.value }} - {{ $objId := $.input.userName }} - {{ else }} - {{ $subjId := $.input.userName }} - {{ $objId = $element.value }} - {{ end }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - } - {{ end }} - {{ if and ($.input.externalId) (ne $.input.externalId "") }} - , - {{ if eq $idObjType $.vars.user.object_type }} - {{ $objId := $.input.userName }} - {{ $subjId = $.input.externalId }} - {{ else }} - {{ $objId = $.input.externalId }} - {{ $subjId = $.input.userName }} - {{ end }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - } - {{ end }} - {{ if and ($.vars.user.manager_relation) (ne $.vars.user.manager_relation "") }} - {{ $manager := index .input "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" }} - {{ if $manager }} - {{ if and ($manager.manager.value) (ne $manager.manager.value "") }} - , - { - "object_type": "{{ $.vars.user.object_type }}", - "object_id": "{{ $.input.userName }}", - "relation": "{{ $.vars.user.manager_relation }}", - "subject_type": "{{ $.vars.user.object_type }}", - "subject_id": "{{ $manager.manager.value }}" - } - {{ end }} - {{ end }} - {{ end }} - {{ else }} - {{ $members := index .input "members" }} - {{ if $members }} - {{ range $i, $member := $members }} - {{ if $i }},{{ end }} - { - "object_type": "{{ $.vars.group.object_type }}", - "object_id": "{{ $.input.displayName }}", - "relation": "{{ $.vars.group.group_member_relation }}", - "subject_type": "{{ $.vars.user.object_type }}", - "subject_id": "{{ $member.value }}" - } - {{ end }} - {{ end }} - {{ end }} - ] -} diff --git a/common/assets/users.tmpl b/common/assets/users.tmpl deleted file mode 100644 index 42016b0..0000000 --- a/common/assets/users.tmpl +++ /dev/null @@ -1,110 +0,0 @@ -{ - "objects": [ - {{ if eq .objectType "user" }} - { - "id": "{{ $.input.userName }}", - "type": "{{ $.vars.user.object_type }}", - "displayName": "{{ $.input.displayName }}" - }, - { - "id": "{{ $.input.userName }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties": { - "verified": true - } - }, - {{ range $i, $element := $.input.emails }} - {{ if $i }},{{ end }} - { - "id": "{{ $element.value }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties":{ - "type": "{{ $element.type }}", - "verified": true - } - } - {{ end }} - {{ if and ($.input.externalId) (ne $.input.externalId "") }} - , - { - "id": "{{ $.input.externalId }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties": { - "verified": true - } - } - {{ end }} - {{ end }} - ], - "relations":[ - {{ if eq .objectType "user" }} - {{ $idRelationMap := splitn "#" 2 $.vars.user.identity_relation }} - {{ $idObjType := $idRelationMap._0 }} - {{ $idRelation := $idRelationMap._1 }} - {{ $idSubjType := $.vars.user.object_type }} - {{ $objId := $.input.userName }} - {{ $subjId := $.input.userName }} - - {{ if eq $idObjType $.vars.user.object_type }} - {{ $idSubjType = $.vars.user.identity_object_type }} - {{ $subjId = $.input.userName }} - {{ end }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - }, - {{ range $i, $element := $.input.emails }} - {{ if $i }},{{ end }} - {{ if eq $idObjType $.vars.user.object_type }} - {{ $subjId = $element.value }} - {{ $objId := $.input.userName }} - {{ else }} - {{ $subjId := $.input.userName }} - {{ $objId = $element.value }} - {{ end }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - } - {{ end }} - {{ if and ($.input.externalId) (ne $.input.externalId "") }} - , - {{ if eq $idObjType $.vars.user.object_type }} - {{ $objId := $.input.userName }} - {{ $subjId = $.input.externalId }} - {{ else }} - {{ $objId = $.input.externalId }} - {{ $subjId = $.input.userName }} - {{ end }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - } - {{ end }} - {{ if and ($.vars.user.manager_relation) (ne $.vars.user.manager_relation "") }} - {{ $manager := index .input "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" }} - {{ if $manager }} - {{ if and ($manager.manager.value) (ne $manager.manager.value "") }} - , - { - "object_type": "{{ $.vars.user.object_type }}", - "object_id": "{{ $.input.userName }}", - "relation": "{{ $.vars.user.manager_relation }}", - "subject_type": "{{ $.vars.user.object_type }}", - "subject_id": "{{ $manager.manager.value }}" - } - {{ end }} - {{ end }} - {{ end }} - {{ end }} - ] -} diff --git a/common/convert/config.go b/common/convert/config.go index faf489e..2f0f9e1 100644 --- a/common/convert/config.go +++ b/common/convert/config.go @@ -10,46 +10,16 @@ import ( "github.com/pkg/errors" ) -type TemplateKind int - -const ( - Users TemplateKind = iota - UsersGroups - UsersGroupsRoles -) - var ErrInvalidConfig = errors.New("invalid config") -func (t TemplateKind) String() string { - switch t { - case Users: - return "users" - case UsersGroups: - return "users-groups" - case UsersGroupsRoles: - return "users-groups-roles" - } - - return "unknown" -} - type TransformConfig struct { *config.Config - template TemplateKind + template []byte IdentityObjectType string `json:"identity_object_type,omitempty"` IdentityRelation string `json:"identity_relation,omitempty"` } func NewTransformConfig(cfg *config.Config) (*TransformConfig, error) { - template := Users - - if cfg.HasGroups() { - template = UsersGroups - if cfg.Role != nil { - template = UsersGroupsRoles - } - } - object, relation, found := strings.Cut(cfg.User.IdentityRelation, "#") if !found { return nil, errors.Wrap(ErrInvalidConfig, "identity relation must be in the format object#relation") @@ -65,7 +35,6 @@ func NewTransformConfig(cfg *config.Config) (*TransformConfig, error) { return &TransformConfig{ Config: cfg, - template: template, IdentityObjectType: object, IdentityRelation: relation, }, nil @@ -86,8 +55,17 @@ func (c *TransformConfig) ToTemplateVars() (map[string]any, error) { return result, nil } -func (c *TransformConfig) Template() ([]byte, error) { - return common.LoadTemplate(c.template.String()) +func (c *TransformConfig) Template() []byte { + if c.template == nil { + return common.LoadDefaultTemplate() + } + + return c.template +} + +func (c *TransformConfig) WithTemplate(template []byte) *TransformConfig { + c.template = template + return c } func (c *TransformConfig) ParseIdentityRelation(userID, identity string) (*dsc.Relation, error) { diff --git a/common/convert/convert.go b/common/convert/convert.go index ea08673..8ede330 100644 --- a/common/convert/convert.go +++ b/common/convert/convert.go @@ -1,6 +1,7 @@ package convert import ( + "encoding/base64" "encoding/json" "github.com/aserto-dev/ds-load/sdk/common/msg" @@ -91,7 +92,7 @@ func (c *Converter) SCIMUserToObject(user *model.User) (*dsc.Object, error) { return nil, err } - userID := lo.Ternary(user.ID != "", user.ID, user.UserName) + userID := lo.Ternary(user.ID != "", user.ID, base64.StdEncoding.EncodeToString([]byte(user.UserName))) displayName := lo.Ternary(user.DisplayName != "", user.DisplayName, userID) object := &dsc.Object{ @@ -120,7 +121,7 @@ func (c *Converter) SCIMGroupToObject(group *model.Group) (*dsc.Object, error) { return nil, err } - objID := lo.Ternary(group.ID != "", group.ID, group.DisplayName) + objID := lo.Ternary(group.ID != "", group.ID, base64.StdEncoding.EncodeToString([]byte(group.DisplayName))) displayName := lo.Ternary(group.DisplayName != "", group.DisplayName, objID) object := &dsc.Object{ @@ -133,23 +134,20 @@ func (c *Converter) SCIMGroupToObject(group *model.Group) (*dsc.Object, error) { return object, nil } -func (c *Converter) TransformResource(resource map[string]any, objType string) (*msg.Transform, error) { - template, err := c.cfg.Template() - if err != nil { - return nil, err - } - +func (c *Converter) TransformResource(resource map[string]any, id, objType string) (*msg.Transform, error) { vars, err := c.cfg.ToTemplateVars() if err != nil { return nil, err } + transformer := transform.NewGoTemplateTransform(c.cfg.Template()) + transformInput := map[string]any{ "input": resource, "vars": vars, "objectType": objType, + "objectId": id, } - transformer := transform.NewGoTemplateTransform(template) return transformer.TransformObject(transformInput) } diff --git a/common/convert/converter_test.go b/common/convert/converter_test.go index 6d5aca0..b522cbd 100644 --- a/common/convert/converter_test.go +++ b/common/convert/converter_test.go @@ -1,6 +1,7 @@ package convert_test import ( + "encoding/base64" "testing" "github.com/aserto-dev/scim/common/config" @@ -42,11 +43,13 @@ func TestTransform(t *testing.T) { }, } + userID := base64.StdEncoding.EncodeToString([]byte("foobar")) + sCfg, err := convert.NewTransformConfig(&cfg) assert.NoError(err) cvt := convert.NewConverter(sCfg) - msg, err := cvt.TransformResource(ScimUser, "user") + msg, err := cvt.TransformResource(ScimUser, userID, "user") assert.NoError(err) assert.NotNil(msg) @@ -57,10 +60,10 @@ func TestTransform(t *testing.T) { assert.Equal("foo@bar.com", msg.GetRelations()[1].GetObjectId()) assert.Equal("identity", msg.GetRelations()[1].GetObjectType()) assert.Equal("identitifier", msg.GetRelations()[1].GetRelation()) - assert.Equal("foobar", msg.GetRelations()[1].GetSubjectId()) + assert.Equal(userID, msg.GetRelations()[1].GetSubjectId()) assert.Equal("user", msg.GetRelations()[1].GetSubjectType()) - assert.Equal("foobar", msg.GetRelations()[0].GetSubjectId()) + assert.Equal(userID, msg.GetRelations()[0].GetSubjectId()) assert.Equal("user", msg.GetRelations()[0].GetSubjectType()) assert.Equal("fooooo", msg.GetRelations()[2].GetObjectId()) @@ -80,11 +83,13 @@ func TestTransformUserIdentifier(t *testing.T) { }, } + userID := base64.StdEncoding.EncodeToString([]byte("foobar")) + sCfg, err := convert.NewTransformConfig(&cfg) assert.NoError(err) cvt := convert.NewConverter(sCfg) - msg, err := cvt.TransformResource(ScimUser, "user") + msg, err := cvt.TransformResource(ScimUser, userID, "user") assert.NoError(err) assert.NotNil(msg) @@ -93,10 +98,10 @@ func TestTransformUserIdentifier(t *testing.T) { assert.Equal("foo@bar.com", msg.GetRelations()[1].GetSubjectId()) assert.Equal("identity", msg.GetRelations()[1].GetSubjectType()) assert.Equal("identitifier", msg.GetRelations()[1].GetRelation()) - assert.Equal("foobar", msg.GetRelations()[1].GetObjectId()) + assert.Equal(userID, msg.GetRelations()[1].GetObjectId()) assert.Equal("user", msg.GetRelations()[1].GetObjectType()) - assert.Equal("foobar", msg.GetRelations()[0].GetObjectId()) + assert.Equal(userID, msg.GetRelations()[0].GetObjectId()) assert.Equal("user", msg.GetRelations()[0].GetObjectType()) assert.Equal("fooooo", msg.GetRelations()[2].GetSubjectId()) diff --git a/common/handlers/groups/create.go b/common/handlers/groups/create.go index 11c04a0..da2dc4a 100644 --- a/common/handlers/groups/create.go +++ b/common/handlers/groups/create.go @@ -45,7 +45,7 @@ func (g GroupResourceHandler) Create(ctx context.Context, attributes scim.Resour return scim.Resource{}, err } - transformResult, err := converter.TransformResource(attributes, "group") + transformResult, err := converter.TransformResource(attributes, sourceGroupResp.GetResult().GetId(), "group") if err != nil { logger.Err(err).Msg("failed to transform group") return scim.Resource{}, serrors.ScimErrorInvalidSyntax diff --git a/common/handlers/groups/patch.go b/common/handlers/groups/patch.go index 2916ff1..23f6f2d 100644 --- a/common/handlers/groups/patch.go +++ b/common/handlers/groups/patch.go @@ -84,7 +84,7 @@ func (g GroupResourceHandler) updateGroup( converter *convert.Converter, logger zerolog.Logger, ) (scim.Resource, error) { - transformResult, err := converter.TransformResource(attr, "group") + transformResult, err := converter.TransformResource(attr, groupObj.GetId(), "group") if err != nil { logger.Err(err).Msg("failed to convert group to object") return scim.Resource{}, serrors.ScimErrorInvalidSyntax diff --git a/common/handlers/users/create.go b/common/handlers/users/create.go index ed7b672..a12206b 100644 --- a/common/handlers/users/create.go +++ b/common/handlers/users/create.go @@ -85,7 +85,7 @@ func (u UsersResourceHandler) processUserResponse( return scim.Resource{}, err } - transformResult, err := converter.TransformResource(userMap, "user") + transformResult, err := converter.TransformResource(userMap, sourceUserResp.GetResult().GetId(), "user") if err != nil { logger.Err(err).Msg("failed to convert user to object") return scim.Resource{}, serrors.ScimErrorInvalidSyntax diff --git a/common/handlers/users/patch.go b/common/handlers/users/patch.go index ae0f9d8..db5d237 100644 --- a/common/handlers/users/patch.go +++ b/common/handlers/users/patch.go @@ -82,7 +82,7 @@ func (u UsersResourceHandler) updateUser( converter *convert.Converter, logger zerolog.Logger, ) (scim.Resource, error) { - transformResult, err := converter.TransformResource(attr, "user") + transformResult, err := converter.TransformResource(attr, userObj.GetId(), "user") if err != nil { logger.Err(err).Msg("failed to convert user to object") return scim.Resource{}, serrors.ScimErrorInvalidSyntax diff --git a/docs/entra-id.md b/docs/entra-id.md new file mode 100644 index 0000000..34f9157 --- /dev/null +++ b/docs/entra-id.md @@ -0,0 +1,31 @@ +# Sync users from Entra ID (AzureAD) + +## Create the SCIM application +To setup SCIM provisioning from Entra ID to Aserto, you need to create a new application in Entra ID. Please follow instructions on how to setup a new application: https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#getting-started + +When creating the application, set Tenant URL to https://{scim-endpoint}/?aadOptscim062020. The `aadOptscim062020` feature flag is required for SCIM 2.0 compliance (see https://learn.microsoft.com/en-us/entra/identity/app-provisioning/application-provisioning-config-problem-scim-compatibility#flags-to-alter-the-scim-behavior) + +For the secret token, enter the value configured in the `auth.bearer` config section. +![Provisioning credentials](./img/credentials.png) + +## Provisioning users and groups +Once the application was created, users and groups can be assigned to this application. Once a user/group was assigned, it becomes available for provisioning. +To test the provisioning works, go to your SCIM app => Manage => Provisioning => Provision on demand, search for your user/group and click `Provision` +Please note that automatic provisioning might take some time to trigger, see https://learn.microsoft.com/en-us/entra/identity/app-provisioning/application-provisioning-when-will-provisioning-finish-specific-user#how-long-will-it-take-to-provision-users + +## Provisioning roles +Only application specific roles can be provisioned. For this, the provisioning scope needs to be set to `Sync only assigned users and groups` +![Sync only assigned users and groups](./img/image.png) + +By default, roles are not mapped to any SCIM property. To add the mapping: +1. open your SCIM app, go to Manage => Provisioning => Mappings => open Provision Microsoft Entra ID Users => on the bottom, toggle Show advanced options => Edit attribute list for customappsso +2. on the bottom, add a new attribute called `roles`, type `String` and make sure `Multi-Value` is checked +3. back on the Attribute Mapping page, Add New Mapping: + - Mapping type: `Expression` + - Expression: `AssertiveAppRoleAssignmentsComplex([appRoleAssignments])` + - Target attribute: select the new created attribute `roles` + ![attribute mapping](./img/role-mapping.png) + - click OK +4. Save attribute mappings + +For more info on mappings, see https://learn.microsoft.com/en-us/entra/identity/app-provisioning/customize-application-attributes#provisioning-a-role-to-a-scim-app diff --git a/docs/img/credentials.png b/docs/img/credentials.png new file mode 100644 index 0000000..4e10cd4 Binary files /dev/null and b/docs/img/credentials.png differ diff --git a/docs/img/image.png b/docs/img/image.png new file mode 100644 index 0000000..26c09d0 Binary files /dev/null and b/docs/img/image.png differ diff --git a/docs/img/role-mapping.png b/docs/img/role-mapping.png new file mode 100644 index 0000000..16f18a3 Binary files /dev/null and b/docs/img/role-mapping.png differ diff --git a/docs/okta.md b/docs/okta.md new file mode 100644 index 0000000..6075d23 --- /dev/null +++ b/docs/okta.md @@ -0,0 +1,34 @@ +# Sync users from Okta + +## Setup Okta SCIM application + +1. Login to your okta admin console, go to Applications => Browse App Catalog, search for `SCIM 2.0 Test App (Basic Auth)` => Add Integration +2. On the General Settings tab, set application label and click Next +3. On the Sign-On Options you can leave all default values, scroll down and click Done +4. On the application page, go to the Provisioning tab => Configure API Integration => Enable API Integration + - Set the SCIM 2.0 Base Url to https://{scim-endpoint}/ + - Set Username to your configured username + - Set Password to your configured password + - Test API Credentials + - Save +5. Back on the Provisioning tab, on the To App Settings, click Edit, enable Create Users, Update User Attributes and Deactivate Users and Save + +## Provision users + +For provisioning users, a user needs to be assigned to the SCIM application. +1. Go to the Assignments tab +2. Click on Assign => Assign to People => Assign wanted users and click Done. +Your user should show up in the Directory +Any updates to a property that is mapped to a SCIM attribute, should trigger a user update in Aserto. + +## Provision groups + +For provisioning groups, a group needs to be assigned to the SCIM application. +1. Go to the Assignments tab +2. Click on Assign => Assign to People => Assign your group and click Done. +3. Go to Push Groups tab => Push Groups => Find groups by name => search for your group and click Save + +Groups and group membership should be provisioned now. + +## Troubleshooting +Please note that any errors on provisioning groups will pause the group provisioning. If a group was provisioned, Okta does keep a state for that provisioned group, so removing it from Aserto before attempting to unlink it from the Okta app can cause issues. If this happens, the group needs to be unlinked and reassigned to the app. diff --git a/pkg/app/run.go b/pkg/app/run.go index 18b8c39..bf2e006 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "fmt" "net/http" + "os" "strings" "github.com/aserto-dev/go-aserto/ds/v3" @@ -160,6 +161,15 @@ func (s *SCIMServer) resourceTypes() ([]scim.ResourceType, error) { return nil, err } + if s.cfg.TemplateFile != "" { + templateContent, err := os.ReadFile(s.cfg.TemplateFile) + if err != nil { + return nil, err + } + + transformCfg = transformCfg.WithTemplate(templateContent) + } + userHandler, err := s.userHandler(transformCfg) if err != nil { return nil, err diff --git a/pkg/config/config.go b/pkg/config/config.go index 9ac2506..e8ec95a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -39,7 +39,8 @@ type Config struct { IdleTimeout time.Duration `json:"idle_timeout"` } `json:"server"` - SCIM config.Config `json:"scim"` + SCIM config.Config `json:"scim"` + TemplateFile string `json:"template_file"` } type AuthConfig struct { diff --git a/pkg/test/assets/assets.go b/pkg/test/assets/assets.go index 44e025d..eab914a 100644 --- a/pkg/test/assets/assets.go +++ b/pkg/test/assets/assets.go @@ -20,6 +20,9 @@ var patch []byte //go:embed data/manifest.yaml var manifest []byte +//go:embed data/group.json +var group []byte + func TopazConfigReader() *bytes.Reader { return bytes.NewReader(topazConfig) } @@ -32,10 +35,14 @@ func Morty() []byte { return mortyJson } -func Patch() []byte { +func PatchOp() []byte { return patch } +func Group() []byte { + return group +} + func Manifest() []byte { return manifest } diff --git a/pkg/test/assets/data/morty.json b/pkg/test/assets/data/morty.json index 2a52677..e9ef510 100644 --- a/pkg/test/assets/data/morty.json +++ b/pkg/test/assets/data/morty.json @@ -1,6 +1,6 @@ { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName": "morty@the-citadel.com", + "userName": "mortysmith", "name": { "givenName": "Morty", "familyName": "Smith" diff --git a/pkg/test/assets/data/rick.json b/pkg/test/assets/data/rick.json index 833b3d0..8298815 100644 --- a/pkg/test/assets/data/rick.json +++ b/pkg/test/assets/data/rick.json @@ -1,6 +1,6 @@ { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName": "rick@the-citadel.com", + "userName": "ricksanchez", "name": { "givenName": "Rick", "familyName": "Sanchez" diff --git a/pkg/test/scim_test.go b/pkg/test/scim_test.go index ed52f4b..c8385b3 100644 --- a/pkg/test/scim_test.go +++ b/pkg/test/scim_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/require" ) +const scimMediaType = "application/scim+json" + func TestScim(t *testing.T) { // Setup test containers tst := common_test.TestSetup(t) @@ -21,32 +23,51 @@ func TestScim(t *testing.T) { rick := map[string]any{} err := json.Unmarshal(assets_test.Rick(), &rick) require.NoError(t, err) + e.GET("/Users").WithBasicAuth("scim", "scim").Expect().Status(200) - e.POST("/Users").WithBasicAuth("scim", "scim").WithJSON(rick).Expect().Status(201).Body().Contains("Rick Sanchez") + + rickID := e.POST("/Users").WithBasicAuth("scim", "scim").WithJSON(rick).Expect(). + Status(201).JSON(httpexpect.ContentOpts{MediaType: scimMediaType}).Object().Value("id").String() + + rickID.NotEmpty() e.GET("/Users").WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Rick Sanchez") - e.GET("/Users/rick@the-citadel.com").WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Rick Sanchez") + e.GET("/Users/"+rickID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Rick Sanchez") // Create user for Morty morty := map[string]any{} err = json.Unmarshal(assets_test.Morty(), &morty) require.NoError(t, err) - e.POST("/Users").WithBasicAuth("scim", "scim").WithJSON(morty).Expect().Status(201).Body().Contains("Morty Smith") - e.GET("/Users/morty@the-citadel.com").WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Morty Smith") - require.True(t, tst.UserHasIdentity(t.Context(), "morty@the-citadel.com", "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs")) - require.True(t, tst.UserHasManager(t.Context(), "morty@the-citadel.com", "rick@the-citadel.com")) - require.Equal(t, true, tst.ReadUserProperty(t.Context(), "morty@the-citadel.com", "enabled")) + mortyID := e.POST("/Users").WithBasicAuth("scim", "scim").WithJSON(morty).Expect(). + Status(201).JSON(httpexpect.ContentOpts{MediaType: scimMediaType}).Object().Value("id").String() + + mortyID.NotEmpty() + e.GET("/Users/"+mortyID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Morty Smith") + + require.True(t, tst.UserHasIdentity(t.Context(), mortyID.Raw(), "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs")) + require.True(t, tst.UserHasManager(t.Context(), mortyID.Raw(), "rick@the-citadel.com")) + require.Equal(t, true, tst.ReadUserProperty(t.Context(), mortyID.Raw(), "enabled")) // Update Morty patchMorty := map[string]any{} - err = json.Unmarshal(assets_test.Patch(), &patchMorty) + err = json.Unmarshal(assets_test.PatchOp(), &patchMorty) require.NoError(t, err) - e.PATCH("/Users/morty@the-citadel.com").WithBasicAuth("scim", "scim").WithJSON(patchMorty).Expect().Status(200).Body().Contains("Morty Smith") - require.Equal(t, false, tst.ReadUserProperty(t.Context(), "morty@the-citadel.com", "enabled")) + e.PATCH("/Users/"+mortyID.Raw()).WithBasicAuth("scim", "scim").WithJSON(patchMorty).Expect().Status(200).Body().Contains("Morty Smith") + require.Equal(t, false, tst.ReadUserProperty(t.Context(), mortyID.Raw(), "enabled")) // Delete Morty - e.DELETE("/Users/morty@the-citadel.com").WithBasicAuth("scim", "scim").Expect().Status(204) - e.GET("/Users/morty@the-citadel.com").WithBasicAuth("scim", "scim").Expect().Status(404) + e.DELETE("/Users/"+mortyID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(204) + e.GET("/Users/"+mortyID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(404) + + group := map[string]any{} + err = json.Unmarshal(assets_test.Group(), &group) + require.NoError(t, err) + + groupID := e.POST("/Groups").WithBasicAuth("scim", "scim").WithJSON(group).Expect(). + Status(201).JSON(httpexpect.ContentOpts{MediaType: scimMediaType}).Object().Value("id").String() + + groupID.NotEmpty() + e.GET("/Groups/"+groupID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Evil Genius") t.Logf("topaz log:\n%s", tst.ContainerLogs(t.Context(), t)) }