Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auth service-to-service api #3148

Merged
merged 23 commits into from
Jan 14, 2025

Conversation

BlairCurrey
Copy link
Contributor

@BlairCurrey BlairCurrey commented Dec 3, 2024

Changes proposed in this pull request

  • Adds a new ServiceAPIServer to auth
  • Adds new tenant CRUD routes to ServiceAPIServer
  • Adds new authServiceAPIClient to backend which is used by tenant service.

Context

fixes: #3125
Opened interledger/helm-charts#47 to follow up on the env vars in the helm charts

Checklist

  • Related issues linked using fixes #number
  • Tests added/updated
  • Make sure that all checks pass
  • Bruno collection updated (if necessary)
  • Documentation issue created with user-docs label (if necessary)
  • OpenAPI specs updated (if necessary)

@github-actions github-actions bot added type: tests Testing related type: source Changes business logic pkg: auth Changes in the GNAP auth package. pkg: documentation Changes in the documentation package. labels Dec 3, 2024
@github-actions github-actions bot added the pkg: backend Changes in the backend package. label Dec 5, 2024
Comment on lines 28 to 69
private async request<T = any>(
path: string,
options: RequestInit
): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, options)

if (!response.ok) {
let errorDetails
try {
errorDetails = await response.json()
} catch {
errorDetails = { message: response.statusText }
}

throw new AuthServiceClientError(
`Auth Service Client Error: ${response.status} ${response.statusText}`,
response.status,
errorDetails
)
}

if (
response.status === 204 ||
response.headers.get('Content-Length') === '0'
) {
return undefined as T
}

const contentType = response.headers.get('Content-Type')
if (contentType && contentType.includes('application/json')) {
try {
return (await response.json()) as T
} catch (error) {
throw new AuthServiceClientError(
`Failed to parse JSON response from ${path}`,
response.status
)
}
}

return (await response.text()) as T
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted to use the native fetch here although there were some behaviors I wasnt quite sure about. I tested with axios and mirrored how that works:

  1. not OK response (400, 500) etc. throws
  2. 204 returns undefined
  3. Success without body (200, 201, etc.) returns the response.text (just '' if not set)
  4. Interface accepts a generic which asserts the return type, although technically it could still return a string or undefined in the cases described above.

Comment on lines +74 to +83
async function createTenant(
deps: ServiceDependencies,
ctx: CreateContext
): Promise<void> {
const { body } = ctx.request

await deps.tenantService.create(body)

ctx.status = 204
}
Copy link
Contributor Author

@BlairCurrey BlairCurrey Dec 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mkurapov influenced by the pattern we've been talking about in the POC meetings. Not 201 with the resource returned. Originally I went with 201 but that really should have a body: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/201

thus the 204 No Content for this and the update and delete.

Comment on lines +132 to +138
function toTenantResponse(tenant: Tenant): TenantResponse {
return {
id: tenant.id,
idpConsentUrl: tenant.idpConsentUrl,
idpSecret: tenant.idpSecret
}
}
Copy link
Contributor Author

@BlairCurrey BlairCurrey Dec 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to filter out the created, deleted, update timestamps basically. Figured we'd keep those in the return from the service layer and filter out here. Although I considered not returning them from the service since in theory maybe those are purely business logic concerns? Kinda academic at this point though.

