├── .gitignore ├── Dockerfile ├── helpers.go ├── CHANGELOG.md ├── LICENSE.md ├── go.mod ├── deputize.go ├── mod_pagerduty.go ├── mod_slack.go ├── mod_gitlab.go ├── cmd └── pdrotator │ ├── README.md │ └── main.go ├── mod_ldap.go ├── config.go ├── go.sum └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.json 3 | *.zip 4 | *.pem 5 | deputize 6 | cmd/pdrotator/pdrotator.zip 7 | cmd/pdrotator/pdrotator -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/lambda/go:1 2 | 3 | # Copy function handler executable 4 | COPY deputize ${LAMBDA_TASK_ROOT} 5 | 6 | # Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile) 7 | CMD [ "deputize" ] -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | // helpers.go - small useful functions for deputize. 2 | // Copyright 2024 F5 Inc. 3 | // Licensed under the BSD 3-clause license; see LICENSE.md for more information. 4 | 5 | package main 6 | 7 | func contains(str []string, search string) bool { 8 | for _, a := range str { 9 | if a == search { 10 | return true 11 | } 12 | } 13 | return false 14 | } 15 | 16 | func removeDuplicates(elements []string) []string { 17 | // Use map to record duplicates as we find them. 18 | encountered := map[string]bool{} 19 | result := []string{} 20 | 21 | for v := range elements { 22 | if encountered[elements[v]] { 23 | // Do not add duplicate. 24 | } else { 25 | // Record this element as an encountered element. 26 | encountered[elements[v]] = true 27 | // Append to result slice. 28 | result = append(result, elements[v]) 29 | } 30 | } 31 | // Return the new slice. 32 | return result 33 | } 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | Deputize has had a few different iterations - we started maintaining a changelog at version 4. 3 | 4 | ## 4.1.3 5 | * Deps: Bumped all first-line deps to latest, swapped `github.com/xanzy/go-gitlab` for the official `gitlab.com/gitlab-org/api/client-go` 6 | * LDAP: Fixed a bug where if there were 0 members in the LDAP group the code to update the group never ran. 7 | 8 | ## 4.1.2 9 | * Deps: Bumped all first-line deps to latest; 10 | 11 | ## 4.1.1 12 | * Deps: Bumped all first-line deps to latest; bumped indirect protobuf to address CVE-2024-24786. 13 | * Slack: Fixed the bug where Deputize would post the on call message == to the amount of channels in the `Channels` config option. 14 | * Pagerduty: Used the query option in the PD API so that we don't have to pull down the full list of schedules, saves me from dealing with pagination. 15 | 16 | ## 4.1.0 17 | * Support for scoped OAuth tokens using AWS secret manager, take a look at the PDRotator [README](cmd/pdrotator/README.md). 18 | 19 | ## 4.0.0 20 | * Refactored the code to work in AWS Lambda 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2017-2024 F5, Inc. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/threatstack/deputize 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/PagerDuty/go-pagerduty v1.8.0 7 | github.com/aws/aws-lambda-go v1.48.0 8 | github.com/aws/aws-sdk-go-v2 v1.36.3 9 | github.com/aws/aws-sdk-go-v2/config v1.29.14 10 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.4 11 | github.com/slack-go/slack v0.16.0 12 | gitlab.com/gitlab-org/api/client-go v0.128.0 13 | gopkg.in/ldap.v2 v2.5.1 14 | ) 15 | 16 | require ( 17 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect 18 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 19 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 20 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 21 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect 27 | github.com/aws/smithy-go v1.22.3 // indirect 28 | github.com/google/go-querystring v1.1.0 // indirect 29 | github.com/gorilla/websocket v1.5.3 // indirect 30 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 31 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 32 | golang.org/x/oauth2 v0.30.0 // indirect 33 | golang.org/x/sys v0.32.0 // indirect 34 | golang.org/x/time v0.11.0 // indirect 35 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /deputize.go: -------------------------------------------------------------------------------- 1 | // deputize.go - main function 2 | // Copyright 2024 F5 Inc. 3 | // Licensed under the BSD 3-clause license; see LICENSE.md for more information. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "log" 10 | "strings" 11 | 12 | "github.com/aws/aws-lambda-go/lambda" 13 | ) 14 | 15 | func main() { 16 | lambda.Start(runLambda) 17 | } 18 | 19 | func runLambda(ctx context.Context, cfg *deputizeConfig) (string, error) { 20 | 21 | log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) 22 | 23 | err := validateConfig(cfg) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | sec, err := buildSecrets(cfg) 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | oncallEmails, err := getPagerdutyInfo(ctx, cfg.Source.PagerDuty.WithOAuth, sec.PDAuthToken, cfg.Source.PagerDuty.OnCallSchedules) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | log.Printf("Current On-Call Users: %s\n", strings.Join(oncallEmails, ", ")) 39 | 40 | if cfg.Sinks.LDAP.Enabled { 41 | err := updateLDAP(cfg.Sinks.LDAP, oncallEmails, sec.LDAPModUserPassword) 42 | if err != nil { 43 | return "", err 44 | } 45 | } 46 | 47 | if cfg.Sinks.Gitlab.Enabled { 48 | gitlabApprovers, err := getPagerdutyInfo(ctx, cfg.Source.PagerDuty.WithOAuth, sec.PDAuthToken, []string{cfg.Sinks.Gitlab.ApproverSchedule}) 49 | if err != nil { 50 | return "", err 51 | } 52 | log.Printf("Gitlab Approvers: %s\n", strings.Join(gitlabApprovers, ", ")) 53 | err = updateGitlab(cfg.Sinks.Gitlab, gitlabApprovers, sec.GitlabAuthToken) 54 | if err != nil { 55 | return "", err 56 | } 57 | } 58 | 59 | if cfg.Sinks.Slack.Enabled { 60 | err := updateSlack(cfg.Sinks.Slack, oncallEmails, sec.SlackAuthToken) 61 | if err != nil { 62 | return "", err 63 | } 64 | } 65 | 66 | return strings.Join(oncallEmails, ", "), nil 67 | } 68 | -------------------------------------------------------------------------------- /mod_pagerduty.go: -------------------------------------------------------------------------------- 1 | // mod_pagerduty.go - PagerDuty source code 2 | // Copyright 2024 F5 Inc. 3 | // Licensed under the BSD 3-clause license; see LICENSE.md for more information. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "slices" 11 | "time" 12 | 13 | "github.com/PagerDuty/go-pagerduty" 14 | ) 15 | 16 | func getPagerdutyInfo(ctx context.Context, withOAuth bool, authToken string, schedules []string) ([]string, error) { 17 | var newOnCallEmails []string 18 | var pdClient *pagerduty.Client 19 | 20 | if withOAuth { 21 | pdClient = pagerduty.NewOAuthClient(authToken) 22 | } else { 23 | pdClient = pagerduty.NewClient(authToken) 24 | } 25 | 26 | var allRawSchedulesPD [][]pagerduty.Schedule 27 | 28 | for _, sch := range schedules { 29 | lsSchedulesOpts := pagerduty.ListSchedulesOptions{ 30 | Total: true, 31 | Query: sch, 32 | } 33 | reqSchedule, err := pdClient.ListSchedulesWithContext(ctx, lsSchedulesOpts) 34 | if err != nil { 35 | return []string{}, err 36 | } 37 | 38 | allRawSchedulesPD = append(allRawSchedulesPD, reqSchedule.Schedules) 39 | } 40 | 41 | allSchedulesPD := slices.Concat(allRawSchedulesPD...) 42 | 43 | for _, p := range allSchedulesPD { 44 | if contains(schedules, p.Name) { 45 | var onCallOpts pagerduty.ListOnCallUsersOptions 46 | var currentTime = time.Now() 47 | onCallOpts.Since = currentTime.Format("2006-01-02T15:04:05Z07:00") 48 | hours, _ := time.ParseDuration("1s") 49 | onCallOpts.Until = currentTime.Add(hours).Format("2006-01-02T15:04:05Z07:00") 50 | if oncall, err := pdClient.ListOnCallUsersWithContext(ctx, p.APIObject.ID, onCallOpts); err != nil { 51 | return []string{}, fmt.Errorf("unable to ListOnCallUsers: %s", err) 52 | } else { 53 | for _, person := range oncall { 54 | newOnCallEmails = append(newOnCallEmails, person.Email) 55 | } 56 | } 57 | } 58 | } 59 | 60 | return newOnCallEmails, nil 61 | } 62 | -------------------------------------------------------------------------------- /mod_slack.go: -------------------------------------------------------------------------------- 1 | // mod_slack.go - Slack sink code 2 | // Copyright 2024 F5 Inc. 3 | // Licensed under the BSD 3-clause license; see LICENSE.md for more information. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "reflect" 11 | "regexp" 12 | "strings" 13 | 14 | "github.com/slack-go/slack" 15 | ) 16 | 17 | func updateSlack(cfg deputizeSlackConfig, pdOnCallEmails []string, slackAuthToken string) error { 18 | log.Printf("Beginning Slack Update.\n") 19 | slackAPI := slack.New(slackAuthToken) 20 | var oncallUsers []*slack.User 21 | for _, email := range pdOnCallEmails { 22 | user, err := slackAPI.GetUserByEmail(email) 23 | if err != nil { 24 | return fmt.Errorf("unable to getUserByEmail: %s", err) 25 | } 26 | oncallUsers = append(oncallUsers, user) 27 | } 28 | 29 | var slackUIDs []string 30 | for _, user := range oncallUsers { 31 | slackUIDs = append(slackUIDs, user.ID) 32 | } 33 | log.Printf("Current Oncall UIDs: %+v\n", slackUIDs) 34 | 35 | for _, channel := range cfg.Channels { 36 | c, err := slackAPI.GetConversationInfo(&slack.GetConversationInfoInput{ChannelID: channel}) 37 | if err != nil { 38 | log.Printf("Warning: Got %s back from Slack API\n", err) 39 | } 40 | 41 | // Does the channel topic have a | in it? that's our delimiter, attempt to split. 42 | channelTopic := strings.Split(c.Topic.Value, "|") 43 | 44 | // Pull out current On Call folks 45 | r, _ := regexp.Compile("U[A-Z0-9]+") 46 | topicUIDs := r.FindAllString(channelTopic[0], -1) 47 | 48 | log.Printf("Oncall UIDs from channel %s: %+v\n", channel, topicUIDs) 49 | 50 | // See if they match w/ current on call, if not then update topic 51 | if !reflect.DeepEqual(slackUIDs, topicUIDs) { 52 | log.Printf("Difference between Current and Topic UIDs, updating topic.\n") 53 | topic := "On-Call: " 54 | // slackify the UIDs 55 | for i, uid := range slackUIDs { 56 | topic = topic + "<@" + uid + ">" 57 | if i+1 < len(slackUIDs) { 58 | topic = topic + ", " 59 | } 60 | 61 | } 62 | if len(channelTopic) > 1 { 63 | _, err := slackAPI.SetTopicOfConversation(channel, fmt.Sprintf("%s |%s", topic, strings.Join(channelTopic[1:], "|"))) 64 | if err != nil { 65 | log.Printf("Warning: Got %s back from Slack API\n", err) 66 | } 67 | } else { 68 | _, err := slackAPI.SetTopicOfConversation(channel, fmt.Sprintf("%s |", topic)) 69 | if err != nil { 70 | log.Printf("Warning: Got %s back from Slack API\n", err) 71 | } 72 | } 73 | if cfg.PostMessage { 74 | slackParams := slack.PostMessageParameters{} 75 | slackParams.AsUser = true 76 | _, _, err := slackAPI.PostMessage(channel, slack.MsgOptionPostMessageParameters(slackParams), slack.MsgOptionText(topic, false)) 77 | if err != nil { 78 | log.Printf("Warning: Got %s back from Slack API\n", err) 79 | } 80 | } 81 | } 82 | } 83 | log.Printf("Slack update complete.\n") 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /mod_gitlab.go: -------------------------------------------------------------------------------- 1 | // mod_gitlab.go - Gitlab sink code 2 | // Copyright 2024 F5 Inc. 3 | // Licensed under the BSD 3-clause license; see LICENSE.md for more information. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | 11 | "gitlab.com/gitlab-org/api/client-go" 12 | ) 13 | 14 | func updateGitlab(cfg deputizeGitlabConfig, pdOnCallEmails []string, gitlabAuthToken string) error { 15 | log.Printf("Beginning Gitlab Update.\n") 16 | var newOnCallApproverGitlabUserIDs []int 17 | 18 | client, err := gitlab.NewClient(gitlabAuthToken, gitlab.WithBaseURL(cfg.Server+"api/v4")) 19 | if err != nil { 20 | return fmt.Errorf("could not initialize client: %s", err) 21 | } 22 | // Lets get user ids for On Call people 23 | for _, email := range pdOnCallEmails { 24 | userOptions := &gitlab.ListUsersOptions{Search: gitlab.Ptr(email)} 25 | users, _, err := client.Users.ListUsers(userOptions) 26 | if err != nil { 27 | log.Printf("Warning: Got %s back from Gitlab API\n", err) 28 | } 29 | if len(users) == 1 { 30 | // We expect only one user returned based on an email. We error out otherwise 31 | log.Printf("User found! username is %s for email %s\n", users[0].Username, email) 32 | newOnCallApproverGitlabUserIDs = append(newOnCallApproverGitlabUserIDs, users[0].ID) 33 | } else if len(users) == 0 { 34 | log.Printf("No user found for email %s\n", email) 35 | } else { 36 | // Lets output some helpful information if we don't get 1 user 37 | for _, user := range users { 38 | log.Printf("Found the following users associated with \"%s\": %s\n", email, user.Username) 39 | } 40 | return fmt.Errorf("found more than one user with an email of %s: %d users found", email, len(users)) 41 | } 42 | } 43 | 44 | if len(newOnCallApproverGitlabUserIDs) == 0 { 45 | // If no users are in the new approver list, leave the group alone 46 | log.Printf("No new Approvers, not updating Gitlab group: %s", cfg.Group) 47 | } else { 48 | // Add OnCall approvers to approver group 49 | log.Printf("Updating Gitlab group: %s", cfg.Group) 50 | 51 | // Remove existing members of the group, if they exist 52 | log.Printf("Removing old approvers from Gitlab group: %s", cfg.Group) 53 | 54 | // Get the existing members of the group 55 | approverGroupMembers, _, err := client.Groups.ListGroupMembers(cfg.Group, &gitlab.ListGroupMembersOptions{}) 56 | if err != nil { 57 | return fmt.Errorf("gitlab could not get group members: %s", err.Error()) 58 | } 59 | if len(approverGroupMembers) > 0 { 60 | // Remove existing members 61 | for _, member := range approverGroupMembers { 62 | // Don't remove group owner/maintainers 63 | if member.AccessLevel < 40 { 64 | log.Printf("Removing user %s", member.Username) 65 | _, err := client.GroupMembers.RemoveGroupMember(cfg.Group, member.ID, &gitlab.RemoveGroupMemberOptions{}) 66 | if err != nil { 67 | return fmt.Errorf("gitlab could not remove group member: %s", err) 68 | } 69 | } 70 | } 71 | } 72 | 73 | // Add new members to the group 74 | log.Printf("Adding new approvers to Gitlab group: %s", cfg.Group) 75 | for _, newApproverUserID := range newOnCallApproverGitlabUserIDs { 76 | log.Printf("Adding user id %d", newApproverUserID) 77 | addGroupMemberOpts := &gitlab.AddGroupMemberOptions{ 78 | UserID: gitlab.Ptr(newApproverUserID), 79 | AccessLevel: gitlab.Ptr(gitlab.DeveloperPermissions), 80 | } 81 | _, _, err := client.GroupMembers.AddGroupMember(cfg.Group, addGroupMemberOpts) 82 | if err != nil { 83 | return fmt.Errorf("gitlab could not add group member: %s", err) 84 | } 85 | } 86 | } 87 | log.Printf("Gitlab Update Complete.\n") 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /cmd/pdrotator/README.md: -------------------------------------------------------------------------------- 1 | # pdrotator 2 | 3 | PDRotator is a tool that can work with AWS Secret Manager to obtain a scoped OAuth token for PagerDuty. This allows you to limit the API access for Deputize. Now instead of requiring read access to all PD API elements, you can scope it to just `schedules.read`. Your security team will thank you. 4 | 5 | ## Prerequisites 6 | 7 | You'll need to be, or have a PagerDuty account admin or owner around to complete this step. 8 | 9 | In PagerDuty, register a new OAuth application - https://yourinstance.pagerduty.com/developer/applications. For authorization, specify **Scoped OAuth**. Next to schedules, select read. 10 | 11 | Save the Client ID and Client Secret. Also note the region of your PD instance (US or EU) for later. 12 | 13 | ## Installation 14 | ### Configuration 15 | Create an AWS Secret Manager Secret that contains configuration for your PD instance(s). 16 | 17 | Name the secret `deputize/source/pagerduty` and load up the plaintext editor. The key for the JSON object should match your PD instance name. You can configure multiple PD instances, if you have them. 18 | 19 | ``` 20 | { 21 | "yourinstance": { 22 | "region": "us", 23 | "id": "PD_CLIENT_ID", 24 | "secret" :"PD_CLIENT_KEY_STARTS_WITH_PDEOC", 25 | "scopes": ["schedules.read"] 26 | } 27 | } 28 | ``` 29 | 30 | ### Create the Target Secret 31 | Create a new secret: `deputize/source/pagerduty/yourinstance` with a value of `foo`. Don't enable rotation yet. Note the ARN of the secret, you'll need it for the IAM policy. 32 | 33 | ### IAM 34 | Create a new IAM role for the lambda that will manage the PD OAuth token. PDRotator will need to log to CloudWatch, read it's config, read the target secret's values, and also write to the target secret. The IAM policy should look like the following: 35 | 36 | ``` 37 | { 38 | "Version": "2012-10-17", 39 | "Statement": [ 40 | { 41 | "Effect": "Allow", 42 | "Action": [ 43 | "logs:CreateLogGroup", 44 | "logs:CreateLogStream", 45 | "logs:PutLogEvents" 46 | ], 47 | "Resource": "arn:aws:logs:*:*:*" 48 | }, 49 | { 50 | "Sid": "ReadConfigAndCurrentSecret", 51 | "Effect": "Allow", 52 | "Action": [ 53 | "secretsmanager:DescribeSecret", 54 | "secretsmanager:GetSecretValue" 55 | ], 56 | "Resource": [ 57 | "arn:aws:secretsmanager:REGION:AWS_ACCOUNT_NUM:secret:deputize/source/pagerduty-ID", 58 | "arn:aws:secretsmanager:REGION:AWS_ACCOUNT_NUM:secret:deputize/source/pagerduty/yourinstance-ID" 59 | ] 60 | }, 61 | { 62 | "Sid": "UpdateSecret", 63 | "Effect": "Allow", 64 | "Action": [ 65 | "secretsmanager:PutSecretValue", 66 | "secretsmanager:UpdateSecretVersionStage" 67 | ], 68 | "Resource": [ 69 | "arn:aws:secretsmanager:REGION:AWS_ACCOUNT_NUM:secret:deputize/source/pagerduty/yourinstance-ID" 70 | ] 71 | } 72 | ] 73 | } 74 | ``` 75 | 76 | ### Upload the Lambda 77 | * Build the function `GOOS=linux GOARCH=amd64 go build && zip pdrotator.zip pdrotator` and then upload it to AWS. 78 | * Configure the handler to be `pdrotator` 79 | * 128MB of memory is enough for this function 80 | * Add a resource policy to allow `lambda:InvokeFunction` from `secretsmanager.amazonaws.com` 81 | 82 | ### Configure rotation 83 | 1. Go to your target secret - `deputize/source/pagerduty/yourinstance`. 84 | 2. Enable automatic rotation - every 23 hours should be sufficient. 85 | 3. Point it at your pdrotator function. 86 | 87 | Attempt rotation. You should get a secret returned that starts with `pdus+` or `pdeu+` -- success! 88 | 89 | ### Deputize Setup 90 | 1. Your Deputize IAM role will need to be updated to allow a read from the new target secret. 91 | 3. In your PagerDuty source configuration, make sure you have `WithOAuth` set to true, and `OAuthSecretPath` set to the target secret name. -------------------------------------------------------------------------------- /mod_ldap.go: -------------------------------------------------------------------------------- 1 | // mod_ldap.go - LDAP sink code 2 | // Copyright 2024 F5 Inc. 3 | // Licensed under the BSD 3-clause license; see LICENSE.md for more information. 4 | 5 | package main 6 | 7 | import ( 8 | "crypto/tls" 9 | "crypto/x509" 10 | "fmt" 11 | "os" 12 | "reflect" 13 | "strings" 14 | 15 | "log" 16 | 17 | "gopkg.in/ldap.v2" 18 | ) 19 | 20 | func updateLDAP(cfg deputizeLDAPConfig, pdOnCallEmails []string, ldappw string) error { 21 | log.Printf("Beginning LDAP Update\n") 22 | client, err := setupLDAPConnection(cfg.Server, cfg.Port, cfg.RootCAFile, cfg.InsecureSkipVerify) 23 | if err != nil { 24 | return fmt.Errorf("unable to set up ldap client: %s", err) 25 | } 26 | 27 | var resolvedLDAPOnCallUIDs []string 28 | 29 | // get current members of the oncall group (needed for removal later) 30 | currentLDAPOnCall, err := search(client, cfg.BaseDN, fmt.Sprintf("(%s)", cfg.OnCallGroup), []string{cfg.MemberAttribute}) 31 | if err != nil { 32 | return fmt.Errorf("unable to get current on call from LDAP: %s", err) 33 | } 34 | currentLDAPOnCallUIDs := currentLDAPOnCall.Entries[0].GetAttributeValues(cfg.MemberAttribute) 35 | // yeah, we *shouldnt* need to do this, but I want to make sure 36 | // both slices are sorted the same way so DeepEqual works 37 | currentLDAPOnCallUIDs = removeDuplicates(currentLDAPOnCallUIDs) 38 | log.Printf("Current LDAP OnCall UIDs: %s\n", strings.Join(currentLDAPOnCallUIDs, ",")) 39 | 40 | // Resolve the emails from PD to UIDs that we can use to determine if we need to update LDAP 41 | for _, email := range pdOnCallEmails { 42 | newOnCall, err := search(client, cfg.BaseDN, fmt.Sprintf("(%s=%s)", cfg.MailAttribute, email), []string{cfg.UserAttribute}) 43 | if err != nil { 44 | return fmt.Errorf("unable to resolve emails from PD into LDAP UIDs: %s", err) 45 | } 46 | resolvedLDAPOnCallUIDs = append(resolvedLDAPOnCallUIDs, newOnCall.Entries[0].GetAttributeValue("uid")) 47 | } 48 | resolvedLDAPOnCallUIDs = removeDuplicates(resolvedLDAPOnCallUIDs) 49 | log.Printf("Resolved New LDAP OnCall UIDs: %s\n", strings.Join(resolvedLDAPOnCallUIDs, ",")) 50 | 51 | // Get the DN for the oncall group 52 | onCallGroup, err := search(client, cfg.BaseDN, fmt.Sprintf("(%s)", cfg.OnCallGroup), []string{"cn"}) 53 | if err != nil { 54 | return fmt.Errorf("unable to get LDAP OnCall Group DN: %s", err) 55 | } 56 | onCallGroupDN := onCallGroup.Entries[0].DN 57 | log.Printf("On Call Group DN: %s\n", onCallGroupDN) 58 | 59 | // If they're not the same, then theres a difference and we need to update LDAP 60 | if !reflect.DeepEqual(currentLDAPOnCallUIDs, resolvedLDAPOnCallUIDs) { 61 | if err := client.Bind(cfg.ModUserDN, ldappw); err != nil { 62 | return fmt.Errorf("unable to bind to LDAP as %s", cfg.ModUserDN) 63 | } 64 | 65 | if len(currentLDAPOnCallUIDs) > 0 { 66 | delUsers := ldap.NewModifyRequest(onCallGroupDN) 67 | delUsers.Delete(cfg.MemberAttribute, currentLDAPOnCallUIDs) 68 | if err = client.Modify(delUsers); err != nil { 69 | return fmt.Errorf("unable to delete existing users from LDAP: %s", err) 70 | } 71 | } 72 | if len(resolvedLDAPOnCallUIDs) > 0 { 73 | addUsers := ldap.NewModifyRequest(onCallGroupDN) 74 | addUsers.Add(cfg.MemberAttribute, resolvedLDAPOnCallUIDs) 75 | if err = client.Modify(addUsers); err != nil { 76 | return fmt.Errorf("unable to add new users to LDAP: %s", err) 77 | } 78 | } 79 | } 80 | log.Printf("LDAP Update Complete.\n") 81 | return nil 82 | } 83 | 84 | func setupLDAPConnection(host string, port int, cafile string, insecureSkipVerify bool) (*ldap.Conn, error) { 85 | l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", host, port)) 86 | if err != nil { 87 | return nil, err 88 | } 89 | tlsConfig := &tls.Config{ 90 | InsecureSkipVerify: insecureSkipVerify, 91 | ServerName: host, 92 | } 93 | rootCerts := x509.NewCertPool() 94 | rootCAFile, err := os.ReadFile(cafile) 95 | if err != nil { 96 | return nil, fmt.Errorf("unable to read trusted CAs from %s: %s", cafile, err) 97 | } 98 | if !rootCerts.AppendCertsFromPEM(rootCAFile) { 99 | return nil, fmt.Errorf("unable to append to trust store: %s", err) 100 | } 101 | tlsConfig.RootCAs = rootCerts 102 | tlsErr := l.StartTLS(tlsConfig) 103 | if tlsErr != nil { 104 | return nil, fmt.Errorf("unable to start TLS connection: %s", err) 105 | } 106 | 107 | return l, nil 108 | } 109 | 110 | // This is only good for things you know will return only one result. Be warned. 111 | func search(l *ldap.Conn, basedn string, search string, attributes []string) (*ldap.SearchResult, error) { 112 | searchRequest := ldap.NewSearchRequest( 113 | basedn, 114 | ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, 115 | search, 116 | attributes, 117 | nil, 118 | ) 119 | sr, err := l.Search(searchRequest) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | if len(sr.Entries) != 1 { 125 | return nil, fmt.Errorf("user does not exist or too many entries returned") 126 | } 127 | 128 | return sr, nil 129 | } 130 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | // config.go - config functions 2 | // Copyright 2024 F5 Inc. 3 | // Licensed under the BSD 3-clause license; see LICENSE.md for more information. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "log" 12 | "os" 13 | 14 | "github.com/aws/aws-sdk-go-v2/aws" 15 | "github.com/aws/aws-sdk-go-v2/config" 16 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager" 17 | ) 18 | 19 | type deputizeConfig struct { 20 | SecretPath string 21 | SecretRegion string 22 | Source deputizeSourceConfig 23 | Sinks deputizeSinkConfig 24 | } 25 | 26 | type deputizeSourceConfig struct { 27 | PagerDuty deputizePDConfig 28 | } 29 | 30 | type deputizeSinkConfig struct { 31 | Gitlab deputizeGitlabConfig 32 | LDAP deputizeLDAPConfig 33 | Slack deputizeSlackConfig 34 | } 35 | 36 | type deputizeGitlabConfig struct { 37 | ApproverSchedule string 38 | Enabled bool 39 | Group string 40 | Server string 41 | } 42 | 43 | type deputizeLDAPConfig struct { 44 | Enabled bool 45 | BaseDN string 46 | RootCAFile string 47 | Server string 48 | Port int 49 | MailAttribute string 50 | MemberAttribute string 51 | ModUserDN string 52 | OnCallGroup string 53 | UserAttribute string 54 | InsecureSkipVerify bool 55 | } 56 | 57 | type deputizePDConfig struct { 58 | Enabled bool 59 | OnCallSchedules []string 60 | WithOAuth bool 61 | OAuthSecretPath string 62 | } 63 | 64 | type deputizeSlackConfig struct { 65 | Channels []string 66 | Enabled bool 67 | PostMessage bool 68 | } 69 | 70 | type deputizeSecrets struct { 71 | GitlabAuthToken string 72 | LDAPModUserPassword string 73 | PDAuthToken string 74 | SlackAuthToken string 75 | } 76 | 77 | func validateConfig(cfg *deputizeConfig) error { 78 | var configErrors []string 79 | 80 | if cfg.SecretPath == "" { 81 | configErrors = append(configErrors, "SecretPath not set") 82 | } 83 | if cfg.SecretRegion == "" { 84 | cfg.SecretRegion = os.Getenv("AWS_REGION") 85 | } 86 | 87 | // Sources 88 | if !cfg.Source.PagerDuty.Enabled { 89 | configErrors = append(configErrors, "Source: No source enabled") 90 | } 91 | 92 | if cfg.Source.PagerDuty.Enabled { 93 | if len(cfg.Source.PagerDuty.OnCallSchedules) == 0 { 94 | configErrors = append(configErrors, "Pagerduty Source: No On Call Groups Selected") 95 | } 96 | if cfg.Source.PagerDuty.WithOAuth { 97 | if cfg.Source.PagerDuty.OAuthSecretPath == "" { 98 | configErrors = append(configErrors, "Pagerduty Source: OAuth enabled, but OAuthSecretPath is not configured") 99 | } 100 | } 101 | } 102 | 103 | // Sinks 104 | if cfg.Sinks.Gitlab.Enabled { 105 | if cfg.Sinks.Gitlab.Server == "" { 106 | configErrors = append(configErrors, "Gitlab Sink: Server not configured") 107 | } 108 | if cfg.Sinks.Gitlab.Group == "" { 109 | configErrors = append(configErrors, "Gitlab Sink: Group not configured") 110 | } 111 | if cfg.Sinks.Gitlab.ApproverSchedule == "" { 112 | configErrors = append(configErrors, "Gitlab Sink: ApproverSchedule not configured") 113 | } 114 | } 115 | if cfg.Sinks.LDAP.Enabled { 116 | if cfg.Sinks.LDAP.BaseDN == "" { 117 | configErrors = append(configErrors, "LDAP Sink: BaseDN not configured") 118 | } 119 | if cfg.Sinks.LDAP.MailAttribute == "" { 120 | cfg.Sinks.LDAP.MailAttribute = "mail" 121 | } 122 | if cfg.Sinks.LDAP.MemberAttribute == "" { 123 | cfg.Sinks.LDAP.MemberAttribute = "memberOf" 124 | } 125 | if cfg.Sinks.LDAP.ModUserDN == "" { 126 | configErrors = append(configErrors, "LDAP Sink: ModUserDN not configured") 127 | } 128 | if cfg.Sinks.LDAP.OnCallGroup == "" { 129 | configErrors = append(configErrors, "LDAP Sink: OnCallGroup not configured") 130 | } 131 | if cfg.Sinks.LDAP.Port < 1 || cfg.Sinks.LDAP.Port > 65535 { 132 | configErrors = append(configErrors, "LDAP Sink: Port is invalid") 133 | } 134 | if cfg.Sinks.LDAP.RootCAFile == "" { 135 | cfg.Sinks.LDAP.RootCAFile = "truststore.pem" 136 | } 137 | if cfg.Sinks.LDAP.Server == "" { 138 | configErrors = append(configErrors, "LDAP Sink: Server not configured") 139 | } 140 | if cfg.Sinks.LDAP.UserAttribute == "" { 141 | cfg.Sinks.LDAP.UserAttribute = "uid" 142 | } 143 | } 144 | if cfg.Sinks.Slack.Enabled { 145 | if len(cfg.Sinks.Slack.Channels) == 0 { 146 | configErrors = append(configErrors, "Slack Sink: Channels not configured") 147 | } 148 | } 149 | 150 | if len(configErrors) > 0 { 151 | return fmt.Errorf("config validation error(s): %s", buildErrorMsg(configErrors)) 152 | } 153 | 154 | log.Printf("Config: %+v", cfg) 155 | return nil 156 | } 157 | 158 | func buildSecrets(c *deputizeConfig) (deputizeSecrets, error) { 159 | var configErrors []string 160 | 161 | svcCfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(c.SecretRegion)) 162 | if err != nil { 163 | return deputizeSecrets{}, fmt.Errorf("could not initialize aws svc cfg: %s", err) 164 | } 165 | svc := secretsmanager.NewFromConfig(svcCfg) 166 | input := &secretsmanager.GetSecretValueInput{ 167 | SecretId: aws.String(c.SecretPath), 168 | } 169 | result, err := svc.GetSecretValue(context.TODO(), input) 170 | if err != nil { 171 | return deputizeSecrets{}, fmt.Errorf("could not get secret: %s", err) 172 | } 173 | 174 | var sec deputizeSecrets 175 | json.Unmarshal([]byte(*result.SecretString), &sec) 176 | 177 | if c.Source.PagerDuty.Enabled && !c.Source.PagerDuty.WithOAuth && sec.PDAuthToken == "" { 178 | configErrors = append(configErrors, "PagerDuty source is enabled, but there's an empty or nonexistant value for PDAuthToken in AWS Secrets Manager") 179 | } 180 | if c.Source.PagerDuty.Enabled && c.Source.PagerDuty.WithOAuth { 181 | input := &secretsmanager.GetSecretValueInput{ 182 | SecretId: aws.String(c.Source.PagerDuty.OAuthSecretPath), 183 | } 184 | result, err := svc.GetSecretValue(context.TODO(), input) 185 | if err != nil { 186 | return deputizeSecrets{}, fmt.Errorf("could not get PD OAuth secret: %s", err) 187 | } 188 | sec.PDAuthToken = *result.SecretString 189 | } 190 | 191 | if c.Sinks.Gitlab.Enabled && sec.GitlabAuthToken == "" { 192 | configErrors = append(configErrors, "Gitlab sink is enabled, but there's an empty or nonexistant GitlabAuthToken value in AWS Secrets Manager") 193 | } 194 | if c.Sinks.LDAP.Enabled && sec.LDAPModUserPassword == "" { 195 | configErrors = append(configErrors, "LDAP sink is enabled, but there's an empty or nonexistant LDAPModUserPassword value in AWS Secrets Manager") 196 | } 197 | if c.Sinks.Slack.Enabled && sec.SlackAuthToken == "" { 198 | configErrors = append(configErrors, "Slack sink is enabled, but there's an empty or nonexistant SlackAuthToken value in AWS Secrets Manager") 199 | } 200 | 201 | if len(configErrors) > 0 { 202 | return deputizeSecrets{}, fmt.Errorf(buildErrorMsg(configErrors)) 203 | } 204 | 205 | return sec, nil 206 | } 207 | 208 | func buildErrorMsg(errs []string) string { 209 | var niceErrors string 210 | numErrors := len(errs) 211 | for i, v := range errs { 212 | niceErrors = niceErrors + fmt.Sprintf("%d. %s", i+1, v) 213 | if i+1 < numErrors { 214 | niceErrors = niceErrors + ", " 215 | } 216 | } 217 | return niceErrors 218 | } 219 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PagerDuty/go-pagerduty v1.8.0 h1:MTFqTffIcAervB83U7Bx6HERzLbyaSPL/+oxH3zyluI= 2 | github.com/PagerDuty/go-pagerduty v1.8.0/go.mod h1:nzIeAqyFSJAFkjWKvMzug0JtwDg+V+UoCWjFrfFH5mI= 3 | github.com/aws/aws-lambda-go v1.48.0 h1:1aZUYsrJu0yo5fC4z+Rba1KhNImXcJcvHu763BxoyIo= 4 | github.com/aws/aws-lambda-go v1.48.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= 5 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= 6 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= 7 | github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= 8 | github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= 9 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= 10 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 11 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= 12 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= 13 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= 14 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= 15 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= 16 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= 17 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 18 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 19 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= 20 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= 21 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= 22 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= 23 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.4 h1:EKXYJ8kgz4fiqef8xApu7eH0eae2SrVG+oHCLFybMRI= 24 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.4/go.mod h1:yGhDiLKguA3iFJYxbrQkQiNzuy+ddxesSZYWVeeEH5Q= 25 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= 26 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= 27 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= 28 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= 29 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= 30 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= 31 | github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= 32 | github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= 33 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 34 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 35 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 36 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 37 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 38 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 39 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 40 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 41 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 42 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 43 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 44 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 45 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 46 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 47 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 48 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 49 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 50 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 51 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 52 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 53 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 54 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 55 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 56 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 57 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 58 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= 61 | github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= 62 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 63 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 64 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 65 | gitlab.com/gitlab-org/api/client-go v0.128.0 h1:Wvy1UIuluKemubao2k8EOqrl3gbgJ1PVifMIQmg2Da4= 66 | gitlab.com/gitlab-org/api/client-go v0.128.0/go.mod h1:bYC6fPORKSmtuPRyD9Z2rtbAjE7UeNatu2VWHRf4/LE= 67 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 68 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 69 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 70 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 71 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 72 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 73 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 74 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= 75 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= 76 | gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU= 77 | gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk= 78 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 79 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | -------------------------------------------------------------------------------- /cmd/pdrotator/main.go: -------------------------------------------------------------------------------- 1 | // pdrotator.go - PagerDuty OAuth Rotator Tool for AWS Secrets Manager 2 | // Copyright 2024 F5 Inc. 3 | // Licensed under the BSD 3-clause license; see LICENSE.md for more information. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net/http" 14 | "net/url" 15 | "os" 16 | "strings" 17 | 18 | "github.com/aws/aws-lambda-go/lambda" 19 | "github.com/aws/aws-sdk-go-v2/aws" 20 | "github.com/aws/aws-sdk-go-v2/config" 21 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager" 22 | ) 23 | 24 | type event struct { 25 | SecretID string 26 | ClientRequestToken string 27 | Step string 28 | } 29 | 30 | type pagerDutyConfig struct { 31 | ID string 32 | Region string 33 | Secret string 34 | Scopes []string 35 | } 36 | 37 | type pagerDutyResponse struct { 38 | TokenType string `json:"token_type"` 39 | AccessToken string `json:"access_token"` 40 | Scope string `json:"scope"` 41 | ExpiresIn int `json:"expires_in"` 42 | } 43 | 44 | func main() { 45 | lambda.Start(runLambda) 46 | } 47 | 48 | func runLambda(ctx context.Context, e *event) (string, error) { 49 | log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) 50 | log.Printf("starting pdrotator from event: %+v\n", e) 51 | svcCfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(os.Getenv("AWS_REGION"))) 52 | if err != nil { 53 | return "", fmt.Errorf("could not initialize aws svc cfg: %s", err) 54 | } 55 | svc := secretsmanager.NewFromConfig(svcCfg) 56 | input := secretsmanager.DescribeSecretInput{ 57 | SecretId: aws.String(e.SecretID), 58 | } 59 | secret, err := svc.DescribeSecret(ctx, &input) 60 | if err != nil { 61 | return "", fmt.Errorf("unable to describe secret") 62 | } 63 | if !aws.ToBool(secret.RotationEnabled) { 64 | return "", fmt.Errorf("secret not enabled for rotation") 65 | } 66 | 67 | _, ok := secret.VersionIdsToStages[e.ClientRequestToken] 68 | if !ok { 69 | return "", fmt.Errorf("secret version %s has no stage for rotation of secret %s", e.ClientRequestToken, e.SecretID) 70 | } 71 | 72 | if contains(secret.VersionIdsToStages[e.ClientRequestToken], "AWSCURRENT") { 73 | return "", fmt.Errorf("secret version %s already set as AWSCURRENT for secret %s", e.ClientRequestToken, e.SecretID) 74 | } 75 | 76 | if !contains(secret.VersionIdsToStages[e.ClientRequestToken], "AWSPENDING") { 77 | return "", fmt.Errorf("secret version %s not set as AWSPENDING for rotation of secret %s", e.ClientRequestToken, e.SecretID) 78 | } 79 | 80 | switch e.Step { 81 | case "createSecret": 82 | err := createSecret(ctx, svc, e) 83 | if err != nil { 84 | return "", err 85 | } 86 | case "setSecret": 87 | err := setSecret(ctx, svc, e) 88 | if err != nil { 89 | return "", err 90 | } 91 | case "testSecret": 92 | err := testSecret(ctx, svc, e) 93 | if err != nil { 94 | return "", err 95 | } 96 | case "finishSecret": 97 | err := finishSecret(ctx, svc, e) 98 | if err != nil { 99 | return "", err 100 | } 101 | default: 102 | return "", fmt.Errorf("invalid step: %s", e.Step) 103 | } 104 | 105 | return "", nil 106 | } 107 | 108 | func createSecret(ctx context.Context, svc *secretsmanager.Client, e *event) error { 109 | // Confirm secret version exists 110 | getInput := secretsmanager.GetSecretValueInput{ 111 | SecretId: aws.String(e.SecretID), 112 | VersionStage: aws.String("AWSCURRENT"), 113 | } 114 | currentSecret, gerr := svc.GetSecretValue(ctx, &getInput) 115 | if gerr != nil { 116 | return fmt.Errorf("unable to GetSecretValue: %s", gerr) 117 | } 118 | 119 | // Parse out the instance name from the secret name 120 | instance := strings.Split(aws.ToString(currentSecret.Name), "/") 121 | // Get configuration, put it into the struct 122 | var pdrotator_config_path string 123 | if os.Getenv("PDROTATOR_CONFIG_PATH") == "" { 124 | pdrotator_config_path = "deputize/source/pagerduty" 125 | } else { 126 | pdrotator_config_path = os.Getenv("PDROTATOR_CONFIG_PATH") 127 | } 128 | getPDInput := secretsmanager.GetSecretValueInput{ 129 | SecretId: aws.String(pdrotator_config_path), 130 | VersionStage: aws.String("AWSCURRENT"), 131 | } 132 | pdConfig, gperr := svc.GetSecretValue(ctx, &getPDInput) 133 | if gperr != nil { 134 | return fmt.Errorf("unable to GetSecretValue: %s", gperr) 135 | } 136 | var cfg map[string]pagerDutyConfig 137 | perr := json.Unmarshal([]byte(*pdConfig.SecretString), &cfg) 138 | if perr != nil { 139 | return fmt.Errorf("cant unmarshal json from secret manager: %s", perr) 140 | } 141 | 142 | // Get new PD Token 143 | token, perr := getPDToken(cfg[instance[len(instance)-1]], instance[len(instance)-1]) 144 | if perr != nil { 145 | return fmt.Errorf("unable to get PD token: %s", perr) 146 | } 147 | 148 | // set value 149 | setInput := secretsmanager.PutSecretValueInput{ 150 | SecretId: aws.String(e.SecretID), 151 | ClientRequestToken: aws.String(e.ClientRequestToken), 152 | SecretString: aws.String(token.AccessToken), 153 | VersionStages: []string{"AWSPENDING"}, 154 | } 155 | _, serr := svc.PutSecretValue(ctx, &setInput) 156 | if serr != nil { 157 | return fmt.Errorf("unable to PutSecretValue: %s", serr) 158 | } 159 | 160 | return nil 161 | } 162 | 163 | // setSecret would update a token somewhere else -- but we're getting it from PD so we're 164 | // just going to return success. 165 | func setSecret(ctx context.Context, svc *secretsmanager.Client, e *event) error { 166 | return nil 167 | } 168 | 169 | // testSecret tests the new API key -- but also, we got this from PD. 170 | func testSecret(ctx context.Context, svc *secretsmanager.Client, e *event) error { 171 | return nil 172 | } 173 | 174 | // finishSecret moves AWSPENDING to AWSCURRENT. 175 | func finishSecret(ctx context.Context, svc *secretsmanager.Client, e *event) error { 176 | input := secretsmanager.DescribeSecretInput{ 177 | SecretId: aws.String(e.SecretID), 178 | } 179 | secret, err := svc.DescribeSecret(ctx, &input) 180 | if err != nil { 181 | return fmt.Errorf("unable to describe secret") 182 | } 183 | 184 | var current_version string 185 | for k, v := range secret.VersionIdsToStages { 186 | if contains(v, "AWSCURRENT") { 187 | if k == e.ClientRequestToken { 188 | return nil 189 | } 190 | current_version = k 191 | } 192 | } 193 | 194 | uinput := secretsmanager.UpdateSecretVersionStageInput{ 195 | SecretId: aws.String(e.SecretID), 196 | VersionStage: aws.String("AWSCURRENT"), 197 | MoveToVersionId: aws.String(e.ClientRequestToken), 198 | RemoveFromVersionId: aws.String(current_version), 199 | } 200 | _, uerr := svc.UpdateSecretVersionStage(ctx, &uinput) 201 | if uerr != nil { 202 | return fmt.Errorf("unable to UpdateSecretVersionStage: %s", uerr) 203 | } 204 | return nil 205 | } 206 | 207 | func getPDToken(cfg pagerDutyConfig, instance string) (pagerDutyResponse, error) { 208 | oAuthEndpoint := "https://identity.pagerduty.com/oauth/token" 209 | 210 | params := url.Values{} 211 | params.Add("grant_type", "client_credentials") 212 | params.Add("client_id", cfg.ID) 213 | params.Add("client_secret", cfg.Secret) 214 | params.Add("scope", fmt.Sprintf("as_account-%s.%s %s", cfg.Region, instance, strings.Join(cfg.Scopes, " "))) 215 | 216 | data := strings.NewReader(params.Encode()) 217 | client := &http.Client{} 218 | req, err := http.NewRequest("POST", oAuthEndpoint, data) 219 | if err != nil { 220 | return pagerDutyResponse{}, fmt.Errorf("unable to POST to PD: %s", err) 221 | } 222 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 223 | resp, err := client.Do(req) 224 | if err != nil { 225 | return pagerDutyResponse{}, fmt.Errorf("unable to talk to PD: %s", err) 226 | } 227 | defer resp.Body.Close() 228 | body, err := io.ReadAll(resp.Body) 229 | if err != nil { 230 | return pagerDutyResponse{}, fmt.Errorf("unable to read PD body: %s", err) 231 | } 232 | 233 | var token pagerDutyResponse 234 | 235 | perr := json.Unmarshal(body, &token) 236 | if perr != nil { 237 | return pagerDutyResponse{}, fmt.Errorf("unable to parse json: %s", perr) 238 | } 239 | 240 | return token, nil 241 | } 242 | 243 | func contains(a []string, x string) bool { 244 | for _, n := range a { 245 | if x == n { 246 | return true 247 | } 248 | } 249 | return false 250 | } 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # deputize 2 | 3 | Deputize is a tool to read in information from a source (such as PagerDuty) to determine who's on call, and then send that information to a sink (GitLab, LDAP, Slack) to do something with - update an approver group in GitLab, or update an LDAP group, or update a topic/post a message on Slack. 4 | 5 | ## Prerequisites 6 | If you want to deploy Deputize, you'll need some API keys before you get started. 7 | 8 | ### Sources 9 | A **Source** is where on-call user information is stored. Deputize can pull the email addresses of on-call folks from PagerDuty. You'll need to: 10 | 1. Create a read-only developer API key (https://your-instance-here.pagerduty.com/api_keys) 11 | 2. Note the name(s) of the on-call schedule(s) you will be monitoring 12 | 13 | ### Sinks 14 | A **Sink** is the destination for those on-call emails you read from the source. Deputize supports sending data to the following sinks today: 15 | * GitLab 16 | * LDAP 17 | * Slack 18 | 19 | #### GitLab 20 | 1. Create an API token for GitLab. 21 | 2. Note the URL of your instance (If you're using gitlab.com, set `Server` to `https://gitlab.com/`) 22 | 2. Note the path to the approver group (this is used for `Group`) 23 | 3. Note the what on-call schedule that will populate that group (this is used for `ApproverSchedule`) 24 | 25 | #### LDAP 26 | There are many LDAP servers in the world, so we can't give a guide to creating scoped users for all of them. High level, you'll want to make a user (and set that user as `ModUserDN`) that can modify a named on-call group. For OpenLDAP, here's a sample `olcAccess` ACL entry you could use to let a named user edit the `memberUid` attribute of a specific `posixGroup` entry: 27 | ``` 28 | olcAccess: to dn.base="cn=oncall,ou=groups,dc=tls,dc=zone" 29 | attrs=memberUid 30 | by dn.exact="cn=deputize,dc=tls,dc=zone" write 31 | by * read 32 | ``` 33 | 34 | If you're using a custom CA in your environment, make sure to drop that root CA certificate into `truststore.pem` 35 | 36 | #### Slack 37 | Create a new Slack application in your workspace with the following scopes: 38 | * `channels:read` 39 | * `channels:write.topic` 40 | * `chat:write` 41 | * `users:read` 42 | * `users:read.email` 43 | 44 | Grab the workspace OAuth Token. It should start with `xoxb-`. 45 | 46 | ## Deployment 47 | 48 | ### Create A Secret 49 | 50 | Create an AWS Secrets Manager secret, and populate it with the following keys for whichever sources and sinks you're looking to use. 51 | 52 | | Key | Type | Purpose 53 | |---------------------|----------|---------------------------------------------------| 54 | | GitlabAuthToken | Sink | GitLab API key for updating a GitLab group. | 55 | | LDAPModUserPassword | Sink | LDAP password for the user you specify in ModUserDN | 56 | | PDAuthToken | Source | Read API key for PagerDuty | 57 | | SlackAuthToken | Sink | Slack Bot Token | 58 | 59 | 60 | ### Create IAM Execution Role 61 | Create an execution IAM role for your Lambda function to execute as. You'll want to give it a policy that allows it to read from AWS Secrets Manager: 62 | 63 | ``` 64 | { 65 | "Version": "2012-10-17", 66 | "Statement": [ 67 | { 68 | "Effect": "Allow", 69 | "Action": [ 70 | "logs:CreateLogGroup", 71 | "logs:CreateLogStream", 72 | "logs:PutLogEvents" 73 | ], 74 | "Resource": "arn:aws:logs:*:*:*" 75 | }, 76 | { 77 | "Effect": "Allow", 78 | "Action": [ 79 | "secretsmanager:GetResourcePolicy", 80 | "secretsmanager:GetSecretValue", 81 | "secretsmanager:DescribeSecret", 82 | "secretsmanager:ListSecretVersionIds" 83 | ], 84 | "Resource": [ 85 | "arn:aws:secretsmanager:REGION:AWS_ACCOUNT_NUM:secret:deputize/myEnv-o9iCSb" 86 | ] 87 | }, 88 | { 89 | "Effect": "Allow", 90 | "Action": "secretsmanager:ListSecrets", 91 | "Resource": "*" 92 | } 93 | ] 94 | } 95 | ``` 96 | 97 | If you're planning on using the LDAP sink and the LDAP server is inside your VPC, you'll want to add the following policy statement: 98 | ``` 99 | { 100 | "Sid": "AllowConnectivityToLDAP", 101 | "Effect": "Allow", 102 | "Action": [ 103 | "ec2:CreateNetworkInterface", 104 | "ec2:DescribeNetworkInterfaces", 105 | "ec2:DeleteNetworkInterface" 106 | ], 107 | "Resource": "*" 108 | } 109 | ``` 110 | 111 | ### Package up the binary and truststore 112 | You'll need to build a linux amd64 binary with a golang toolchain. Run `GOOS=linux GOARCH=amd64 go build`. 113 | 114 | If you're using the LDAP sink, make sure you've put the root CA for your LDAP server in `truststore.pem`. 115 | 116 | Combine the files into a zip file you can upload with `zip deputize.zip deputize truststore.pem` 117 | 118 | ### Create the Lambda function 119 | 1. Specify the IAM execution role you created above 120 | 2. Upload the zip file 121 | 3. Configure the entrypoint (`deputize`), and RAM settings (128mb is fine) 122 | 4. (if using the LDAP sink) Configure the VPC connectivity - subnets and security groups 123 | 124 | ### Invoke the function 125 | This is where it all comes together. The configuration for Deputize is sent via on invocation. You could set up an EventBridge event to run this function every few minutes. 126 | 127 | ``` 128 | { 129 | "SecretPath": "deputize/myEnvConfig", 130 | "SecretRegion": "us-east-1", 131 | "Source": { 132 | "PagerDuty": { 133 | "Enabled": true, 134 | "OnCallSchedules": ["Ops", "Ops 2nd Level"], 135 | "WithOAuth": true, 136 | "OAuthSecretPath": "deputize/source/pagerduty/yourinstance" 137 | } 138 | }, 139 | "Sinks": { 140 | "Gitlab": { 141 | "Enabled": false, 142 | "Server": "https://gitlab.com/", 143 | "Group": "patcable/approvers", 144 | "ApproverSchedule": "Ops 2nd Level" 145 | }, 146 | "LDAP": { 147 | "Enabled": false, 148 | "BaseDN": "dc=tls,dc=zone", 149 | "RootCAFile": "truststore.pem", 150 | "Server": "ldap.tls.zone", 151 | "Port": 389, 152 | "ModUserDN": "cn=deputize,dc=tls,dc=zone", 153 | "OnCallGroup": "cn=lg-oncall" 154 | }, 155 | "Slack": { 156 | "Enabled": true, 157 | "Channels": ["C0CRTBR8R"], 158 | "PostMessage": true 159 | } 160 | } 161 | } 162 | ``` 163 | 164 | ## Contributing 165 | ### Before you Begin 166 | Before you start contributing to any project sponsored by F5, Inc. (F5) on GitHub, you will need to sign a Contributor License Agreement (CLA). This document can be provided to you once you submit a GitHub issue that you contemplate contributing code to, or after you issue a pull request. 167 | 168 | If you are signing as an individual, we recommend that you talk to your employer (if applicable) before signing the CLA since some employment agreements may have restrictions on your contributions to other projects. Otherwise by submitting a CLA you represent that you are legally entitled to grant the licenses recited therein. 169 | 170 | If your employer has rights to intellectual property that you create, such as your contributions, you represent that you have received permission to make contributions on behalf of that employer, that your employer has waived such rights for your contributions, or that your employer has executed a separate CLA with F5. 171 | 172 | If you are signing on behalf of a company, you represent that you are legally entitled to grant the license recited therein. You represent further that each employee of the entity that submits contributions is authorized to submit such contributions on behalf of the entity pursuant to the CLA. 173 | ### Contribution Ideas 174 | * Source and Sink additions/updates 175 | * Take a look at `deputize.go` to see how sources and sinks work 176 | * Take a look at `config.go` to see how sources and sinks are configured 177 | * Abstract secret storage 178 | * A previous version of Deputize would read its secrets from Hashicorp Vault 179 | ### Testing Locally 180 | You can perform local testing with by using the AWS Lambda Docker Images. Replace `arm64` with `amd64` if you're using an x86-64 flavored processor. 181 | 182 | 1. Build a local binary with `GOOS=linux go build` 183 | 2. Create a container: `docker build --platform linux/arm64 -t deputize:test .` 184 | 3. Run the container: `docker run --platform linux/arm64 -p 9000:8080 -v ~/.aws:/root/.aws deputize:test` 185 | * The `-v ~/.aws:/root/.aws` will map your local AWS creds into the container; required for pulling AWS Secrets Manager items 186 | 4. Put the deputize configuration in `config.json` 187 | 5. In another window, invoke the function: `curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d @config.json` 188 | 189 | To get LDAP connectivity working in the container, set `Server` to `host.docker.internal`. You may find the `InsecureSkipVerify` flag helpful in this case, but it's not something you'd want to deploy into production. 190 | 191 | --------------------------------------------------------------------------------