Merge pull request #95 from ztkkOip/feat/base-service-delivery
Feat(base service delivery)基础服务部署在主系统的初始化
This commit is contained in:
Vendored
+1
-1
@@ -17,8 +17,8 @@ declare module 'vue' {
|
|||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export const businessLineApi = {
|
|||||||
business_line_id: number
|
business_line_id: number
|
||||||
target_user_id: number
|
target_user_id: number
|
||||||
target_business_line_id: number
|
target_business_line_id: number
|
||||||
permission: 0 | 1
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await request('/auth/api/v1/business-lines/authorizations', {
|
await request('/auth/api/v1/business-lines/authorizations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -69,7 +68,7 @@ export const businessLineApi = {
|
|||||||
|
|
||||||
async listWayneNamespaces(): Promise<WayneNamespace[]> {
|
async listWayneNamespaces(): Promise<WayneNamespace[]> {
|
||||||
const data = await request('/auth/api/v1/wayne/namespaces')
|
const data = await request('/auth/api/v1/wayne/namespaces')
|
||||||
const items = Array.isArray(data.data) ? data.data : Array.isArray(data.items) ? data.items : []
|
const items = Array.isArray(data.data?.list) ? data.data.list : []
|
||||||
return items.map((item: any) => ({
|
return items.map((item: any) => ({
|
||||||
id: Number(item.id),
|
id: Number(item.id),
|
||||||
name: item.name || '',
|
name: item.name || '',
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { getToken } from '@/utils/auth'
|
||||||
|
|
||||||
|
export interface DeploymentCreatePayload {
|
||||||
|
component: string
|
||||||
|
business_line_id: number
|
||||||
|
params: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeploymentCreateResult {
|
||||||
|
deployment_id: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deploymentApi = {
|
||||||
|
async create(payload: DeploymentCreatePayload): Promise<DeploymentCreateResult> {
|
||||||
|
const data = await authRequest('/auth/api/v1/deployments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
deployment_id: String(data.deployment_id || ''),
|
||||||
|
status: String(data.status || ''),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancel(deploymentId: string): Promise<void> {
|
||||||
|
await authRequest(`/auth/api/v1/deployments/${encodeURIComponent(deploymentId)}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
eventsURL(deploymentId: string): string {
|
||||||
|
const token = getToken()
|
||||||
|
const params = token ? `?access_token=${encodeURIComponent(token)}` : ''
|
||||||
|
return `/auth/api/v1/deployments/${encodeURIComponent(deploymentId)}/events${params}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authRequest(path: string, init: RequestInit = {}) {
|
||||||
|
const token = getToken()
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
const data = parseResponseBody(text)
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = data?.error || data?.message || text || `HTTP ${response.status}`
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return data || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponseBody(text: string) {
|
||||||
|
if (!text.trim()) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { error: text }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,14 @@ export interface UserOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
async list(): Promise<UserOption[]> {
|
async list(params: { businessLineId?: number } = {}): Promise<UserOption[]> {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const response = await fetch('/auth/api/v1/users', {
|
const query = new URLSearchParams()
|
||||||
|
if (params.businessLineId) {
|
||||||
|
query.set('business_line_id', String(params.businessLineId))
|
||||||
|
}
|
||||||
|
const path = query.toString() ? `/auth/api/v1/users?${query}` : '/auth/api/v1/users'
|
||||||
|
const response = await fetch(path, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/service/Catalog.vue'),
|
component: () => import('@/views/service/Catalog.vue'),
|
||||||
meta: { title: '基础服务' },
|
meta: { title: '基础服务' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'service/catalog/:component',
|
||||||
|
name: 'ServiceDelivery',
|
||||||
|
component: () => import('@/views/service/Catalog.vue'),
|
||||||
|
meta: { title: '基础服务交付' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'service/management',
|
path: 'service/management',
|
||||||
name: 'ServiceManagement',
|
name: 'ServiceManagement',
|
||||||
|
|||||||
@@ -19,9 +19,6 @@
|
|||||||
<el-option v-for="item in businessLineStore.items" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in businessLineStore.items" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="权限">
|
|
||||||
<el-segmented v-model="grantForm.permission" :options="permissionOptions" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-button type="primary" :loading="granting" @click="grantPermission">保存分配</el-button>
|
<el-button type="primary" :loading="granting" @click="grantPermission">保存分配</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
@@ -72,18 +69,11 @@ const savingWayneNamespaces = ref(false)
|
|||||||
const grantForm = reactive<{
|
const grantForm = reactive<{
|
||||||
target_user_id: number | null
|
target_user_id: number | null
|
||||||
target_business_line_id: number | null
|
target_business_line_id: number | null
|
||||||
permission: 0 | 1
|
|
||||||
}>({
|
}>({
|
||||||
target_user_id: null,
|
target_user_id: null,
|
||||||
target_business_line_id: null,
|
target_business_line_id: null,
|
||||||
permission: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const permissionOptions = [
|
|
||||||
{ label: '管理员', value: 0 },
|
|
||||||
{ label: '普通用户', value: 1 },
|
|
||||||
]
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => businessLineStore.current?.id,
|
() => businessLineStore.current?.id,
|
||||||
(id) => {
|
(id) => {
|
||||||
@@ -139,7 +129,6 @@ async function grantPermission() {
|
|||||||
business_line_id: businessLineID,
|
business_line_id: businessLineID,
|
||||||
target_user_id: grantForm.target_user_id,
|
target_user_id: grantForm.target_user_id,
|
||||||
target_business_line_id: grantForm.target_business_line_id,
|
target_business_line_id: grantForm.target_business_line_id,
|
||||||
permission: grantForm.permission,
|
|
||||||
})
|
})
|
||||||
ElMessage.success('已保存业务线分配')
|
ElMessage.success('已保存业务线分配')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+1255
-287
File diff suppressed because it is too large
Load Diff
@@ -239,10 +239,11 @@ const operatorStateDetail = computed(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => businessLineStore.current?.id,
|
() => businessLineStore.current?.id,
|
||||||
async () => {
|
async () => {
|
||||||
|
selectedUserId.value = null
|
||||||
selectedNamespaceId.value = null
|
selectedNamespaceId.value = null
|
||||||
selectedRoleIds.value = []
|
selectedRoleIds.value = []
|
||||||
userRoles.value = null
|
userRoles.value = null
|
||||||
await loadBusinessLineData()
|
await Promise.all([loadUsers(), loadBusinessLineData()])
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -286,14 +287,25 @@ async function loadSystems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
|
const businessLineId = currentBusinessLineId.value
|
||||||
|
if (!businessLineId) {
|
||||||
|
users.value = []
|
||||||
|
selectedUserId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
loadingUsers.value = true
|
loadingUsers.value = true
|
||||||
try {
|
try {
|
||||||
users.value = await userApi.list()
|
users.value = await userApi.list({ businessLineId })
|
||||||
if (!selectedUserId.value && users.value.length) {
|
if (!selectedUserId.value && users.value.length) {
|
||||||
selectedUserId.value = users.value[0].uid
|
selectedUserId.value = users.value[0].uid
|
||||||
await loadSelectedUserRoles()
|
await loadSelectedUserRoles()
|
||||||
|
} else if (selectedUserId.value && !users.value.some((user) => user.uid === selectedUserId.value)) {
|
||||||
|
selectedUserId.value = users.value[0]?.uid || null
|
||||||
|
await loadSelectedUserRoles()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
users.value = []
|
||||||
|
selectedUserId.value = null
|
||||||
ElMessage.error(error instanceof Error ? error.message : '查询用户失败')
|
ElMessage.error(error instanceof Error ? error.message : '查询用户失败')
|
||||||
} finally {
|
} finally {
|
||||||
loadingUsers.value = false
|
loadingUsers.value = false
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ OIDC_TOKEN_ENDPOINT=http://localhost:8080/auth/oauth/token
|
|||||||
OIDC_USERINFO_ENDPOINT=http://localhost:8080/auth/oauth/userinfo
|
OIDC_USERINFO_ENDPOINT=http://localhost:8080/auth/oauth/userinfo
|
||||||
OIDC_JWKS_URI=http://localhost:8080/auth/oauth/jwks
|
OIDC_JWKS_URI=http://localhost:8080/auth/oauth/jwks
|
||||||
CLOUDDM_TARGET_URL=http://authserver-nginx/internal/clouddm
|
CLOUDDM_TARGET_URL=http://authserver-nginx/internal/clouddm
|
||||||
|
WAYNE_API_BASE_URL=http://wayne-backend:8080
|
||||||
|
WAYNE_ADMIN_USERNAME=admin
|
||||||
|
WAYNE_ADMIN_PASSWORD=change-this-wayne-admin-password
|
||||||
|
WAYNE_TOKEN_TTL_MINUTES=1440
|
||||||
|
ANSIBLE_SERVICE_BASE_URL=http://ansible-runner:8084
|
||||||
|
ANSIBLE_INTERNAL_TOKEN=change-this-ansible-internal-token
|
||||||
|
DEPLOYMENT_CALLBACK_BASE_URL=http://authserver-backend:8080
|
||||||
|
|
||||||
MYSQL_DSN=auth:auth@tcp(127.0.0.1:3000)/authserver?charset=utf8mb4&parseTime=True&loc=Local
|
MYSQL_DSN=auth:auth@tcp(127.0.0.1:3000)/authserver?charset=utf8mb4&parseTime=True&loc=Local
|
||||||
AUTO_MIGRATE=true
|
AUTO_MIGRATE=true
|
||||||
|
|||||||
+21
-15
@@ -285,7 +285,7 @@ Wayne 会把回调地址拼成:
|
|||||||
|
|
||||||
## Wayne 授权代理接口
|
## Wayne 授权代理接口
|
||||||
|
|
||||||
AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端会从当前 `authserver_token` 里取 `email` 作为操作者 `operatorName`,目标用户名由请求体或路径参数提供。
|
AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端使用配置里的 Wayne 超级管理员账号登录 Wayne 原生 API,拿到 Wayne token 后存入数据库,后续代理请求都带 `Authorization: Bearer <wayne_token>`。token 过期或 Wayne 返回 401 时会重新调用 Wayne 登录接口获取 token 并重试一次。
|
||||||
|
|
||||||
对外接口:
|
对外接口:
|
||||||
|
|
||||||
@@ -317,30 +317,36 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
AuthServer 转发到 Wayne internal API 时会使用请求体里的 `username`:
|
AuthServer 转发到 Wayne 原生 API 时会先按 `username` 查询 Wayne 用户,拿到 Wayne `user.id` 后再查询目标 namespace/app 下的用户角色绑定记录:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PUT /api/v1/internal/namespaces/1/users/target@example.com/roles
|
GET /api/v1/users?name=target@example.com
|
||||||
|
GET /api/v1/namespaces/1/users?userId=<wayne_user_id>
|
||||||
```
|
```
|
||||||
|
|
||||||
并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。`username` 只用于 Wayne path,不会透传到 Wayne 请求体。
|
如果绑定记录存在,会调用原生更新接口;不存在则调用原生创建接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/namespaces/1/users
|
||||||
|
PUT /api/v1/namespaces/1/users/<namespace_user_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
删除角色时会先查询绑定记录,再调用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DELETE /api/v1/namespaces/1/users/<namespace_user_id>
|
||||||
|
```
|
||||||
|
|
||||||
相关配置:
|
相关配置:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
WAYNE_INTERNAL_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080
|
WAYNE_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080
|
||||||
WAYNE_SERVICE_NAME=xinfra
|
WAYNE_ADMIN_USERNAME=admin
|
||||||
WAYNE_SERVICE_API_SECRET_KEY=<wayne-service-secret>
|
WAYNE_ADMIN_PASSWORD=<wayne-admin-password>
|
||||||
|
WAYNE_TOKEN_TTL_MINUTES=1440
|
||||||
```
|
```
|
||||||
|
|
||||||
Wayne internal API 签名规则:
|
`WAYNE_API_BASE_URL` 未配置时会兼容读取旧的 `WAYNE_INTERNAL_API_BASE_URL`。Wayne admin token 会写入 `wayne_tokens` 表,服务重启后优先复用未过期 token。
|
||||||
|
|
||||||
```text
|
|
||||||
bodyHash = SHA256_HEX(rawBody)
|
|
||||||
payload = METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash
|
|
||||||
signature = HMAC_SHA256_HEX(secret, payload)
|
|
||||||
X-Wayne-Signature = "sha256=" + signature
|
|
||||||
```
|
|
||||||
|
|
||||||
## 子系统赋权接口
|
## 子系统赋权接口
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ type Config struct {
|
|||||||
WayenLoginValue string
|
WayenLoginValue string
|
||||||
WayenOAuthRef string
|
WayenOAuthRef string
|
||||||
WayenOAuthLoginURL string
|
WayenOAuthLoginURL string
|
||||||
|
WayneAPIBaseURL string
|
||||||
|
WayneAdminUsername string
|
||||||
|
WayneAdminPassword string
|
||||||
|
WayneTokenTTLMinutes int
|
||||||
WayneInternalAPIBaseURL string
|
WayneInternalAPIBaseURL string
|
||||||
WayneServiceName string
|
WayneServiceName string
|
||||||
WayneServiceAPISecretKey string
|
WayneServiceAPISecretKey string
|
||||||
@@ -53,6 +57,9 @@ type Config struct {
|
|||||||
CloudDMClientSecret string
|
CloudDMClientSecret string
|
||||||
CloudDMRedirectURI string
|
CloudDMRedirectURI string
|
||||||
CloudDMTargetURL string
|
CloudDMTargetURL string
|
||||||
|
AnsibleServiceBaseURL string
|
||||||
|
AnsibleInternalToken string
|
||||||
|
DeploymentCallbackBaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@@ -88,6 +95,10 @@ func Load() Config {
|
|||||||
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
|
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
|
||||||
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
|
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
|
||||||
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
|
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
|
||||||
|
WayneAPIBaseURL: trimURL(env("WAYNE_API_BASE_URL", env("WAYNE_INTERNAL_API_BASE_URL", ""))),
|
||||||
|
WayneAdminUsername: env("WAYNE_ADMIN_USERNAME", ""),
|
||||||
|
WayneAdminPassword: env("WAYNE_ADMIN_PASSWORD", ""),
|
||||||
|
WayneTokenTTLMinutes: envInt("WAYNE_TOKEN_TTL_MINUTES", 1440),
|
||||||
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
|
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
|
||||||
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
|
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
|
||||||
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
|
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
|
||||||
@@ -104,6 +115,9 @@ func Load() Config {
|
|||||||
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
|
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
|
||||||
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
|
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
|
||||||
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
|
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
|
||||||
|
AnsibleServiceBaseURL: trimURL(env("ANSIBLE_SERVICE_BASE_URL", "")),
|
||||||
|
AnsibleInternalToken: env("ANSIBLE_INTERNAL_TOKEN", ""),
|
||||||
|
DeploymentCallbackBaseURL: trimURL(env("DEPLOYMENT_CALLBACK_BASE_URL", publicBaseURL)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&model.BusinessLineUser{},
|
&model.BusinessLineUser{},
|
||||||
&model.BusinessLineWayneNamespace{},
|
&model.BusinessLineWayneNamespace{},
|
||||||
&model.AccessToken{},
|
&model.AccessToken{},
|
||||||
|
&model.WayneToken{},
|
||||||
|
&model.Deployment{},
|
||||||
|
&model.DeploymentEvent{},
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ type GrantBusinessLinePermissionRequest struct {
|
|||||||
BusinessLineID uint64 `json:"business_line_id" binding:"required"`
|
BusinessLineID uint64 `json:"business_line_id" binding:"required"`
|
||||||
TargetUserID uint64 `json:"target_user_id" binding:"required"`
|
TargetUserID uint64 `json:"target_user_id" binding:"required"`
|
||||||
TargetBusinessLineID uint64 `json:"target_business_line_id" binding:"required"`
|
TargetBusinessLineID uint64 `json:"target_business_line_id" binding:"required"`
|
||||||
Permission int `json:"permission"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BusinessLinePayload struct {
|
type BusinessLinePayload struct {
|
||||||
@@ -223,10 +222,6 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Permission != 0 && req.Permission != 1 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "permission must be 0 or 1"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !claims.IsAdmin {
|
if !claims.IsAdmin {
|
||||||
var currentBinding model.BusinessLineUser
|
var currentBinding model.BusinessLineUser
|
||||||
@@ -278,7 +273,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
|
|||||||
binding = model.BusinessLineUser{
|
binding = model.BusinessLineUser{
|
||||||
BusinessLineID: req.TargetBusinessLineID,
|
BusinessLineID: req.TargetBusinessLineID,
|
||||||
UserID: req.TargetUserID,
|
UserID: req.TargetUserID,
|
||||||
Permission: req.Permission,
|
Permission: 1,
|
||||||
}
|
}
|
||||||
if err := h.db.Create(&binding).Error; err != nil {
|
if err := h.db.Create(&binding).Error; err != nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
@@ -288,30 +283,6 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
} else if binding.Permission != req.Permission {
|
|
||||||
if err := h.db.Model(&binding).Update("permission", req.Permission).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
binding.Permission = req.Permission
|
|
||||||
}
|
|
||||||
|
|
||||||
var initializedWayne []gin.H
|
|
||||||
if created {
|
|
||||||
operatorEmail, ok := subsystemOperatorEmail(c, claims)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
targetUsername := wayneUsernameForUser(targetUser)
|
|
||||||
if targetUsername == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "target user has no Wayne username"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
initialized, ok := h.initializeWayneVisitorForBusinessLine(c, req.TargetBusinessLineID, targetUsername, operatorEmail, claims.IsAdmin)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
initializedWayne = initialized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -321,7 +292,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
|
|||||||
"permission": binding.Permission,
|
"permission": binding.Permission,
|
||||||
"created_at": binding.CreatedAt.Format(time.RFC3339),
|
"created_at": binding.CreatedAt.Format(time.RFC3339),
|
||||||
"updated_at": binding.UpdatedAt.Format(time.RFC3339),
|
"updated_at": binding.UpdatedAt.Format(time.RFC3339),
|
||||||
"wayne_init": initializedWayne,
|
"created": created,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ const ClaimsKey = "claims"
|
|||||||
func AuthMiddleware(cfg config.Config) gin.HandlerFunc {
|
func AuthMiddleware(cfg config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
value := c.GetHeader("Authorization")
|
value := c.GetHeader("Authorization")
|
||||||
if !strings.HasPrefix(value, "Bearer ") {
|
tokenValue := ""
|
||||||
|
if strings.HasPrefix(value, "Bearer ") {
|
||||||
|
tokenValue = strings.TrimPrefix(value, "Bearer ")
|
||||||
|
} else {
|
||||||
|
tokenValue = strings.TrimSpace(c.Query("access_token"))
|
||||||
|
}
|
||||||
|
if tokenValue == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims, err := auth.Parse(cfg.JWTSecret, strings.TrimPrefix(value, "Bearer "))
|
claims, err := auth.Parse(cfg.JWTSecret, tokenValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/1024XEngineer/xinfra/server/internal/config"
|
||||||
|
"github.com/1024XEngineer/xinfra/server/internal/model"
|
||||||
|
"github.com/1024XEngineer/xinfra/server/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeploymentHandler struct {
|
||||||
|
cfg config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
deployments *service.DeploymentService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeploymentHandler(cfg config.Config, db *gorm.DB, deployments *service.DeploymentService) *DeploymentHandler {
|
||||||
|
return &DeploymentHandler{cfg: cfg, db: db, deployments: deployments}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) Create(c *gin.Context) {
|
||||||
|
claims, ok := CurrentClaims(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing auth claims"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.DeploymentCreateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.ensureBusinessLineMember(c, req.BusinessLineID, claims.UserID, claims.IsAdmin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deployment, err := h.deployments.Create(c.Request.Context(), req, claims.UserID, claims.Username)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if errors.Is(err, service.ErrDeploymentNotConfigured) {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{
|
||||||
|
"error": err.Error(),
|
||||||
|
"deployment_id": deployment.DeploymentID,
|
||||||
|
"status": deployment.Status,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"deployment_id": deployment.DeploymentID, "status": deployment.Status})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) Get(c *gin.Context) {
|
||||||
|
deployment, ok := h.getAuthorizedDeployment(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events, err := h.deployments.Events(c.Request.Context(), deployment.DeploymentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deployment": deployment, "events": events})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) Events(c *gin.Context) {
|
||||||
|
deployment, ok := h.getAuthorizedDeployment(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events, err := h.deployments.Events(c.Request.Context(), deployment.DeploymentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w := c.Writer
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
for _, event := range events {
|
||||||
|
if err := writeSSE(w, event.Type, service.DeploymentEventData(event)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isTerminalDeploymentStatus(deployment.Status) {
|
||||||
|
_ = writeSSE(w, service.DeploymentEventDone, gin.H{"status": deployment.Status})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, unsubscribe := h.deployments.Subscribe(deployment.DeploymentID)
|
||||||
|
defer unsubscribe()
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
return
|
||||||
|
case event := <-ch:
|
||||||
|
if err := writeSSE(w, event.Event.Type, event.Data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Event.Type == service.DeploymentEventDone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) Cancel(c *gin.Context) {
|
||||||
|
deployment, ok := h.getAuthorizedDeployment(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.deployments.Cancel(c.Request.Context(), deployment.DeploymentID); err != nil {
|
||||||
|
writeDeploymentError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"deployment_id": deployment.DeploymentID, "status": service.DeploymentStatusCanceling})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) InternalEvent(c *gin.Context) {
|
||||||
|
if !h.authorizeInternal(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deploymentID := strings.TrimSpace(c.Param("id"))
|
||||||
|
var req service.DeploymentEventRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event, err := h.deployments.AppendEvent(c.Request.Context(), deploymentID, req)
|
||||||
|
if err != nil {
|
||||||
|
writeDeploymentError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"event_id": event.ID, "seq": event.Seq})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) InternalFinish(c *gin.Context) {
|
||||||
|
if !h.authorizeInternal(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deploymentID := strings.TrimSpace(c.Param("id"))
|
||||||
|
var req service.DeploymentFinishRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deployment, err := h.deployments.Finish(c.Request.Context(), deploymentID, req)
|
||||||
|
if err != nil {
|
||||||
|
writeDeploymentError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"deployment_id": deployment.DeploymentID, "status": deployment.Status})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) getAuthorizedDeployment(c *gin.Context) (model.Deployment, bool) {
|
||||||
|
claims, ok := CurrentClaims(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing auth claims"})
|
||||||
|
return model.Deployment{}, false
|
||||||
|
}
|
||||||
|
deploymentID := strings.TrimSpace(c.Param("id"))
|
||||||
|
deployment, err := h.deployments.Get(c.Request.Context(), deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
writeDeploymentError(c, err)
|
||||||
|
return model.Deployment{}, false
|
||||||
|
}
|
||||||
|
if !h.ensureBusinessLineMember(c, deployment.BusinessLineID, claims.UserID, claims.IsAdmin) {
|
||||||
|
return model.Deployment{}, false
|
||||||
|
}
|
||||||
|
return deployment, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) ensureBusinessLineMember(c *gin.Context, businessLineID uint64, userID uint64, isAdmin bool) bool {
|
||||||
|
if businessLineID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "business_line_id is required"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var binding model.BusinessLineUser
|
||||||
|
err := h.db.WithContext(c.Request.Context()).Where("business_line_id = ? AND user_id = ?", businessLineID, userID).First(&binding).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "current user is not assigned to this business line"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeploymentHandler) authorizeInternal(c *gin.Context) bool {
|
||||||
|
if h.cfg.AnsibleInternalToken == "" {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ansible internal token is not configured"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
value := c.GetHeader("Authorization")
|
||||||
|
if strings.TrimPrefix(value, "Bearer ") != h.cfg.AnsibleInternalToken {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid internal token"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSSE(w gin.ResponseWriter, event string, data any) error {
|
||||||
|
body, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDeploymentError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrDeploymentNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrDeploymentForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrDeploymentInvalidState):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrDeploymentNotConfigured):
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTerminalDeploymentStatus(status string) bool {
|
||||||
|
return status == service.DeploymentStatusSuccess || status == service.DeploymentStatusFailed || status == service.DeploymentStatusCanceled
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/1024XEngineer/xinfra/server/internal/model"
|
"github.com/1024XEngineer/xinfra/server/internal/model"
|
||||||
|
|
||||||
@@ -32,8 +33,33 @@ func (h *UserHandler) Me(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) List(c *gin.Context) {
|
func (h *UserHandler) List(c *gin.Context) {
|
||||||
|
claims, ok := CurrentClaims(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.Model(&model.User{}).Where("users.deleted_at IS NULL")
|
||||||
|
if businessLineID := c.Query("business_line_id"); businessLineID != "" {
|
||||||
|
if !claims.IsAdmin {
|
||||||
|
id, err := strconv.ParseUint(businessLineID, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid business line id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var binding model.BusinessLineUser
|
||||||
|
if err := h.db.Where("business_line_id = ? AND user_id = ?", id, claims.UserID).First(&binding).Error; err != nil {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "current user is not in business line"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query = query.
|
||||||
|
Joins("JOIN business_line_users ON business_line_users.user_id = users.id").
|
||||||
|
Where("business_line_users.business_line_id = ?", businessLineID)
|
||||||
|
}
|
||||||
|
|
||||||
var users []model.User
|
var users []model.User
|
||||||
if err := h.db.Where("deleted_at IS NULL").Order("id ASC").Find(&users).Error; err != nil {
|
if err := query.Order("users.id ASC").Find(&users).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,43 @@ type AccessToken struct {
|
|||||||
RevokedAt *time.Time `json:"revoked_at"`
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WayneToken struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
Account string `gorm:"size:128;not null;uniqueIndex" json:"account"`
|
||||||
|
Token string `gorm:"type:text;not null" json:"-"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Deployment struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
DeploymentID string `gorm:"size:64;not null;uniqueIndex" json:"deployment_id"`
|
||||||
|
Component string `gorm:"size:64;not null;index" json:"component"`
|
||||||
|
BusinessLineID uint64 `gorm:"not null;index" json:"business_line_id"`
|
||||||
|
BusinessLine string `gorm:"size:128;not null;default:''" json:"business_line"`
|
||||||
|
Status string `gorm:"size:32;not null;index" json:"status"`
|
||||||
|
RequestPayload string `gorm:"type:longtext" json:"request_payload"`
|
||||||
|
ResultPayload string `gorm:"type:longtext" json:"result_payload"`
|
||||||
|
CreatedBy uint64 `gorm:"not null;index" json:"created_by"`
|
||||||
|
CreatedByName string `gorm:"size:128;not null;default:''" json:"created_by_name"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeploymentEvent struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
DeploymentID string `gorm:"size:64;not null;index:idx_deployment_events_deployment_seq,priority:1" json:"deployment_id"`
|
||||||
|
Seq uint64 `gorm:"not null;index:idx_deployment_events_deployment_seq,priority:2" json:"seq"`
|
||||||
|
Type string `gorm:"size:32;not null;index" json:"type"`
|
||||||
|
Level string `gorm:"size:32;not null;default:''" json:"level"`
|
||||||
|
Message string `gorm:"type:longtext" json:"message"`
|
||||||
|
Payload string `gorm:"type:longtext" json:"payload"`
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type AuditLog struct {
|
type AuditLog struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
RequestID string `gorm:"size:128;not null;default:''" json:"request_id"`
|
RequestID string `gorm:"size:128;not null;default:''" json:"request_id"`
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
|
|||||||
auditService := service.NewAuditService(deps.DB)
|
auditService := service.NewAuditService(deps.DB)
|
||||||
authService := service.NewAuthService(deps.Config, deps.DB, auditService)
|
authService := service.NewAuthService(deps.Config, deps.DB, auditService)
|
||||||
wayenService := service.NewWayenService(deps.Config, deps.DB)
|
wayenService := service.NewWayenService(deps.Config, deps.DB)
|
||||||
wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config)
|
wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config, deps.DB)
|
||||||
|
deploymentService := service.NewDeploymentService(deps.Config, deps.DB)
|
||||||
|
|
||||||
healthHandler := handler.NewHealthHandler(deps.DB)
|
healthHandler := handler.NewHealthHandler(deps.DB)
|
||||||
authHandler := handler.NewAuthHandler(deps.Config, authService)
|
authHandler := handler.NewAuthHandler(deps.Config, authService)
|
||||||
@@ -81,6 +82,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
|
|||||||
clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService)
|
clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService)
|
||||||
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
|
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
|
||||||
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
|
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
|
||||||
|
deploymentHandler := handler.NewDeploymentHandler(deps.Config, deps.DB, deploymentService)
|
||||||
|
|
||||||
r.GET("/healthz", healthHandler.Healthz)
|
r.GET("/healthz", healthHandler.Healthz)
|
||||||
r.GET("/readyz", healthHandler.Readyz)
|
r.GET("/readyz", healthHandler.Readyz)
|
||||||
@@ -89,6 +91,8 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
|
|||||||
r.POST("/auth/oauth/token", oauthHandler.Token)
|
r.POST("/auth/oauth/token", oauthHandler.Token)
|
||||||
r.GET("/auth/oauth/jwks", oauthHandler.JWKS)
|
r.GET("/auth/oauth/jwks", oauthHandler.JWKS)
|
||||||
r.GET("/auth/oauth/userinfo", oauthHandler.UserInfo)
|
r.GET("/auth/oauth/userinfo", oauthHandler.UserInfo)
|
||||||
|
r.POST("/auth/internal/deployments/:id/events", deploymentHandler.InternalEvent)
|
||||||
|
r.POST("/auth/internal/deployments/:id/finish", deploymentHandler.InternalFinish)
|
||||||
|
|
||||||
v1 := r.Group("/auth/api/v1")
|
v1 := r.Group("/auth/api/v1")
|
||||||
{
|
{
|
||||||
@@ -130,6 +134,10 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
|
|||||||
protected.PUT("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.BindWayneNamespaceRoles)
|
protected.PUT("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.BindWayneNamespaceRoles)
|
||||||
protected.DELETE("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.UnbindWayneNamespaceRoles)
|
protected.DELETE("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.UnbindWayneNamespaceRoles)
|
||||||
protected.POST("/subsystem-auth/wayne/business-lines/:id/users/:userid/init", subsystemAuthHandler.InitWayneBusinessLineUser)
|
protected.POST("/subsystem-auth/wayne/business-lines/:id/users/:userid/init", subsystemAuthHandler.InitWayneBusinessLineUser)
|
||||||
|
protected.POST("/deployments", deploymentHandler.Create)
|
||||||
|
protected.GET("/deployments/:id", deploymentHandler.Get)
|
||||||
|
protected.GET("/deployments/:id/events", deploymentHandler.Events)
|
||||||
|
protected.POST("/deployments/:id/cancel", deploymentHandler.Cancel)
|
||||||
protected.GET("/clouddm/login", clouddmHandler.Login)
|
protected.GET("/clouddm/login", clouddmHandler.Login)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/1024XEngineer/xinfra/server/internal/config"
|
||||||
|
"github.com/1024XEngineer/xinfra/server/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeploymentStatusPending = "pending"
|
||||||
|
DeploymentStatusRunning = "running"
|
||||||
|
DeploymentStatusSuccess = "success"
|
||||||
|
DeploymentStatusFailed = "failed"
|
||||||
|
DeploymentStatusCanceling = "canceling"
|
||||||
|
DeploymentStatusCanceled = "canceled"
|
||||||
|
|
||||||
|
DeploymentEventLog = "log"
|
||||||
|
DeploymentEventStatus = "status"
|
||||||
|
DeploymentEventResult = "result"
|
||||||
|
DeploymentEventDone = "done"
|
||||||
|
DeploymentEventError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDeploymentNotConfigured = errors.New("ansible deployment service is not configured")
|
||||||
|
ErrDeploymentNotFound = errors.New("deployment not found")
|
||||||
|
ErrDeploymentForbidden = errors.New("deployment permission denied")
|
||||||
|
ErrDeploymentInvalidState = errors.New("deployment state does not allow this operation")
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeploymentCreateRequest struct {
|
||||||
|
Component string `json:"component"`
|
||||||
|
BusinessLineID uint64 `json:"business_line_id"`
|
||||||
|
Params map[string]any `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeploymentEventRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Seq uint64 `json:"seq"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeploymentFinishRequest struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Summary map[string]any `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeploymentEventEnvelope struct {
|
||||||
|
Event model.DeploymentEvent
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeploymentService struct {
|
||||||
|
cfg config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
subscribers map[string]map[chan DeploymentEventEnvelope]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeploymentService(cfg config.Config, db *gorm.DB) *DeploymentService {
|
||||||
|
return &DeploymentService{
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
client: &http.Client{Timeout: 8 * time.Second},
|
||||||
|
subscribers: make(map[string]map[chan DeploymentEventEnvelope]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) Create(ctx context.Context, req DeploymentCreateRequest, actorID uint64, actorName string) (model.Deployment, error) {
|
||||||
|
component := strings.TrimSpace(strings.ToLower(req.Component))
|
||||||
|
if !isSupportedDeploymentComponent(component) {
|
||||||
|
return model.Deployment{}, fmt.Errorf("unsupported deployment component: %s", req.Component)
|
||||||
|
}
|
||||||
|
if req.BusinessLineID == 0 {
|
||||||
|
return model.Deployment{}, errors.New("business_line_id is required")
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return model.Deployment{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var businessLine model.BusinessLine
|
||||||
|
if err := s.db.WithContext(ctx).First(&businessLine, req.BusinessLineID).Error; err != nil {
|
||||||
|
return model.Deployment{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment := model.Deployment{
|
||||||
|
DeploymentID: newDeploymentID(),
|
||||||
|
Component: component,
|
||||||
|
BusinessLineID: req.BusinessLineID,
|
||||||
|
BusinessLine: businessLine.Name,
|
||||||
|
Status: DeploymentStatusPending,
|
||||||
|
RequestPayload: string(payload),
|
||||||
|
CreatedBy: actorID,
|
||||||
|
CreatedByName: actorName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&deployment).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := s.appendEventTx(ctx, tx, deployment.DeploymentID, DeploymentEventStatus, "info", "deployment created", map[string]any{"status": DeploymentStatusPending})
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return model.Deployment{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.startPythonDeployment(ctx, deployment, req.Params); err != nil {
|
||||||
|
_ = s.FailStart(ctx, deployment.DeploymentID, err)
|
||||||
|
return deployment, err
|
||||||
|
}
|
||||||
|
return deployment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) Get(ctx context.Context, deploymentID string) (model.Deployment, error) {
|
||||||
|
var deployment model.Deployment
|
||||||
|
if err := s.db.WithContext(ctx).Where("deployment_id = ?", deploymentID).First(&deployment).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return deployment, ErrDeploymentNotFound
|
||||||
|
}
|
||||||
|
return deployment, err
|
||||||
|
}
|
||||||
|
return deployment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) Events(ctx context.Context, deploymentID string) ([]model.DeploymentEvent, error) {
|
||||||
|
var events []model.DeploymentEvent
|
||||||
|
err := s.db.WithContext(ctx).Where("deployment_id = ?", deploymentID).Order("seq ASC").Find(&events).Error
|
||||||
|
return events, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) AppendEvent(ctx context.Context, deploymentID string, req DeploymentEventRequest) (model.DeploymentEvent, error) {
|
||||||
|
eventType := normalizeDeploymentEventType(req.Type)
|
||||||
|
level := strings.TrimSpace(req.Level)
|
||||||
|
if level == "" {
|
||||||
|
level = "info"
|
||||||
|
}
|
||||||
|
payload := req.Payload
|
||||||
|
if payload == nil {
|
||||||
|
payload = map[string]any{}
|
||||||
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
payload["status"] = normalizeDeploymentStatus(req.Status)
|
||||||
|
}
|
||||||
|
event, err := s.appendEvent(ctx, deploymentID, eventType, level, req.Message, payload)
|
||||||
|
if err != nil {
|
||||||
|
return event, err
|
||||||
|
}
|
||||||
|
if status, _ := payload["status"].(string); status != "" {
|
||||||
|
_ = s.updateStatus(ctx, deploymentID, status, "")
|
||||||
|
}
|
||||||
|
return event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) Finish(ctx context.Context, deploymentID string, req DeploymentFinishRequest) (model.Deployment, error) {
|
||||||
|
status := normalizeDeploymentStatus(req.Status)
|
||||||
|
if status == "" {
|
||||||
|
status = DeploymentStatusFailed
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"exit_code": req.ExitCode,
|
||||||
|
"summary": req.Summary,
|
||||||
|
}
|
||||||
|
if req.Error != "" {
|
||||||
|
payload["error"] = req.Error
|
||||||
|
}
|
||||||
|
result, _ := json.Marshal(payload)
|
||||||
|
now := time.Now()
|
||||||
|
var deployment model.Deployment
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("deployment_id = ?", deploymentID).First(&deployment).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrDeploymentNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates := map[string]any{"status": status, "result_payload": string(result), "finished_at": &now}
|
||||||
|
if err := tx.Model(&model.Deployment{}).Where("deployment_id = ?", deploymentID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
eventType := DeploymentEventResult
|
||||||
|
if status == DeploymentStatusFailed || status == DeploymentStatusCanceled {
|
||||||
|
eventType = DeploymentEventError
|
||||||
|
}
|
||||||
|
if _, err := s.appendEventTx(ctx, tx, deploymentID, eventType, eventLevelForStatus(status), finishMessage(status, req.Error), payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := s.appendEventTx(ctx, tx, deploymentID, DeploymentEventDone, eventLevelForStatus(status), status, map[string]any{"status": status})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return deployment, err
|
||||||
|
}
|
||||||
|
deployment.Status = status
|
||||||
|
deployment.ResultPayload = string(result)
|
||||||
|
deployment.FinishedAt = &now
|
||||||
|
return deployment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) Cancel(ctx context.Context, deploymentID string) error {
|
||||||
|
deployment, err := s.Get(ctx, deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !deploymentCancelable(deployment.Status) {
|
||||||
|
return ErrDeploymentInvalidState
|
||||||
|
}
|
||||||
|
if err := s.updateStatus(ctx, deploymentID, DeploymentStatusCanceling, "cancel requested"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.cfg.AnsibleServiceBaseURL == "" {
|
||||||
|
return ErrDeploymentNotConfigured
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{"deployment_id": deploymentID})
|
||||||
|
path := s.cfg.AnsibleServiceBaseURL + "/internal/ansible/deployments/" + deploymentID + "/cancel"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if s.cfg.AnsibleInternalToken != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.AnsibleInternalToken)
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return fmt.Errorf("ansible cancel failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) Subscribe(deploymentID string) (chan DeploymentEventEnvelope, func()) {
|
||||||
|
ch := make(chan DeploymentEventEnvelope, 64)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.subscribers[deploymentID] == nil {
|
||||||
|
s.subscribers[deploymentID] = make(map[chan DeploymentEventEnvelope]struct{})
|
||||||
|
}
|
||||||
|
s.subscribers[deploymentID][ch] = struct{}{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return ch, func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if subscribers := s.subscribers[deploymentID]; subscribers != nil {
|
||||||
|
delete(subscribers, ch)
|
||||||
|
if len(subscribers) == 0 {
|
||||||
|
delete(s.subscribers, deploymentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) FailStart(ctx context.Context, deploymentID string, cause error) error {
|
||||||
|
_, err := s.Finish(ctx, deploymentID, DeploymentFinishRequest{
|
||||||
|
Status: DeploymentStatusFailed,
|
||||||
|
Error: cause.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) startPythonDeployment(ctx context.Context, deployment model.Deployment, params map[string]any) error {
|
||||||
|
if s.cfg.AnsibleServiceBaseURL == "" {
|
||||||
|
return ErrDeploymentNotConfigured
|
||||||
|
}
|
||||||
|
callbackBaseURL := strings.TrimRight(s.cfg.DeploymentCallbackBaseURL, "/")
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"deployment_id": deployment.DeploymentID,
|
||||||
|
"component": deployment.Component,
|
||||||
|
"callback_url": callbackBaseURL + "/auth/internal/deployments/" + deployment.DeploymentID + "/events",
|
||||||
|
"finish_url": callbackBaseURL + "/auth/internal/deployments/" + deployment.DeploymentID + "/finish",
|
||||||
|
"params": params,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AnsibleServiceBaseURL+"/internal/ansible/deploy", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if s.cfg.AnsibleInternalToken != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.AnsibleInternalToken)
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return fmt.Errorf("ansible deploy failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) appendEvent(ctx context.Context, deploymentID, eventType, level, message string, payload map[string]any) (model.DeploymentEvent, error) {
|
||||||
|
var event model.DeploymentEvent
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
event, err = s.appendEventTx(ctx, tx, deploymentID, eventType, level, message, payload)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return event, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) appendEventTx(ctx context.Context, tx *gorm.DB, deploymentID, eventType, level, message string, payload map[string]any) (model.DeploymentEvent, error) {
|
||||||
|
var deployment model.Deployment
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("deployment_id = ?", deploymentID).First(&deployment).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return model.DeploymentEvent{}, ErrDeploymentNotFound
|
||||||
|
}
|
||||||
|
return model.DeploymentEvent{}, err
|
||||||
|
}
|
||||||
|
var maxSeq uint64
|
||||||
|
if err := tx.Model(&model.DeploymentEvent{}).Where("deployment_id = ?", deploymentID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq).Error; err != nil {
|
||||||
|
return model.DeploymentEvent{}, err
|
||||||
|
}
|
||||||
|
payloadBody, _ := json.Marshal(payload)
|
||||||
|
event := model.DeploymentEvent{
|
||||||
|
DeploymentID: deploymentID,
|
||||||
|
Seq: maxSeq + 1,
|
||||||
|
Type: eventType,
|
||||||
|
Level: level,
|
||||||
|
Message: message,
|
||||||
|
Payload: string(payloadBody),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&event).Error; err != nil {
|
||||||
|
return model.DeploymentEvent{}, err
|
||||||
|
}
|
||||||
|
go s.broadcast(event, payload)
|
||||||
|
return event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) updateStatus(ctx context.Context, deploymentID string, status string, message string) error {
|
||||||
|
status = normalizeDeploymentStatus(status)
|
||||||
|
if status == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]any{"status": status}
|
||||||
|
if status == DeploymentStatusRunning {
|
||||||
|
updates["started_at"] = &now
|
||||||
|
}
|
||||||
|
if isDeploymentTerminal(status) {
|
||||||
|
updates["finished_at"] = &now
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.Deployment{}).Where("deployment_id = ?", deploymentID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if message != "" {
|
||||||
|
_, err := s.appendEvent(ctx, deploymentID, DeploymentEventStatus, eventLevelForStatus(status), message, map[string]any{"status": status})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeploymentService) broadcast(event model.DeploymentEvent, data map[string]any) {
|
||||||
|
envelope := DeploymentEventEnvelope{Event: event, Data: eventData(event, data)}
|
||||||
|
s.mu.Lock()
|
||||||
|
subscribers := make([]chan DeploymentEventEnvelope, 0, len(s.subscribers[event.DeploymentID]))
|
||||||
|
for ch := range s.subscribers[event.DeploymentID] {
|
||||||
|
subscribers = append(subscribers, ch)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, ch := range subscribers {
|
||||||
|
select {
|
||||||
|
case ch <- envelope:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventData(event model.DeploymentEvent, data map[string]any) map[string]any {
|
||||||
|
out := map[string]any{
|
||||||
|
"deployment_id": event.DeploymentID,
|
||||||
|
"seq": event.Seq,
|
||||||
|
"type": event.Type,
|
||||||
|
"level": event.Level,
|
||||||
|
"message": event.Message,
|
||||||
|
"created_at": event.CreatedAt,
|
||||||
|
}
|
||||||
|
for key, value := range data {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeploymentEventData(event model.DeploymentEvent) map[string]any {
|
||||||
|
payload := map[string]any{}
|
||||||
|
if strings.TrimSpace(event.Payload) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(event.Payload), &payload)
|
||||||
|
}
|
||||||
|
return eventData(event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSupportedDeploymentComponent(component string) bool {
|
||||||
|
switch component {
|
||||||
|
case "mysql", "openresty":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeploymentEventType(value string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
|
case DeploymentEventStatus:
|
||||||
|
return DeploymentEventStatus
|
||||||
|
case DeploymentEventResult:
|
||||||
|
return DeploymentEventResult
|
||||||
|
case DeploymentEventDone:
|
||||||
|
return DeploymentEventDone
|
||||||
|
case DeploymentEventError:
|
||||||
|
return DeploymentEventError
|
||||||
|
default:
|
||||||
|
return DeploymentEventLog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeploymentStatus(value string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
|
case DeploymentStatusPending, DeploymentStatusRunning, DeploymentStatusSuccess, DeploymentStatusFailed, DeploymentStatusCanceling, DeploymentStatusCanceled:
|
||||||
|
return strings.TrimSpace(strings.ToLower(value))
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDeploymentTerminal(status string) bool {
|
||||||
|
return status == DeploymentStatusSuccess || status == DeploymentStatusFailed || status == DeploymentStatusCanceled
|
||||||
|
}
|
||||||
|
|
||||||
|
func deploymentCancelable(status string) bool {
|
||||||
|
return status == DeploymentStatusPending || status == DeploymentStatusRunning || status == DeploymentStatusCanceling
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventLevelForStatus(status string) string {
|
||||||
|
if status == DeploymentStatusFailed || status == DeploymentStatusCanceled {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
return "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishMessage(status, fallback string) string {
|
||||||
|
if fallback != "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case DeploymentStatusSuccess:
|
||||||
|
return "deployment completed"
|
||||||
|
case DeploymentStatusCanceled:
|
||||||
|
return "deployment canceled"
|
||||||
|
default:
|
||||||
|
return "deployment failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDeploymentID() string {
|
||||||
|
now := time.Now()
|
||||||
|
buf := make([]byte, 3)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("CMP-%s-%d", now.Format("20060102"), now.UnixNano()%1000000)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("CMP-%s-%s", now.Format("20060102"), strings.ToUpper(hex.EncodeToString(buf)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func SortedDeploymentEvents(events []model.DeploymentEvent) {
|
||||||
|
sort.Slice(events, func(i, j int) bool {
|
||||||
|
return events[i].Seq < events[j].Seq
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,14 +16,19 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/1024XEngineer/xinfra/server/internal/config"
|
"github.com/1024XEngineer/xinfra/server/internal/config"
|
||||||
"github.com/1024XEngineer/xinfra/server/internal/wayne"
|
"github.com/1024XEngineer/xinfra/server/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrWayneRoleBindingNotConfigured = errors.New("wayne internal role binding api is not configured")
|
ErrWayneRoleBindingNotConfigured = errors.New("wayne native api is not configured")
|
||||||
ErrWayneRoleBindingRequestFailed = errors.New("wayne internal role binding request failed")
|
ErrWayneRoleBindingRequestFailed = errors.New("wayne native role binding request failed")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const wayneAdminTokenAccount = "admin"
|
||||||
|
|
||||||
type WayneRoleBindingRequest struct {
|
type WayneRoleBindingRequest struct {
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
GroupIDs []uint64 `json:"groupIds,omitempty"`
|
GroupIDs []uint64 `json:"groupIds,omitempty"`
|
||||||
@@ -71,13 +77,17 @@ func (e *WayneRoleBindingHTTPError) Error() string {
|
|||||||
|
|
||||||
type WayneRoleBindingService struct {
|
type WayneRoleBindingService struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
|
db *gorm.DB
|
||||||
client *http.Client
|
client *http.Client
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
cachedToken string
|
||||||
|
cachedExp time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService {
|
func NewWayneRoleBindingService(cfg config.Config, db *gorm.DB) *WayneRoleBindingService {
|
||||||
return &WayneRoleBindingService{
|
return &WayneRoleBindingService{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -86,33 +96,33 @@ func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||||
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), operatorEmail, req)
|
return s.bindUserRoles(ctx, "namespace", namespaceID, username, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||||
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), operatorEmail, req)
|
return s.unbindUserRoles(ctx, "namespace", namespaceID, username, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||||
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), operatorEmail, req)
|
return s.bindUserRoles(ctx, "app", appID, username, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||||
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), operatorEmail, req)
|
return s.unbindUserRoles(ctx, "app", appID, username, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) {
|
||||||
return s.callRaw(ctx, http.MethodGet, "/api/v1/internal/namespaces", nil)
|
return s.callRaw(ctx, http.MethodGet, "/api/v1/namespaces", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
|
||||||
internalPath := "/api/v1/internal/groups"
|
path := "/api/v1/groups"
|
||||||
if groupType != nil {
|
if groupType != nil {
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
values.Set("type", strconv.Itoa(*groupType))
|
values.Set("type", strconv.Itoa(*groupType))
|
||||||
internalPath += "?" + values.Encode()
|
path += "?" + values.Encode()
|
||||||
}
|
}
|
||||||
return s.callRaw(ctx, http.MethodGet, internalPath, nil)
|
return s.callRaw(ctx, http.MethodGet, path, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) ListNamespaceRoleGroups(ctx context.Context) ([]WayneRoleGroup, error) {
|
func (s *WayneRoleBindingService) ListNamespaceRoleGroups(ctx context.Context) ([]WayneRoleGroup, error) {
|
||||||
@@ -146,11 +156,44 @@ func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username str
|
|||||||
if username == "" {
|
if username == "" {
|
||||||
return nil, ErrWayenEmailMissing
|
return nil, ErrWayenEmailMissing
|
||||||
}
|
}
|
||||||
return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%s/roles", url.PathEscape(username)), nil)
|
user, err := s.findUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
namespaces, err := s.listResourceIDs(ctx, "/api/v1/namespaces")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]json.RawMessage, 0, len(namespaces))
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("userId", strconv.FormatUint(user.ID, 10))
|
||||||
|
for _, namespaceID := range namespaces {
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, roleBindingBasePath("namespace", namespaceID)+"?"+values.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
results = append(results, rawWayneData(result.Body))
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]json.RawMessage{
|
||||||
|
"namespaceRoles": mustMarshalRaw(results),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WayneRoleBindingResult{StatusCode: http.StatusOK, ContentType: "application/json", Body: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) listResourceIDs(ctx context.Context, path string) ([]uint64, error) {
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseWayneResourceIDs(result.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
|
||||||
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/namespaces/%d/operator-permissions", namespaceID), operatorEmail)
|
return s.permissions(ctx, "namespace", namespaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneOperatorPermissions, error) {
|
func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneOperatorPermissions, error) {
|
||||||
@@ -162,59 +205,122 @@ func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) AppOperatorPermissions(ctx context.Context, appID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) AppOperatorPermissions(ctx context.Context, appID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
|
||||||
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/apps/%d/operator-permissions", appID), operatorEmail)
|
return s.permissions(ctx, "app", appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) call(ctx context.Context, method, internalPath, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) bindUserRoles(ctx context.Context, scope string, resourceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||||
if err := s.validateConfig(); err != nil {
|
if strings.TrimSpace(username) == "" {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
operatorEmail = strings.TrimSpace(operatorEmail)
|
|
||||||
if operatorEmail == "" {
|
|
||||||
return nil, ErrWayenEmailMissing
|
return nil, ErrWayenEmailMissing
|
||||||
}
|
}
|
||||||
|
user, err := s.findUser(ctx, username)
|
||||||
req.OperatorUserID = nil
|
|
||||||
req.OperatorName = operatorEmail
|
|
||||||
req.Username = ""
|
|
||||||
|
|
||||||
body, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.callRaw(ctx, method, internalPath, body)
|
bindings, err := s.listUserBindings(ctx, scope, resourceID, user.ID)
|
||||||
}
|
if err != nil {
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) operatorPermissions(ctx context.Context, internalPath, operatorEmail string) (*WayneRoleBindingResult, error) {
|
|
||||||
operatorEmail = strings.TrimSpace(operatorEmail)
|
|
||||||
if operatorEmail == "" {
|
|
||||||
return nil, ErrWayenEmailMissing
|
|
||||||
}
|
|
||||||
values := url.Values{}
|
|
||||||
values.Set("operatorName", operatorEmail)
|
|
||||||
return s.callRaw(ctx, http.MethodGet, internalPath+"?"+values.Encode(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalPath string, body []byte) (*WayneRoleBindingResult, error) {
|
|
||||||
if err := s.validateConfig(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body := nativeRoleBindingBody(scope, resourceID, user.ID, req.GroupIDs)
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return s.callRaw(ctx, http.MethodPost, roleBindingBasePath(scope, resourceID), body)
|
||||||
|
}
|
||||||
|
bodyWithID, err := nativeRoleBindingBodyWithID(body, bindings[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.callRaw(ctx, http.MethodPut, fmt.Sprintf("%s/%d", roleBindingBasePath(scope, resourceID), bindings[0].ID), bodyWithID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) unbindUserRoles(ctx context.Context, scope string, resourceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||||
|
if strings.TrimSpace(username) == "" {
|
||||||
|
return nil, ErrWayenEmailMissing
|
||||||
|
}
|
||||||
|
user, err := s.findUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bindings, err := s.listUserBindings(ctx, scope, resourceID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return &WayneRoleBindingResult{StatusCode: http.StatusOK, ContentType: "application/json", Body: []byte(`{"data":null}`)}, nil
|
||||||
|
}
|
||||||
|
return s.callRaw(ctx, http.MethodDelete, fmt.Sprintf("%s/%d", roleBindingBasePath(scope, resourceID), bindings[0].ID), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) permissions(ctx context.Context, scope string, resourceID uint64) (*WayneRoleBindingResult, error) {
|
||||||
|
path := fmt.Sprintf("%s/permissions/%d", roleBindingBasePath(scope, resourceID), resourceID)
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
permissions, err := parseNativePermissions(result.Body, scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]WayneOperatorPermissions{"permissions": permissions})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WayneRoleBindingResult{StatusCode: result.StatusCode, ContentType: "application/json", Body: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) findUser(ctx context.Context, username string) (*wayneNativeUser, error) {
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("name", strings.TrimSpace(username))
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, "/api/v1/users?"+values.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users, err := parseWayneUsers(result.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if sameWayneUser(user, username) {
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("wayne user %q not found", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) listUserBindings(ctx context.Context, scope string, resourceID uint64, userID uint64) ([]wayneRoleBinding, error) {
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("userId", strconv.FormatUint(userID, 10))
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, roleBindingBasePath(scope, resourceID)+"?"+values.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseWayneRoleBindings(result.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, nativePath string, body []byte) (*WayneRoleBindingResult, error) {
|
||||||
|
token, err := s.adminToken(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := s.callRawWithToken(ctx, method, nativePath, body, token)
|
||||||
|
if result != nil && result.StatusCode == http.StatusUnauthorized {
|
||||||
|
token, refreshErr := s.adminToken(ctx, true)
|
||||||
|
if refreshErr != nil {
|
||||||
|
return result, refreshErr
|
||||||
|
}
|
||||||
|
return s.callRawWithToken(ctx, method, nativePath, body, token)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) callRawWithToken(ctx context.Context, method, nativePath string, body []byte, token string) (*WayneRoleBindingResult, error) {
|
||||||
if body == nil {
|
if body == nil {
|
||||||
body = []byte{}
|
body = []byte{}
|
||||||
}
|
}
|
||||||
target, signingURI, err := s.requestURL(internalPath)
|
target, err := s.requestURL(nativePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
log.Printf(
|
log.Printf("wayne native request: method=%s target=%s body_bytes=%d", method, target, len(body))
|
||||||
"wayne role binding request: method=%s target=%s signing_uri=%s body_bytes=%d service_name=%s secret_configured=%t",
|
|
||||||
method,
|
|
||||||
target,
|
|
||||||
signingURI,
|
|
||||||
len(body),
|
|
||||||
s.cfg.WayneServiceName,
|
|
||||||
strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != "",
|
|
||||||
)
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -223,16 +329,11 @@ func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalP
|
|||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
httpReq.Header.Set("Accept", "application/json")
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
headers, err := wayne.BuildSignedHeaders(s.cfg.WayneServiceName, s.cfg.WayneServiceAPISecretKey, method, signingURI, body, s.now())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
headers.Apply(httpReq)
|
|
||||||
|
|
||||||
resp, err := s.client.Do(httpReq)
|
resp, err := s.client.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("wayne role binding request failed: method=%s target=%s error=%v", method, target, err)
|
log.Printf("wayne native request failed: method=%s target=%s error=%v", method, target, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -246,48 +347,154 @@ func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalP
|
|||||||
ContentType: resp.Header.Get("Content-Type"),
|
ContentType: resp.Header.Get("Content-Type"),
|
||||||
Body: respBody,
|
Body: respBody,
|
||||||
}
|
}
|
||||||
log.Printf(
|
log.Printf("wayne native response: method=%s target=%s status=%d content_type=%q body=%q", method, target, resp.StatusCode, result.ContentType, truncateForDebugLog(string(respBody), 512))
|
||||||
"wayne role binding response: method=%s target=%s status=%d content_type=%q body=%q",
|
|
||||||
method,
|
|
||||||
target,
|
|
||||||
resp.StatusCode,
|
|
||||||
result.ContentType,
|
|
||||||
truncateForDebugLog(string(respBody), 512),
|
|
||||||
)
|
|
||||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||||||
return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
|
return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) adminToken(ctx context.Context, forceRefresh bool) (string, error) {
|
||||||
|
if err := s.validateConfig(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
now := s.now()
|
||||||
|
if !forceRefresh && strings.TrimSpace(s.cachedToken) != "" && s.cachedExp.After(now.Add(time.Minute)) {
|
||||||
|
return s.cachedToken, nil
|
||||||
|
}
|
||||||
|
if !forceRefresh && s.db != nil {
|
||||||
|
var saved model.WayneToken
|
||||||
|
err := s.db.WithContext(ctx).Where("account = ?", wayneAdminTokenAccount).First(&saved).Error
|
||||||
|
if err == nil && strings.TrimSpace(saved.Token) != "" && saved.ExpiresAt.After(now.Add(time.Minute)) {
|
||||||
|
s.cachedToken = saved.Token
|
||||||
|
s.cachedExp = saved.ExpiresAt
|
||||||
|
return saved.Token, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token, expiresAt, err := s.loginAdmin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.cachedToken = token
|
||||||
|
s.cachedExp = expiresAt
|
||||||
|
if s.db != nil {
|
||||||
|
err = s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "account"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"token", "expires_at", "updated_at"}),
|
||||||
|
}).Create(&model.WayneToken{
|
||||||
|
Account: wayneAdminTokenAccount,
|
||||||
|
Token: token,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) loginAdmin(ctx context.Context) (string, time.Time, error) {
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"username": s.cfg.WayneAdminUsername,
|
||||||
|
"password": s.cfg.WayneAdminPassword,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
target, err := s.requestURL("/login/db")
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||||||
|
return "", time.Time{}, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
|
||||||
|
}
|
||||||
|
token, err := parseWayneLoginToken(respBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
expiresAt := wayneTokenExpiresAt(token, s.now().Add(time.Duration(s.cfg.WayneTokenTTLMinutes)*time.Minute))
|
||||||
|
return token, expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) validateConfig() error {
|
func (s *WayneRoleBindingService) validateConfig() error {
|
||||||
baseConfigured := strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL) != ""
|
baseConfigured := strings.TrimSpace(s.cfg.WayneAPIBaseURL) != ""
|
||||||
serviceConfigured := strings.TrimSpace(s.cfg.WayneServiceName) != ""
|
usernameConfigured := strings.TrimSpace(s.cfg.WayneAdminUsername) != ""
|
||||||
secretConfigured := strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != ""
|
passwordConfigured := strings.TrimSpace(s.cfg.WayneAdminPassword) != ""
|
||||||
if !baseConfigured || !serviceConfigured || !secretConfigured {
|
if !baseConfigured || !usernameConfigured || !passwordConfigured {
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"wayne role binding config invalid: base_url_configured=%t service_name_configured=%t secret_configured=%t",
|
"wayne native config invalid: base_url_configured=%t admin_username_configured=%t admin_password_configured=%t",
|
||||||
baseConfigured,
|
baseConfigured,
|
||||||
serviceConfigured,
|
usernameConfigured,
|
||||||
secretConfigured,
|
passwordConfigured,
|
||||||
)
|
)
|
||||||
return ErrWayneRoleBindingNotConfigured
|
return ErrWayneRoleBindingNotConfigured
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) requestURL(internalPath string) (string, string, error) {
|
func (s *WayneRoleBindingService) requestURL(nativePath string) (string, error) {
|
||||||
base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL), "/"))
|
base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneAPIBaseURL), "/"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if base.Scheme == "" || base.Host == "" {
|
if base.Scheme == "" || base.Host == "" {
|
||||||
return "", "", fmt.Errorf("invalid wayne internal api base url: %s", s.cfg.WayneInternalAPIBaseURL)
|
return "", fmt.Errorf("invalid wayne api base url: %s", s.cfg.WayneAPIBaseURL)
|
||||||
}
|
}
|
||||||
path, rawQuery, _ := strings.Cut(internalPath, "?")
|
path, rawQuery, _ := strings.Cut(nativePath, "?")
|
||||||
base.Path = strings.TrimRight(base.Path, "/") + path
|
base.Path = strings.TrimRight(base.Path, "/") + path
|
||||||
base.RawQuery = rawQuery
|
base.RawQuery = rawQuery
|
||||||
return base.String(), base.RequestURI(), nil
|
return base.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roleBindingBasePath(scope string, resourceID uint64) string {
|
||||||
|
switch scope {
|
||||||
|
case "app":
|
||||||
|
return fmt.Sprintf("/api/v1/apps/%d/users", resourceID)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("/api/v1/namespaces/%d/users", resourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeRoleBindingBody(scope string, resourceID, userID uint64, groupIDs []uint64) []byte {
|
||||||
|
groups := make([]map[string]uint64, 0, len(groupIDs))
|
||||||
|
for _, groupID := range groupIDs {
|
||||||
|
groups = append(groups, map[string]uint64{"id": groupID})
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"user": map[string]uint64{"id": userID},
|
||||||
|
"groups": groups,
|
||||||
|
}
|
||||||
|
if scope == "app" {
|
||||||
|
body["app"] = map[string]uint64{"id": resourceID}
|
||||||
|
} else {
|
||||||
|
body["namespace"] = map[string]uint64{"id": resourceID}
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeRoleBindingBodyWithID(body []byte, id uint64) ([]byte, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payload["id"] = id
|
||||||
|
return json.Marshal(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateForDebugLog(value string, limit int) string {
|
func truncateForDebugLog(value string, limit int) string {
|
||||||
@@ -298,42 +505,169 @@ func truncateForDebugLog(value string, limit int) string {
|
|||||||
return value[:limit] + "...(truncated)"
|
return value[:limit] + "...(truncated)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseWayneLoginToken(body []byte) (string, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
} `json:"data"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(wrapped.Data.Token)
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(wrapped.Token)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return "", fmt.Errorf("wayne login response token is empty")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wayneTokenExpiresAt(token string, fallback time.Time) time.Time {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
ExpiresAt int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil || claims.ExpiresAt <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return time.Unix(claims.ExpiresAt, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func parseWayneRoleGroups(body []byte) ([]WayneRoleGroup, error) {
|
func parseWayneRoleGroups(body []byte) ([]WayneRoleGroup, error) {
|
||||||
var wrapped struct {
|
var wrapped struct {
|
||||||
Data []WayneRoleGroup `json:"data"`
|
Data struct {
|
||||||
Items []WayneRoleGroup `json:"items"`
|
List []WayneRoleGroup `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &wrapped); err == nil {
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
if wrapped.Data != nil {
|
|
||||||
return wrapped.Data, nil
|
|
||||||
}
|
|
||||||
if wrapped.Items != nil {
|
|
||||||
return wrapped.Items, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var direct []WayneRoleGroup
|
|
||||||
if err := json.Unmarshal(body, &direct); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return direct, nil
|
return wrapped.Data.List, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseWayneOperatorPermissions(body []byte) (*WayneOperatorPermissions, error) {
|
func parseWayneOperatorPermissions(body []byte) (*WayneOperatorPermissions, error) {
|
||||||
var wrapped struct {
|
var wrapped struct {
|
||||||
Data struct {
|
|
||||||
Permissions WayneOperatorPermissions `json:"permissions"`
|
|
||||||
} `json:"data"`
|
|
||||||
Permissions WayneOperatorPermissions `json:"permissions"`
|
Permissions WayneOperatorPermissions `json:"permissions"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &wrapped); err != nil {
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if wrapped.Data.Permissions != (WayneOperatorPermissions{}) {
|
|
||||||
return &wrapped.Data.Permissions, nil
|
|
||||||
}
|
|
||||||
return &wrapped.Permissions, nil
|
return &wrapped.Permissions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseNativePermissions(body []byte, scope string) (WayneOperatorPermissions, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data map[string]map[string]bool `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return WayneOperatorPermissions{}, err
|
||||||
|
}
|
||||||
|
key := "namespaceUser"
|
||||||
|
if scope == "app" {
|
||||||
|
key = "appUser"
|
||||||
|
}
|
||||||
|
return WayneOperatorPermissions{
|
||||||
|
Create: wrapped.Data[key]["create"],
|
||||||
|
Update: wrapped.Data[key]["update"],
|
||||||
|
Delete: wrapped.Data[key]["delete"],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawWayneData(body []byte) json.RawMessage {
|
||||||
|
var wrapped struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err == nil && len(wrapped.Data) > 0 {
|
||||||
|
return wrapped.Data
|
||||||
|
}
|
||||||
|
return json.RawMessage(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalRaw(value any) json.RawMessage {
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return json.RawMessage(`null`)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWayneResourceIDs(body []byte) ([]uint64, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data struct {
|
||||||
|
List []struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
} `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resourceIDsFromItems(wrapped.Data.List), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceIDsFromItems(items []struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
}) []uint64 {
|
||||||
|
ids := make([]uint64, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ID != 0 {
|
||||||
|
ids = append(ids, item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
type wayneNativeUser struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWayneUsers(body []byte) ([]wayneNativeUser, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data struct {
|
||||||
|
List []wayneNativeUser `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return wrapped.Data.List, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameWayneUser(user wayneNativeUser, username string) bool {
|
||||||
|
username = strings.TrimSpace(strings.ToLower(username))
|
||||||
|
return strings.ToLower(strings.TrimSpace(user.Name)) == username ||
|
||||||
|
strings.ToLower(strings.TrimSpace(user.Email)) == username
|
||||||
|
}
|
||||||
|
|
||||||
|
type wayneRoleBinding struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWayneRoleBindings(body []byte) ([]wayneRoleBinding, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data struct {
|
||||||
|
List []wayneRoleBinding `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return wrapped.Data.List, nil
|
||||||
|
}
|
||||||
|
|
||||||
func isWayneVisitorRoleName(name string) bool {
|
func isWayneVisitorRoleName(name string) bool {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||||
return normalized == "访客" || normalized == "visitor" || strings.Contains(normalized, "visitor")
|
return normalized == "访客" || normalized == "visitor" || strings.Contains(normalized, "visitor")
|
||||||
|
|||||||
@@ -2,235 +2,209 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/1024XEngineer/xinfra/server/internal/config"
|
"github.com/1024XEngineer/xinfra/server/internal/config"
|
||||||
"github.com/1024XEngineer/xinfra/server/internal/wayne"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testing.T) {
|
func TestWayneRoleBindingServiceBindNamespaceUsesNativeAPI(t *testing.T) {
|
||||||
var requestPath string
|
var got []string
|
||||||
var payload WayneRoleBindingRequest
|
var updateBody map[string]any
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
requestPath = r.URL.RequestURI()
|
got = append(got, r.Method+" "+r.URL.RequestURI())
|
||||||
|
switch r.URL.RequestURI() {
|
||||||
|
case "/login/db":
|
||||||
|
assertLoginBody(t, r)
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
|
||||||
|
case "/api/v1/users?name=target%40example.com":
|
||||||
|
assertBearer(t, r)
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"list":[{"id":7,"name":"target@example.com","email":"target@example.com"}]}}`))
|
||||||
|
case "/api/v1/namespaces/1/users?userId=7":
|
||||||
|
assertBearer(t, r)
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"list":[{"id":99}]}}`))
|
||||||
|
case "/api/v1/namespaces/1/users/99":
|
||||||
|
assertBearer(t, r)
|
||||||
body := readTestBody(t, r)
|
body := readTestBody(t, r)
|
||||||
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
|
if err := json.Unmarshal(body, &updateBody); err != nil {
|
||||||
t.Fatalf("invalid signature headers: %#v body=%s", r.Header, string(body))
|
t.Fatalf("invalid update body: %v", err)
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
t.Fatalf("invalid request body: %v", err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
|
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI())
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
svc := NewWayneRoleBindingService(config.Config{
|
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
|
||||||
WayneInternalAPIBaseURL: server.URL,
|
result, err := svc.BindNamespace(context.Background(), 1, "target@example.com", "operator@example.com", WayneRoleBindingRequest{GroupIDs: []uint64{10, 11}})
|
||||||
WayneServiceName: "xinfra",
|
|
||||||
WayneServiceAPISecretKey: "service-secret",
|
|
||||||
})
|
|
||||||
svc.now = func() time.Time { return time.Unix(1721000000, 0) }
|
|
||||||
|
|
||||||
operatorUserID := uint64(123)
|
|
||||||
replace := false
|
|
||||||
result, err := svc.BindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{
|
|
||||||
Username: "target@example.com",
|
|
||||||
GroupIDs: []uint64{10, 11},
|
|
||||||
OperatorUserID: &operatorUserID,
|
|
||||||
OperatorName: "attacker@example.com",
|
|
||||||
Replace: &replace,
|
|
||||||
RequestID: "req-001",
|
|
||||||
Reason: "grant",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BindNamespace() error = %v", err)
|
t.Fatalf("BindNamespace() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.StatusCode != http.StatusOK {
|
if result.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("StatusCode = %d, want 200", result.StatusCode)
|
t.Fatalf("StatusCode = %d, want 200", result.StatusCode)
|
||||||
}
|
}
|
||||||
if requestPath != "/api/v1/internal/namespaces/1/users/target@example.com/roles" {
|
want := []string{
|
||||||
t.Fatalf("requestPath = %q", requestPath)
|
"POST /login/db",
|
||||||
|
"GET /api/v1/users?name=target%40example.com",
|
||||||
|
"GET /api/v1/namespaces/1/users?userId=7",
|
||||||
|
"PUT /api/v1/namespaces/1/users/99",
|
||||||
}
|
}
|
||||||
if payload.OperatorName != "eastsales@qiniu.com" {
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||||
t.Fatalf("OperatorName = %q, want token email", payload.OperatorName)
|
t.Fatalf("requests:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||||
}
|
}
|
||||||
if payload.OperatorUserID != nil {
|
if updateBody["id"].(float64) != 99 {
|
||||||
t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID)
|
t.Fatalf("update id = %v, want 99", updateBody["id"])
|
||||||
}
|
}
|
||||||
if payload.Username != "" {
|
if updateBody["namespace"].(map[string]any)["id"].(float64) != 1 {
|
||||||
t.Fatalf("Username should be omitted from Wayne body, got %q", payload.Username)
|
t.Fatalf("namespace body = %#v", updateBody["namespace"])
|
||||||
}
|
}
|
||||||
if payload.Replace == nil || *payload.Replace {
|
if updateBody["user"].(map[string]any)["id"].(float64) != 7 {
|
||||||
t.Fatalf("Replace = %v, want false", payload.Replace)
|
t.Fatalf("user body = %#v", updateBody["user"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) {
|
func TestParseWayneUsersSupportsNativePageList(t *testing.T) {
|
||||||
|
users, err := parseWayneUsers([]byte(`{"data":{"pageNo":1,"pageSize":10,"totalCount":1,"list":[{"id":7,"name":"target@example.com","email":"target@example.com"}]}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseWayneUsers() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(users) != 1 || users[0].ID != 7 || users[0].Name != "target@example.com" {
|
||||||
|
t.Fatalf("users = %#v", users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWayneRoleBindingServiceCreateAndDeleteNativeBindings(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error)
|
call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error)
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "unbind namespace",
|
name: "create app binding",
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
||||||
return s.UnbindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}})
|
return s.BindApp(context.Background(), 3, "target@example.com", "operator@example.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
||||||
},
|
},
|
||||||
want: "DELETE /api/v1/internal/namespaces/1/users/target@example.com/roles",
|
want: "POST /api/v1/apps/3/users",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bind app",
|
name: "delete namespace binding",
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
||||||
return s.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
return s.UnbindNamespace(context.Background(), 1, "target@example.com", "operator@example.com", WayneRoleBindingRequest{})
|
||||||
},
|
},
|
||||||
want: "PUT /api/v1/internal/apps/3/users/target@example.com/roles",
|
want: "DELETE /api/v1/namespaces/1/users/44",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unbind app",
|
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
|
||||||
return s.UnbindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
|
||||||
},
|
|
||||||
want: "DELETE /api/v1/internal/apps/3/users/target@example.com/roles",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list namespaces",
|
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
|
||||||
return s.ListNamespaces(context.Background())
|
|
||||||
},
|
|
||||||
want: "GET /api/v1/internal/namespaces",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list namespace groups",
|
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
|
||||||
groupType := 1
|
|
||||||
return s.ListGroups(context.Background(), &groupType)
|
|
||||||
},
|
|
||||||
want: "GET /api/v1/internal/groups?type=1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list all groups",
|
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
|
||||||
return s.ListGroups(context.Background(), nil)
|
|
||||||
},
|
|
||||||
want: "GET /api/v1/internal/groups",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "get user roles",
|
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
|
||||||
return s.GetUserRoles(context.Background(), "target@example.com")
|
|
||||||
},
|
|
||||||
want: "GET /api/v1/internal/users/target@example.com/roles",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "namespace operator permissions",
|
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
|
||||||
return s.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com")
|
|
||||||
},
|
|
||||||
want: "GET /api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "app operator permissions",
|
|
||||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
|
||||||
return s.AppOperatorPermissions(context.Background(), 3, "eastsales@qiniu.com")
|
|
||||||
},
|
|
||||||
want: "GET /api/v1/internal/apps/3/operator-permissions?operatorName=eastsales%40qiniu.com",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
var got string
|
var finalRequest string
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
got = r.Method + " " + r.URL.RequestURI()
|
switch {
|
||||||
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
|
case r.URL.RequestURI() == "/login/db":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
|
||||||
|
case strings.HasPrefix(r.URL.RequestURI(), "/api/v1/users?"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"list":[{"id":7,"name":"target@example.com"}]}}`))
|
||||||
|
case strings.Contains(r.URL.RequestURI(), "userId=7"):
|
||||||
|
if strings.Contains(tt.want, "POST ") {
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"list":[]}}`))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"list":[{"id":44}]}}`))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
finalRequest = r.Method + " " + r.URL.RequestURI()
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"ok":true}}`))
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
svc := NewWayneRoleBindingService(config.Config{
|
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
|
||||||
WayneInternalAPIBaseURL: server.URL,
|
|
||||||
WayneServiceName: "xinfra",
|
|
||||||
WayneServiceAPISecretKey: "service-secret",
|
|
||||||
})
|
|
||||||
if _, err := tt.call(svc); err != nil {
|
if _, err := tt.call(svc); err != nil {
|
||||||
t.Fatalf("call error = %v", err)
|
t.Fatalf("call error = %v", err)
|
||||||
}
|
}
|
||||||
if got != tt.want {
|
if finalRequest != tt.want {
|
||||||
t.Fatalf("got endpoint %q, want %q", got, tt.want)
|
t.Fatalf("final request = %q, want %q", finalRequest, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWayneRoleBindingServiceQuerySignsEmptyBodyAndQueryURI(t *testing.T) {
|
func TestWayneRoleBindingServiceRefreshesTokenAfterUnauthorized(t *testing.T) {
|
||||||
|
loginCount := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
body := readTestBody(t, r)
|
switch r.URL.RequestURI() {
|
||||||
if len(body) != 0 {
|
case "/login/db":
|
||||||
t.Fatalf("GET body length = %d, want 0", len(body))
|
loginCount++
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
|
||||||
|
case "/api/v1/groups?type=1":
|
||||||
|
if loginCount == 1 {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"msg":"expired"}`))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if r.URL.RequestURI() != "/api/v1/internal/groups?type=1" {
|
_, _ = w.Write([]byte(`{"data":{"list":[]}}`))
|
||||||
t.Fatalf("RequestURI = %q", r.URL.RequestURI())
|
default:
|
||||||
|
t.Fatalf("unexpected request %s", r.URL.RequestURI())
|
||||||
}
|
}
|
||||||
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
|
|
||||||
t.Fatalf("invalid GET signature headers: %#v", r.Header)
|
|
||||||
}
|
|
||||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
svc := NewWayneRoleBindingService(config.Config{
|
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
|
||||||
WayneInternalAPIBaseURL: server.URL,
|
|
||||||
WayneServiceName: "xinfra",
|
|
||||||
WayneServiceAPISecretKey: "service-secret",
|
|
||||||
})
|
|
||||||
groupType := 1
|
groupType := 1
|
||||||
if _, err := svc.ListGroups(context.Background(), &groupType); err != nil {
|
if _, err := svc.ListGroups(context.Background(), &groupType); err != nil {
|
||||||
t.Fatalf("ListGroups() error = %v", err)
|
t.Fatalf("ListGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if loginCount != 2 {
|
||||||
|
t.Fatalf("loginCount = %d, want 2", loginCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWayneRoleBindingServiceOperatorPermissionsSignsQueryURI(t *testing.T) {
|
func TestWayneRoleBindingServiceOperatorPermissionsFromNativePermissions(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
body := readTestBody(t, r)
|
switch r.URL.RequestURI() {
|
||||||
if r.URL.RequestURI() != "/api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com" {
|
case "/login/db":
|
||||||
t.Fatalf("RequestURI = %q", r.URL.RequestURI())
|
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
|
||||||
|
case "/api/v1/namespaces/1/users/permissions/1":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"namespaceUser":{"create":true,"update":true,"delete":false}}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s", r.URL.RequestURI())
|
||||||
}
|
}
|
||||||
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
|
|
||||||
t.Fatalf("invalid operator permissions signature headers: %#v", r.Header)
|
|
||||||
}
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"permissions":{"create":true,"update":true,"delete":false}}}`))
|
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
svc := NewWayneRoleBindingService(config.Config{
|
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
|
||||||
WayneInternalAPIBaseURL: server.URL,
|
permissions, err := svc.NamespaceOperatorPermissionsParsed(context.Background(), 1, "operator@example.com")
|
||||||
WayneServiceName: "xinfra",
|
if err != nil {
|
||||||
WayneServiceAPISecretKey: "service-secret",
|
t.Fatalf("NamespaceOperatorPermissionsParsed() error = %v", err)
|
||||||
})
|
}
|
||||||
if _, err := svc.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com"); err != nil {
|
if !permissions.Create || !permissions.Update || permissions.Delete {
|
||||||
t.Fatalf("NamespaceOperatorPermissions() error = %v", err)
|
t.Fatalf("permissions = %#v", permissions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
|
func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.RequestURI() {
|
||||||
|
case "/login/db":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
|
||||||
|
case "/api/v1/groups":
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
_, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`))
|
_, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s", r.URL.RequestURI())
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
svc := NewWayneRoleBindingService(config.Config{
|
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
|
||||||
WayneInternalAPIBaseURL: server.URL,
|
result, err := svc.ListGroups(context.Background(), nil)
|
||||||
WayneServiceName: "xinfra",
|
|
||||||
WayneServiceAPISecretKey: "service-secret",
|
|
||||||
})
|
|
||||||
result, err := svc.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
@@ -242,6 +216,39 @@ func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testWayneConfig(baseURL string) config.Config {
|
||||||
|
return config.Config{
|
||||||
|
WayneAPIBaseURL: baseURL,
|
||||||
|
WayneAdminUsername: "wayne-admin",
|
||||||
|
WayneAdminPassword: "wayne-password",
|
||||||
|
WayneTokenTTLMinutes: 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLoginBody(t *testing.T, r *http.Request) {
|
||||||
|
t.Helper()
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.Unmarshal(readTestBody(t, r), &body); err != nil {
|
||||||
|
t.Fatalf("invalid login body: %v", err)
|
||||||
|
}
|
||||||
|
if body["username"] != "wayne-admin" || body["password"] != "wayne-password" {
|
||||||
|
t.Fatalf("login body = %#v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBearer(t *testing.T, r *http.Request) {
|
||||||
|
t.Helper()
|
||||||
|
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||||
|
t.Fatalf("missing bearer header: %#v", r.Header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJWT(exp time.Time) string {
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"exp":` + strconv.FormatInt(exp.Unix(), 10) + `}`))
|
||||||
|
return header + "." + payload + ".sig"
|
||||||
|
}
|
||||||
|
|
||||||
func readTestBody(t *testing.T, r *http.Request) []byte {
|
func readTestBody(t *testing.T, r *http.Request) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
|
|||||||
Reference in New Issue
Block a user