Compare commits

...

4 Commits

Author SHA1 Message Date
Gucheng
06dc07602f Update syncer_awsiam.go 2026-02-11 00:48:12 +08:00
copilot-swe-agent[bot]
96ffbba309 Improve error logging and documentation for AWS IAM syncer
Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com>
2026-02-10 16:38:29 +00:00
copilot-swe-agent[bot]
0e27b5fb14 Add AWS IAM syncer implementation
Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com>
2026-02-10 16:31:38 +00:00
copilot-swe-agent[bot]
4cde0fc602 Initial plan 2026-02-10 16:27:14 +00:00
4 changed files with 413 additions and 9 deletions

2
go.mod
View File

@@ -17,6 +17,7 @@ require (
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible
github.com/aliyun/credentials-go v1.3.10
github.com/aws/aws-sdk-go v1.45.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/beego/beego/v2 v2.3.8
github.com/beevik/etree v1.1.0
@@ -114,7 +115,6 @@ require (
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/apistd/uni-go-sdk v0.0.2 // indirect
github.com/atc0005/go-teams-notify/v2 v2.13.0 // indirect
github.com/aws/aws-sdk-go v1.45.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/baidubce/bce-sdk-go v0.9.156 // indirect
github.com/beorn7/perks v1.0.1 // indirect

327
object/syncer_awsiam.go Normal file
View File

@@ -0,0 +1,327 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/casdoor/casdoor/util"
)
// AwsIamSyncerProvider implements SyncerProvider for AWS IAM API-based syncers
type AwsIamSyncerProvider struct {
Syncer *Syncer
iamClient *iam.IAM
}
// InitAdapter initializes the AWS IAM syncer
func (p *AwsIamSyncerProvider) InitAdapter() error {
// syncer.Host should be the AWS region (e.g., "us-east-1")
// syncer.User should be the AWS Access Key ID
// syncer.Password should be the AWS Secret Access Key
region := p.Syncer.Host
if region == "" {
return fmt.Errorf("AWS region (host field) is required for AWS IAM syncer")
}
accessKeyId := p.Syncer.User
if accessKeyId == "" {
return fmt.Errorf("AWS Access Key ID (user field) is required for AWS IAM syncer")
}
secretAccessKey := p.Syncer.Password
if secretAccessKey == "" {
return fmt.Errorf("AWS Secret Access Key (password field) is required for AWS IAM syncer")
}
// Create AWS session
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
Credentials: credentials.NewStaticCredentials(accessKeyId, secretAccessKey, ""),
})
if err != nil {
return fmt.Errorf("failed to create AWS session: %w", err)
}
// Create IAM client
p.iamClient = iam.New(sess)
return nil
}
// GetOriginalUsers retrieves all users from AWS IAM API
func (p *AwsIamSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
if p.iamClient == nil {
if err := p.InitAdapter(); err != nil {
return nil, err
}
}
return p.getAwsIamUsers()
}
// AddUser adds a new user to AWS IAM (not supported for read-only API)
func (p *AwsIamSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// AWS IAM syncer is typically read-only
return false, fmt.Errorf("adding users to AWS IAM is not supported")
}
// UpdateUser updates an existing user in AWS IAM (not supported for read-only API)
func (p *AwsIamSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// AWS IAM syncer is typically read-only
return false, fmt.Errorf("updating users in AWS IAM is not supported")
}
// TestConnection tests the AWS IAM API connection
func (p *AwsIamSyncerProvider) TestConnection() error {
if p.iamClient == nil {
if err := p.InitAdapter(); err != nil {
return err
}
}
// Try to list users with a limit of 1 to test the connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
input := &iam.ListUsersInput{
MaxItems: aws.Int64(1),
}
_, err := p.iamClient.ListUsersWithContext(ctx, input)
return err
}
// Close closes any open connections
func (p *AwsIamSyncerProvider) Close() error {
// AWS IAM client doesn't require explicit cleanup
p.iamClient = nil
return nil
}
// getAwsIamUsers gets all users from AWS IAM API
func (p *AwsIamSyncerProvider) getAwsIamUsers() ([]*OriginalUser, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
allUsers := []*iam.User{}
var marker *string
// Paginate through all users
for {
input := &iam.ListUsersInput{
Marker: marker,
}
result, err := p.iamClient.ListUsersWithContext(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list IAM users: %w", err)
}
allUsers = append(allUsers, result.Users...)
if result.IsTruncated == nil || !*result.IsTruncated {
break
}
marker = result.Marker
}
// Convert AWS IAM users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, iamUser := range allUsers {
originalUser, err := p.awsIamUserToOriginalUser(iamUser)
if err != nil {
// Log error but continue processing other users
userName := "unknown"
if iamUser.UserName != nil {
userName = *iamUser.UserName
}
fmt.Printf("Warning: Failed to convert IAM user %s: %v\n", userName, err)
continue
}
originalUsers = append(originalUsers, originalUser)
}
return originalUsers, nil
}
// awsIamUserToOriginalUser converts AWS IAM user to Casdoor OriginalUser
func (p *AwsIamSyncerProvider) awsIamUserToOriginalUser(iamUser *iam.User) (*OriginalUser, error) {
if iamUser == nil {
return nil, fmt.Errorf("IAM user is nil")
}
user := &OriginalUser{
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
}
// Set ID from UserId (unique identifier)
if iamUser.UserId != nil {
user.Id = *iamUser.UserId
}
// Set Name from UserName
if iamUser.UserName != nil {
user.Name = *iamUser.UserName
}
// Set DisplayName (use UserName if not available separately)
if iamUser.UserName != nil {
user.DisplayName = *iamUser.UserName
}
// Set CreatedTime from CreateDate
if iamUser.CreateDate != nil {
user.CreatedTime = iamUser.CreateDate.Format(time.RFC3339)
} else {
user.CreatedTime = util.GetCurrentTime()
}
// Get user tags which might contain additional information
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tagsInput := &iam.ListUserTagsInput{
UserName: iamUser.UserName,
}
tagsResult, err := p.iamClient.ListUserTagsWithContext(ctx, tagsInput)
if err == nil && tagsResult != nil {
// Process tags to extract additional user information
for _, tag := range tagsResult.Tags {
if tag.Key != nil && tag.Value != nil {
key := *tag.Key
value := *tag.Value
switch key {
case "Email", "email":
user.Email = value
case "Phone", "phone":
user.Phone = value
case "DisplayName", "displayName":
user.DisplayName = value
case "FirstName", "firstName":
user.FirstName = value
case "LastName", "lastName":
user.LastName = value
case "Title", "title":
user.Title = value
case "Department", "department":
user.Affiliation = value
default:
// Store other tags in Properties
user.Properties[key] = value
}
}
}
}
// AWS IAM users are active by default unless specified in tags
// Check if there's a "Status" or "Active" tag
if status, ok := user.Properties["Status"]; ok {
if status == "Inactive" || status == "Disabled" {
user.IsForbidden = true
}
}
if active, ok := user.Properties["Active"]; ok {
if active == "false" || active == "False" || active == "0" {
user.IsForbidden = true
}
}
return user, nil
}
// GetOriginalGroups retrieves all groups from AWS IAM
func (p *AwsIamSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
if p.iamClient == nil {
if err := p.InitAdapter(); err != nil {
return nil, err
}
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
allGroups := []*iam.Group{}
var marker *string
// Paginate through all groups
for {
input := &iam.ListGroupsInput{
Marker: marker,
}
result, err := p.iamClient.ListGroupsWithContext(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list IAM groups: %w", err)
}
allGroups = append(allGroups, result.Groups...)
if result.IsTruncated == nil || !*result.IsTruncated {
break
}
marker = result.Marker
}
// Convert AWS IAM groups to Casdoor OriginalGroup
originalGroups := []*OriginalGroup{}
for _, iamGroup := range allGroups {
if iamGroup.GroupId != nil && iamGroup.GroupName != nil {
group := &OriginalGroup{
Id: *iamGroup.GroupId,
Name: *iamGroup.GroupName,
}
if iamGroup.GroupName != nil {
group.DisplayName = *iamGroup.GroupName
}
originalGroups = append(originalGroups, group)
}
}
return originalGroups, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to
func (p *AwsIamSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
if p.iamClient == nil {
if err := p.InitAdapter(); err != nil {
return nil, err
}
}
// Note: AWS IAM API requires UserName to query groups, but this interface provides UserId.
// This is a known limitation. To properly implement this, we would need to:
// 1. Maintain a mapping cache from UserId to UserName, or
// 2. Modify the interface to accept both UserId and UserName
// For now, returning empty groups to maintain interface compatibility.
// TODO: Implement user group synchronization by maintaining a UserId->UserName mapping
return []string{}, nil
}

View File

@@ -72,6 +72,8 @@ func GetSyncerProvider(syncer *Syncer) SyncerProvider {
return &OktaSyncerProvider{Syncer: syncer}
case "SCIM":
return &SCIMSyncerProvider{Syncer: syncer}
case "AWS IAM":
return &AwsIamSyncerProvider{Syncer: syncer}
case "Keycloak":
return &KeycloakSyncerProvider{
DatabaseSyncerProvider: DatabaseSyncerProvider{Syncer: syncer},

View File

@@ -710,6 +710,79 @@ class SyncerEditPage extends React.Component {
"values": [],
},
];
case "AWS IAM":
return [
{
"name": "UserId",
"type": "string",
"casdoorName": "Id",
"isHashed": true,
"values": [],
},
{
"name": "UserName",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "UserName",
"type": "string",
"casdoorName": "DisplayName",
"isHashed": true,
"values": [],
},
{
"name": "Tags.Email",
"type": "string",
"casdoorName": "Email",
"isHashed": true,
"values": [],
},
{
"name": "Tags.Phone",
"type": "string",
"casdoorName": "Phone",
"isHashed": true,
"values": [],
},
{
"name": "Tags.FirstName",
"type": "string",
"casdoorName": "FirstName",
"isHashed": true,
"values": [],
},
{
"name": "Tags.LastName",
"type": "string",
"casdoorName": "LastName",
"isHashed": true,
"values": [],
},
{
"name": "Tags.Title",
"type": "string",
"casdoorName": "Title",
"isHashed": true,
"values": [],
},
{
"name": "Tags.Department",
"type": "string",
"casdoorName": "Affiliation",
"isHashed": true,
"values": [],
},
{
"name": "CreateDate",
"type": "string",
"casdoorName": "CreatedTime",
"isHashed": true,
"values": [],
},
];
default:
return [];
}
@@ -766,14 +839,14 @@ class SyncerEditPage extends React.Component {
});
})}>
{
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace", "DingTalk", "Lark", "Okta", "SCIM"]
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace", "DingTalk", "Lark", "Okta", "SCIM", "AWS IAM"]
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" || this.state.syncer.type === "AWS IAM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("syncer:Database type"), i18next.t("syncer:Database type - Tooltip"))} :
@@ -828,7 +901,7 @@ class SyncerEditPage extends React.Component {
this.state.syncer.type === "WeCom" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Azure AD" ? i18next.t("provider:Tenant ID") : this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Admin Email") : this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Server") : this.state.syncer.type === "SCIM" ? i18next.t("syncer:SCIM Server URL") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
{Setting.getLabel(this.state.syncer.type === "Azure AD" ? i18next.t("provider:Tenant ID") : this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Admin Email") : this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Server") : this.state.syncer.type === "SCIM" ? i18next.t("syncer:SCIM Server URL") : this.state.syncer.type === "AWS IAM" ? i18next.t("syncer:AWS Region") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={this.state.syncer.host} onChange={e => {
@@ -839,7 +912,7 @@ class SyncerEditPage extends React.Component {
)
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" || this.state.syncer.type === "AWS IAM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("provider:LDAP port") : i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} :
@@ -863,7 +936,8 @@ class SyncerEditPage extends React.Component {
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client ID") :
this.state.syncer.type === "Active Directory" ? i18next.t("syncer:Bind DN") :
this.state.syncer.type === "SCIM" ? i18next.t("syncer:Username (optional)") :
i18next.t("general:User"),
this.state.syncer.type === "AWS IAM" ? i18next.t("syncer:AWS Access Key ID") :
i18next.t("general:User"),
i18next.t("general:User - Tooltip")
)} :
</Col>
@@ -884,7 +958,8 @@ class SyncerEditPage extends React.Component {
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client secret") :
this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Service account key") :
this.state.syncer.type === "SCIM" ? i18next.t("syncer:API Token / Password") :
i18next.t("general:Password"),
this.state.syncer.type === "AWS IAM" ? i18next.t("syncer:AWS Secret Access Key") :
i18next.t("general:Password"),
i18next.t("general:Password - Tooltip")
)} :
</Col>
@@ -903,7 +978,7 @@ class SyncerEditPage extends React.Component {
</Col>
</Row>
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" || this.state.syncer.type === "AWS IAM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Base DN") : i18next.t("syncer:Database"), i18next.t("syncer:Database - Tooltip"))} :
@@ -999,7 +1074,7 @@ class SyncerEditPage extends React.Component {
) : null
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" || this.state.syncer.type === "AWS IAM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("syncer:Table"), i18next.t("syncer:Table - Tooltip"))} :