Comment on lines 71 to 87
public tenant = {
get: (id: string) =>
this.request<Tenant>(`/tenant/${id}`, { method: 'GET' }),
create: (data: Omit<Tenant, 'id'>) =>
this.request('/tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}),
update: (id: string, data: Partial<Omit<Tenant, 'id'>>) =>
this.request(`/tenant/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}),
delete: (id: string) => this.request(`/tenant/${id}`, { method: 'DELETE' })
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could probably move these methods and the Tenant interface out of this file if we want but opted to just put it here since it's really the only thing we need for the forseeable future.

}
}

export class AuthServiceClient {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost feels like this should be a different package outside of backend but this is the only consumer at this point so I felt like this keeps it simpler.

@BlairCurrey
Copy link
Contributor Author

Not able to add an integration test yet because the backend doesnt have any endpoints that uses the backend tenant service. However, I did do some manual integration tests by adding this to the start function the backend/src/index.ts and found everything to be working as expected:

  // Test the tenant stuff. Comment out client methods and inspect db as needed to confirm.
  const authServiceClient = container.use('authServiceClient')
  const tenant = await authServiceClient.tenant.get(config.operatorTenantId)
  console.log({ tenant }) // OK

  const id = '438fa74a-fa7d-4317-9ced-dde32ece1788'

  await authServiceClient.tenant.create({
    // OK
    id,
    idpConsentUrl: 'http://localhost:3031/mock-idp2/',
    idpSecret: '3pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE='
  })

  await authServiceClient.tenant.update(id, {
    // OK
    idpConsentUrl: 'http://localhost:3031/mock-idp3/'
  })

  const deletedAt = new Date('2024-12-20T18:16:02.019Z')
  await authServiceClient.tenant.delete(id, deletedAt) // OK

@BlairCurrey BlairCurrey marked this pull request as ready for review December 20, 2024 19:34
@njlie
Copy link
Contributor

njlie commented Jan 7, 2025

Not able to add an integration test yet because the backend doesnt have any endpoints that uses the backend tenant service. However, I did do some manual integration tests by adding this to the start function the backend/src/index.ts and found everything to be working as expected:

  // Test the tenant stuff. Comment out client methods and inspect db as needed to confirm.
  const authServiceClient = container.use('authServiceClient')
  const tenant = await authServiceClient.tenant.get(config.operatorTenantId)
  console.log({ tenant }) // OK

  const id = '438fa74a-fa7d-4317-9ced-dde32ece1788'

  await authServiceClient.tenant.create({
    // OK
    id,
    idpConsentUrl: 'http://localhost:3031/mock-idp2/',
    idpSecret: '3pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE='
  })

  await authServiceClient.tenant.update(id, {
    // OK
    idpConsentUrl: 'http://localhost:3031/mock-idp3/'
  })

  const deletedAt = new Date('2024-12-20T18:16:02.019Z')
  await authServiceClient.tenant.delete(id, deletedAt) // OK

We can add the proper test for this in #3124

Copy link
Contributor

@njlie njlie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@@ -43,6 +43,7 @@ export const Config = {
authPort: envInt('AUTH_PORT', 3006),
interactionPort: envInt('INTERACTION_PORT', 3009),
introspectionPort: envInt('INTROSPECTION_PORT', 3007),
serviceAPIPort: envInt('SERVICE_API_PORT', 3011),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you think about calling this Tenant service instead?

Are you approaching this from this service being used for something else, for example?

Copy link
Contributor Author

@BlairCurrey BlairCurrey Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you approaching this from this service being used for something else, for example?

Yes, that was the idea. Figured it can serve the purpose of any service-to-service communication. No specific things in mind but if we did want to expose some other resource I dont imagine we'd want a new api just for that. And probably better to avoid renaming everything including this env var (since it would be breaking).


public tenant = {
get: (id: string) =>
this.request<Tenant>(`/tenant/${id}`, { method: 'GET' }),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need the headers object in here as well?
Can also just have it in the request fn

Copy link
Contributor Author

@BlairCurrey BlairCurrey Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second arg in request? Yeah I think we need this. Its more than just the headers. request just wraps fetch and fetch needs the the method at the very least since its used for gets, posts, delete, etc. Or maybe I'm misunderstanding the comment?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in, I think we need { 'Content-Type': 'application/json' } also for the GET method.

My suggestion is to just have { 'Content-Type': 'application/json' } once on L29

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i added it in request as suggested. Actually I dont think we need it for the GET (just for specifying format of body no?) but it shouldn't hurt in any case.

@@ -104,3 +104,8 @@ export async function verifyApiSignature(

return verifyApiSignatureDigest(signature as string, ctx.request, config)
}

// Intended for Date strings like "2024-12-05T15:10:09.545Z" (e.g., from new Date().toISOString())
export function isValidDateString(date: string): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small, but would be good to write a few tests for this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -265,6 +266,7 @@ export interface AppServices {
paymentMethodHandlerService: Promise<PaymentMethodHandlerService>
ilpPaymentService: Promise<IlpPaymentService>
localPaymentService: Promise<LocalPaymentService>
authServiceClient: AuthServiceClient
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
authServiceClient: AuthServiceClient
authServiceClient: Promise<AuthServiceClient>

Copy link
Contributor Author

@BlairCurrey BlairCurrey Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically I dont think I need this? Although I realize everything else is a promise but its driven by the factory function we pass into the singleton. I have:

  container.singleton('authServiceClient', () => {
    return new AuthServiceClient(config.authServiceApiUrl)
  })

I didn't make that async since it didn't need to be, hence the AuthServiceClient and not a promise fort he type here. I do see that we return a promise elsewhere where it doesnt seem like we need to. Recent in memory cache for example.

  container.singleton('tenantCache', async () => {
    return createInMemoryDataStore(config.localCacheDuration)
  })

So either I'm missing something or we just return a promise everywhere even when its not needed out of habit I guess.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, I think it's only out of habit. In that case, it's fine as is

@mkurapov mkurapov linked an issue Jan 14, 2025 that may be closed by this pull request
4 tasks
@BlairCurrey BlairCurrey requested a review from mkurapov January 14, 2025 18:27
@BlairCurrey BlairCurrey merged commit fd8283b into 2893/multi-tenancy-v1 Jan 14, 2025
30 of 36 checks passed
@BlairCurrey BlairCurrey deleted the bc/3125/auth-backend-service-api branch January 14, 2025 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
pkg: auth Changes in the GNAP auth package. pkg: backend Changes in the backend package. pkg: documentation Changes in the documentation package. type: source Changes business logic type: tests Testing related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Mulit-Tenant] auth Tenant Admin Graphql API
3 participants